Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-pay/src/invoice/tests.rs

//! Red-first tests for the invoice vendor. The check-digit tests assert against a
//! SECOND, independent implementation written here (table Luhn, cyclic 7-3-1) so a
//! shared off-by-one cannot hide.

use super::bankfile::{self, Source};
use super::ocr;
use super::*;
use crate::{
    ChargeHandle, ChargeRequest, ChargeStatus, Currency, Market, Money, PayError, Payment,
    RawCallback,
};

// ---- independent check-digit implementations (test oracle) -----------------

/// Luhn via the doubled-digit lookup table, not arithmetic.
fn luhn_table(digits: &str) -> u8 {
    const DBL: [u8; 10] = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9];
    let mut sum = 0u32;
    for (i, c) in digits.bytes().rev().enumerate() {
        let d = c - b'0';
        sum += if i % 2 == 0 { DBL[d as usize] } else { d } as u32;
    }
    ((10 - sum % 10) % 10) as u8
}

/// Finnish 7-3-1 via an explicit cycling iterator.
fn fi_cycle(digits: &str) -> u8 {
    let weights = [7u32, 3, 1].iter().cycle();
    let sum: u32 = digits
        .bytes()
        .rev()
        .zip(weights)
        .map(|(c, w)| (c - b'0') as u32 * w)
        .sum();
    ((10 - sum % 10) % 10) as u8
}

#[test]
fn luhn_matches_the_textbook_example_and_the_table_oracle() {
    // Wikipedia's Luhn example: payload 7992739871 -> check digit 3.
    assert_eq!(ocr::luhn_check_digit("7992739871"), 3);
    assert!(ocr::luhn_verify("79927398713"));
    assert!(!ocr::luhn_verify("79927398714"));
    for body in [
        "0",
        "1",
        "12345",
        "1234567890",
        "999999999999999",
        "4992739871",
    ] {
        assert_eq!(ocr::luhn_check_digit(body), luhn_table(body), "body {body}");
    }
}

#[test]
fn bankgiro_ocr_has_length_digit_then_luhn_and_verifies() {
    // Body 12345: total length 7 -> length digit 7; Luhn over "123457".
    let n = ocr::bankgiro_ocr("12345");
    assert_eq!(n.len(), 7);
    assert_eq!(&n[..6], "123457");
    assert_eq!(n.as_bytes()[6] - b'0', luhn_table("123457"));
    assert!(ocr::bankgiro_verify(&n));
    // Wrong length digit is caught even when Luhn passes over the tampered digits.
    let t = format!("{}8", &n[..5]);
    let t = format!("{t}{}", luhn_table(&t));
    assert!(!ocr::bankgiro_verify(&t), "length digit must be checked");
    // Every body length in the spec's range round-trips.
    for len in 1..=23 {
        let body: String = (0..len)
            .map(|i| char::from(b'0' + ((i * 7 + 3) % 10) as u8))
            .collect();
        let m = ocr::bankgiro_ocr(&body);
        assert_eq!(m.len(), len + 2);
        assert_eq!(
            m.as_bytes()[len] - b'0',
            ((len + 2) % 10) as u8,
            "length digit for {len}"
        );
        assert_eq!(m.as_bytes()[len + 1] - b'0', luhn_table(&m[..len + 1]));
        assert!(ocr::bankgiro_verify(&m));
    }
    assert!(!ocr::bankgiro_verify(""), "empty");
    assert!(!ocr::bankgiro_verify("12a45"), "non-digit");
}

#[test]
fn finnish_reference_uses_7_3_1_from_the_right() {
    // Finanssiala's worked example: base 12345 -> reference 123453.
    assert_eq!(ocr::finnish_reference("12345"), "123453");
    assert!(ocr::finnish_verify("123453"));
    assert!(!ocr::finnish_verify("123454"));
    for body in ["123", "1234", "12345", "9876543210", "1234567890123456789"] {
        let r = ocr::finnish_reference(body);
        assert_eq!(
            r.as_bytes()[r.len() - 1] - b'0',
            fi_cycle(body),
            "body {body}"
        );
        assert!(ocr::finnish_verify(&r));
    }
    // Spec length: 4..=20 digits in total.
    assert!(!ocr::finnish_verify("13"), "too short");
    assert!(
        !ocr::finnish_verify(&ocr::finnish_reference("12345678901234567890")),
        "too long"
    );
    // Leading zeros are not part of the number.
    assert_eq!(ocr::finnish_reference("0012345"), "123453");
}

// ---- provider ---------------------------------------------------------------

fn provider() -> InvoiceProvider {
    InvoiceProvider::new(InvoiceConfig {
        import_token: "import-secret".into(),
        bankgiro: Some("5050-1055".into()),
    })
}

fn req(market: Market, reference: &str) -> ChargeRequest {
    ChargeRequest {
        reference: reference.into(),
        amount: Money {
            minor: 12_500,
            currency: market.currency(),
        },
        market,
        description: "gunnar pro 2026-09".into(),
        payer: None,
    }
}

#[test]
fn reserve_mints_a_valid_reference_number_per_market_and_is_idempotent() {
    let p = provider();
    let se = p.reserve(&req(Market::Se, "gunnar/acme/2026-09")).unwrap();
    assert_eq!(se.vendor, "invoice");
    assert!(
        ocr::bankgiro_verify(&se.id),
        "SE handle is a Bankgiro OCR: {}",
        se.id
    );
    let fi = p.reserve(&req(Market::Fi, "gunnar/oy/2026-09")).unwrap();
    assert!(
        ocr::finnish_verify(&fi.id),
        "FI handle is a Finnish reference: {}",
        fi.id
    );
    assert_ne!(se.id, fi.id);
    // Same reference again -> same handle, no second number minted.
    let again = p.reserve(&req(Market::Se, "gunnar/acme/2026-09")).unwrap();
    assert_eq!(again, se);
    assert_eq!(p.charge_status(&se).unwrap(), ChargeStatus::Reserved);
    assert_eq!(
        p.reference_number("gunnar/acme/2026-09").as_deref(),
        Some(se.id.as_str())
    );
}

#[test]
fn reserve_refuses_the_wrong_currency_for_the_market() {
    let p = provider();
    let mut r = req(Market::Fi, "x/y/z");
    r.amount.currency = Currency::Sek;
    match p.reserve(&r) {
        Err(PayError::Declined(m)) => assert!(m.contains("currency"), "{m}"),
        other => panic!("expected Declined, got {other:?}"),
    }
}

#[test]
fn capture_issues_the_invoice_and_is_idempotent() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    let first = p.capture(&h).unwrap();
    let ChargeStatus::Captured { vendor_ref, .. } = &first else {
        panic!("{first:?}")
    };
    assert_eq!(vendor_ref, &h.id);
    assert!(matches!(p.state(&h), Some(InvoiceState::Issued { .. })));
    let second = p.capture(&h).unwrap();
    assert_eq!(second, first, "second capture is the same issue, same time");
    assert_eq!(p.charge_status(&h).unwrap(), first);
}

#[test]
fn release_voids_and_is_idempotent_and_capture_after_void_is_declined() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    assert_eq!(p.release(&h).unwrap(), ChargeStatus::Released);
    assert_eq!(p.release(&h).unwrap(), ChargeStatus::Released);
    assert_eq!(p.charge_status(&h).unwrap(), ChargeStatus::Released);
    assert!(matches!(p.capture(&h), Err(PayError::Declined(_))));
}

#[test]
fn unknown_handle_is_unknown() {
    let p = provider();
    let h = ChargeHandle {
        vendor: "invoice".into(),
        id: "12345".into(),
    };
    assert!(matches!(
        p.charge_status(&h),
        Err(PayError::UnknownHandle(_))
    ));
    assert!(matches!(p.capture(&h), Err(PayError::UnknownHandle(_))));
    assert!(matches!(p.release(&h), Err(PayError::UnknownHandle(_))));
    let wrong_vendor = ChargeHandle {
        vendor: "stripe".into(),
        id: "pi_1".into(),
    };
    assert!(matches!(
        p.charge_status(&wrong_vendor),
        Err(PayError::UnknownHandle(_))
    ));
}

#[test]
fn payment_before_capture_is_out_of_order() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    let err = p
        .record_payment(
            &h.id,
            Money {
                minor: 12_500,
                currency: Currency::Sek,
            },
            1_700_000_000_000,
        )
        .unwrap_err();
    assert!(matches!(err, InvoiceError::OutOfOrder { .. }), "{err}");
    assert_eq!(
        p.charge_status(&h).unwrap(),
        ChargeStatus::Reserved,
        "nothing recorded"
    );
}

#[test]
fn double_record_is_idempotent_and_status_moves_to_the_settlement() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    p.capture(&h).unwrap();
    let paid_at = 1_700_000_000_000;
    let amt = Money {
        minor: 12_500,
        currency: Currency::Sek,
    };
    let s1 = p.record_payment(&h.id, amt, paid_at).unwrap();
    let s2 = p.record_payment(&h.id, amt, paid_at).unwrap();
    assert_eq!(s1, s2);
    let InvoiceState::Paid {
        paid_minor,
        paid_at: at,
        ..
    } = s2
    else {
        panic!("{s2:?}")
    };
    assert_eq!(paid_minor, 12_500, "the second import did not double-count");
    assert_eq!(at, paid_at);
    assert_eq!(
        p.charge_status(&h).unwrap(),
        ChargeStatus::Captured {
            at_unix_ms: paid_at,
            vendor_ref: h.id.clone()
        }
    );
    // The ledger reference works as the key too (an operator typing it in).
    let s3 = p.record_payment("a/b/c", amt, paid_at).unwrap();
    assert_eq!(s3, s1);
    assert!(matches!(
        p.record_payment("nope", amt, paid_at),
        Err(InvoiceError::UnknownReference(_))
    ));
}

#[test]
fn partial_payments_accumulate_until_paid() {
    let p = provider();
    let h = p.reserve(&req(Market::Fi, "a/b/c")).unwrap();
    p.capture(&h).unwrap();
    let s = p
        .record_payment(
            &h.id,
            Money {
                minor: 5_000,
                currency: Currency::Eur,
            },
            10,
        )
        .unwrap();
    assert!(
        matches!(
            s,
            InvoiceState::Issued {
                paid_minor: 5_000,
                ..
            }
        ),
        "{s:?}"
    );
    let s = p
        .record_payment(
            &h.id,
            Money {
                minor: 7_500,
                currency: Currency::Eur,
            },
            20,
        )
        .unwrap();
    assert!(
        matches!(
            s,
            InvoiceState::Paid {
                paid_minor: 12_500,
                paid_at: 20,
                ..
            }
        ),
        "{s:?}"
    );
    assert!(matches!(
        p.record_payment(
            &h.id,
            Money {
                minor: 1,
                currency: Currency::Sek
            },
            30
        ),
        Err(InvoiceError::WrongCurrency { .. })
    ));
}

#[test]
fn refund_after_payment_is_recorded_partial_allowed() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    assert!(
        matches!(
            p.refund(
                &h,
                Money {
                    minor: 1,
                    currency: Currency::Sek
                }
            ),
            Err(PayError::Declined(_))
        ),
        "before capture"
    );
    p.capture(&h).unwrap();
    p.record_payment(
        &h.id,
        Money {
            minor: 12_500,
            currency: Currency::Sek,
        },
        10,
    )
    .unwrap();
    let s = p
        .refund(
            &h,
            Money {
                minor: 2_500,
                currency: Currency::Sek,
            },
        )
        .unwrap();
    assert!(matches!(s, ChargeStatus::Captured { .. }));
    assert!(matches!(
        p.state(&h),
        Some(InvoiceState::Paid {
            refunded_minor: 2_500,
            ..
        })
    ));
    assert!(
        matches!(
            p.refund(
                &h,
                Money {
                    minor: 20_000,
                    currency: Currency::Sek
                }
            ),
            Err(PayError::Declined(_))
        ),
        "more than paid"
    );
}

// ---- bank file ----------------------------------------------------------------

/// A BgMax TK20 payment line for `reference` and `minor` öre, plus the TK15
/// deposit line that carries the payment date.
fn bgmax_body(reference: &str, minor: u64, yyyymmdd: &str) -> String {
    let tk20 = format!(
        "20{:<8}{:<25}{:0>18}{:<2}{:<2}{:<12}{}",
        "50501055", reference, minor, "  ", "  ", "", " "
    );
    let tk15 = format!(
        "15{:<35}{}{:0>5}{:0>18}{}{:0>8}{}",
        "SE1234567890", yyyymmdd, 1, minor, "SEK", 1, "K"
    );
    format!("{tk15}\r\n{tk20}\r\n")
}

/// A Finnish reference-payment (KTL) record: 90 chars fixed width.
fn ktl_body(reference: &str, cents: u64, yymmdd: &str) -> String {
    let mut s = String::new();
    s.push('3'); // 1: record type
    s.push_str("  "); // 2-3: filler
    s.push_str(&format!("{:<14}", "FI001234567890")); // 4-17 account
    s.push_str(yymmdd); // 18-23 entry date
    s.push_str(yymmdd); // 24-29 payment date
    s.push_str(&format!("{:<16}", "ARCH000000000001")); // 30-45 archive id
    s.push_str(&format!("{:0>20}", reference)); // 46-65 reference, zero padded
    s.push_str(&format!("{:<12}", "MAKSAJA OY")); // 66-77 payer name
    s.push('1'); // 78 currency code (1 = EUR)
    s.push('A'); // 79 name source
    s.push_str(&format!("{:0>10}", cents)); // 80-89 amount, cents
    s.push('0'); // 90 correction flag
    assert_eq!(s.len(), 90);
    s
}

#[test]
fn bgmax_line_parses_reference_amount_and_date() {
    let body = bgmax_body("1234567", 12_500, "20260903");
    let lines = bankfile::parse(body.as_bytes()).unwrap();
    assert_eq!(lines.len(), 1);
    let l = &lines[0];
    assert_eq!(l.source, Source::BgMax);
    assert_eq!(l.reference, "1234567");
    assert_eq!(
        l.amount,
        Money {
            minor: 12_500,
            currency: Currency::Sek
        }
    );
    assert_eq!(
        l.paid_unix_ms,
        bankfile::ymd_to_unix_ms(2026, 9, 3).unwrap()
    );
    assert_eq!(l.payer_account.as_deref(), Some("50501055"));
}

#[test]
fn ktl_line_parses_reference_amount_and_date() {
    let body = ktl_body("123453", 12_500, "260903");
    let lines = bankfile::parse(body.as_bytes()).unwrap();
    assert_eq!(lines.len(), 1);
    let l = &lines[0];
    assert_eq!(l.source, Source::Ktl);
    assert_eq!(l.reference, "123453", "leading zeros stripped");
    assert_eq!(
        l.amount,
        Money {
            minor: 12_500,
            currency: Currency::Eur
        }
    );
    assert_eq!(
        l.paid_unix_ms,
        bankfile::ymd_to_unix_ms(2026, 9, 3).unwrap()
    );
}

#[test]
fn ymd_to_unix_ms_agrees_with_known_epochs() {
    assert_eq!(bankfile::ymd_to_unix_ms(1970, 1, 1), Some(0));
    assert_eq!(bankfile::ymd_to_unix_ms(2000, 3, 1), Some(951_868_800_000));
    assert_eq!(
        bankfile::ymd_to_unix_ms(2024, 2, 29),
        Some(1_709_164_800_000)
    );
    assert_eq!(
        bankfile::ymd_to_unix_ms(2023, 2, 29),
        None,
        "not a leap year"
    );
    assert_eq!(bankfile::ymd_to_unix_ms(2026, 13, 1), None);
}

#[test]
fn a_forged_import_is_unverified_and_a_genuine_one_decodes() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    p.capture(&h).unwrap();
    let body = bgmax_body(&h.id, 12_500, "20260903");

    let forged = vec![("X-Monetize-Import-Token".to_string(), "wrong".to_string())];
    let err = p
        .parse_callback(RawCallback {
            headers: &forged,
            body: body.as_bytes(),
        })
        .unwrap_err();
    assert!(matches!(err, PayError::Unverified(_)), "{err}");
    let none: Vec<(String, String)> = vec![];
    assert!(matches!(
        p.parse_callback(RawCallback {
            headers: &none,
            body: body.as_bytes()
        }),
        Err(PayError::Unverified(_))
    ));
    assert_eq!(
        p.charge_status(&h).unwrap(),
        p.capture(&h).unwrap(),
        "nothing moved"
    );

    let genuine = vec![(
        "x-monetize-import-token".to_string(),
        "import-secret".to_string(),
    )];
    let ev = p
        .parse_callback(RawCallback {
            headers: &genuine,
            body: body.as_bytes(),
        })
        .unwrap();
    assert_eq!(ev.reference, "a/b/c");
    assert_eq!(ev.handle, h);
    let paid_at = bankfile::ymd_to_unix_ms(2026, 9, 3).unwrap();
    assert_eq!(
        ev.status,
        ChargeStatus::Captured {
            at_unix_ms: paid_at,
            vendor_ref: h.id.clone()
        }
    );
    // parse_callback verifies and decodes; it does not move the ledger.
    assert!(matches!(
        p.state(&h),
        Some(InvoiceState::Issued { paid_minor: 0, .. })
    ));

    // A line with a tampered check digit is refused even with the right token.
    let mut bad = h.id.clone().into_bytes();
    let last = bad.len() - 1;
    bad[last] = b'0' + ((bad[last] - b'0' + 1) % 10);
    let bad = String::from_utf8(bad).unwrap();
    let body_bad = bgmax_body(&bad, 12_500, "20260903");
    assert!(matches!(
        p.parse_callback(RawCallback {
            headers: &genuine,
            body: body_bad.as_bytes()
        }),
        Err(PayError::Unverified(_))
    ));
    // An unknown-but-valid number is refused too: the ledger never minted it.
    let body_unknown = bgmax_body(&ocr::bankgiro_ocr("777"), 12_500, "20260903");
    assert!(matches!(
        p.parse_callback(RawCallback {
            headers: &genuine,
            body: body_unknown.as_bytes()
        }),
        Err(PayError::Unverified(_))
    ));
}

#[test]
fn ingest_records_the_verified_line_and_a_reimport_is_a_noop() {
    let p = provider();
    let h = p.reserve(&req(Market::Se, "a/b/c")).unwrap();
    p.capture(&h).unwrap();
    let body = bgmax_body(&h.id, 12_500, "20260903");
    let genuine = vec![(
        "X-Monetize-Import-Token".to_string(),
        "import-secret".to_string(),
    )];
    let (ev, st) = p
        .ingest(RawCallback {
            headers: &genuine,
            body: body.as_bytes(),
        })
        .unwrap();
    assert!(matches!(
        st,
        InvoiceState::Paid {
            paid_minor: 12_500,
            ..
        }
    ));
    let (ev2, st2) = p
        .ingest(RawCallback {
            headers: &genuine,
            body: body.as_bytes(),
        })
        .unwrap();
    assert_eq!(ev, ev2);
    assert_eq!(st, st2);
    assert_eq!(p.charge_status(&h).unwrap(), ev.status);
}

/// R5a: the invoice ledger is the open default's vendor on the ONE box. A panic
/// that once held it must not make every later reserve, payment and status
/// panic. Every change finishes before the ledger is let go, so the ledger a
/// panic leaves holds whole entries.
#[test]
fn a_poisoned_invoice_ledger_still_reserves_and_books_payments() {
    let p = provider();
    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        let _held = p.ledger.lock();
        panic!("a panic while the ledger is held");
    }));
    assert!(p.ledger.is_poisoned());
    let h = p
        .reserve(&req(Market::Se, "gunnar/acme/2026-09"))
        .expect("a reserve after the panic");
    assert_eq!(p.charge_status(&h).unwrap(), ChargeStatus::Reserved);
    assert!(p.state(&h).is_some());
    assert_eq!(
        p.reference_number("gunnar/acme/2026-09").as_deref(),
        Some(h.id.as_str())
    );
    assert!(matches!(
        p.record_payment(
            "nobody",
            Money {
                minor: 1,
                currency: Currency::Sek
            },
            1
        ),
        Err(InvoiceError::UnknownReference(_))
    ));
}