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(_))
));
}