crates/monetize-pay/src/invoice/ocr.rs
//! **Reference numbers a bank can route a payment by.** Two national formats:
//!
//! * **Bankgiro OCR (SE)** — Bankgirot's *OCR-referensnummer*: digits only, 2–25 long.
//! The last digit is a Luhn (mod-10) check digit over everything before it; the
//! digit before that is the *length digit*, the total length (including both
//! control digits) mod 10. Bankgirot lets a payee choose "hard" length control;
//! we always mint with it and always verify it, so a truncated or padded number
//! from a bank file is refused instead of matched.
//!
//! * **Finnish creditor reference (FI)** — *viitenumero*, the national (RF-less)
//! format from Finanssiala: 4–20 digits in total, the last a mod-10 check digit
//! with weights 7, 3, 1 repeating from the right over the base. Leading zeros are
//! not part of the number (banks strip them; we do too).
//!
//! The RF (ISO 11649) wrapper is not used: Swedish banks route on OCR, Finnish
//! banks accept both, and one canonical form per market keeps the ledger key simple.
/// Luhn mod-10 check digit over `digits` (ASCII digits only; anything else is
/// treated as 0 — callers validate first with [`is_digits`]).
pub fn luhn_check_digit(digits: &str) -> u8 {
let mut sum = 0u32;
for (i, b) in digits.bytes().rev().enumerate() {
let d = b.wrapping_sub(b'0').min(9) as u32;
// Rightmost digit of the base is doubled (it sits left of the check digit).
let v = if i % 2 == 0 { d * 2 } else { d };
sum += if v > 9 { v - 9 } else { v };
}
((10 - sum % 10) % 10) as u8
}
/// `true` if the last digit of `s` is the Luhn check digit of the rest.
pub fn luhn_verify(s: &str) -> bool {
if s.len() < 2 || !is_digits(s) {
return false;
}
let (base, check) = s.split_at(s.len() - 1);
luhn_check_digit(base) == check.as_bytes()[0] - b'0'
}
pub fn is_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// Mint a Bankgiro OCR from `body` (1–23 digits): body + length digit + Luhn.
pub fn bankgiro_ocr(body: &str) -> String {
assert!(is_digits(body) && body.len() <= 23, "bankgiro OCR body: 1..=23 digits");
let mut s = String::with_capacity(body.len() + 2);
s.push_str(body);
s.push(char::from(b'0' + ((body.len() + 2) % 10) as u8));
let c = luhn_check_digit(&s);
s.push(char::from(b'0' + c));
s
}
/// Verify a Bankgiro OCR with hard length control: Luhn AND the length digit.
pub fn bankgiro_verify(s: &str) -> bool {
if s.len() < 3 || s.len() > 25 || !is_digits(s) {
return false;
}
let len_digit = s.as_bytes()[s.len() - 2] - b'0';
len_digit == (s.len() % 10) as u8 && luhn_verify(s)
}
/// 7-3-1 weighted mod-10 check digit over `base` (digits, from the right).
pub fn finnish_check_digit(base: &str) -> u8 {
const W: [u32; 3] = [7, 3, 1];
let sum: u32 = base
.bytes()
.rev()
.enumerate()
.map(|(i, b)| b.wrapping_sub(b'0').min(9) as u32 * W[i % 3])
.sum();
((10 - sum % 10) % 10) as u8
}
/// Mint a Finnish creditor reference from `base` (3–19 significant digits).
/// Leading zeros are dropped first; the result is base + check digit.
pub fn finnish_reference(base: &str) -> String {
assert!(is_digits(base), "finnish reference base: digits");
let base = base.trim_start_matches('0');
let base = if base.is_empty() { "0" } else { base };
let mut s = String::with_capacity(base.len() + 1);
s.push_str(base);
s.push(char::from(b'0' + finnish_check_digit(base)));
s
}
/// Verify a Finnish creditor reference: 4–20 digits, no leading zero, 7-3-1 check.
pub fn finnish_verify(s: &str) -> bool {
if s.len() < 4 || s.len() > 20 || !is_digits(s) || s.starts_with('0') {
return false;
}
let (base, check) = s.split_at(s.len() - 1);
finnish_check_digit(base) == check.as_bytes()[0] - b'0'
}