crates/monetize-pay/src/invoice/bankfile.rs
//! **Minimal bank-file reading: one payment line → (reference, amount, date).**
//!
//! An invoice is settled when the bank tells us money arrived against the reference
//! number. Two fixed-width formats, read only for the fields the ledger needs; the
//! importer in the server feeds each record here after splitting the file.
//!
//! ## BgMax (Bankgirot, SE) — `Source::BgMax`
//!
//! Record type in positions 1–2. We read:
//!
//! | TK | positions | field | used as |
//! |---|---|---|---|
//! | `15` deposit | 38–45 | payment date `YYYYMMDD` | `paid_unix_ms` for the TK20 lines that follow |
//! | `15` deposit | 69–71 | currency (`SEK`) | `amount.currency` |
//! | `20` payment | 3–10 | payer's bankgiro | `payer_account` |
//! | `20` payment | 11–35 | reference (OCR), left-justified | `reference` |
//! | `20` payment | 36–53 | amount in öre, zero-padded | `amount.minor` |
//!
//! A TK20 without a preceding TK15 in the same body is dated `fallback_unix_ms`
//! (the importer passes the file date; a webhook passes "now"). Other record types
//! (01, 05, 22, 23, 25–29, 70) are skipped — deductions (TK21) are deliberately NOT
//! read as payments.
//!
//! ## Finnish reference payments, KTL record (`viiteaineisto`) — `Source::Ktl`
//!
//! A 90-character record whose first character is `3`. We read:
//!
//! | positions | field | used as |
//! |---|---|---|
//! | 24–29 | payment date `YYMMDD` (20YY) | `paid_unix_ms` |
//! | 46–65 | reference, zero-padded to 20 | `reference` (leading zeros stripped) |
//! | 78 | currency code, `1` = EUR | `amount.currency` (anything else is rejected) |
//! | 80–89 | amount in cents, zero-padded | `amount.minor` |
//! | 90 | correction flag; `1` = reversal | rejected — a reversal is not a payment |
//!
//! Position 2–3 and the archive id / payer name are ignored. Finvoice (XML) is not
//! parsed here: it is an e-invoice *sending* format, not a bank statement.
use crate::{Currency, Money};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Source {
BgMax,
Ktl,
}
/// One payment the bank reports against a reference number.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PaymentLine {
pub source: Source,
/// The reference number as the bank saw it (check digits NOT yet verified).
pub reference: String,
pub amount: Money,
pub paid_unix_ms: u64,
/// The payer's account / bankgiro when the format carries it.
pub payer_account: Option<String>,
}
/// Parse a body of one or more records (CRLF or LF separated). Unknown record types
/// are skipped; a malformed record we DO read is an error, never a guess.
pub fn parse(body: &[u8]) -> Result<Vec<PaymentLine>, String> {
parse_with_fallback_date(body, crate::now_unix_ms())
}
/// Like [`parse`], with an explicit date for TK20 lines that have no TK15 before them.
pub fn parse_with_fallback_date(body: &[u8], fallback_unix_ms: u64) -> Result<Vec<PaymentLine>, String> {
let text = std::str::from_utf8(body).map_err(|e| format!("bank file is not UTF-8/ASCII: {e}"))?;
let mut out = Vec::new();
let mut bg_date = fallback_unix_ms;
let mut bg_currency = Currency::Sek;
for raw in text.lines() {
let line = raw.trim_end_matches(['\r', '\n']);
if line.is_empty() {
continue;
}
if line.starts_with("15") {
let date = field(line, 38, 45).ok_or("TK15: missing payment date")?;
bg_date = parse_ymd(date, 4).ok_or_else(|| format!("TK15: bad date {date:?}"))?;
let cur = field(line, 69, 71).unwrap_or("SEK").trim();
bg_currency = match cur {
"SEK" | "" => Currency::Sek,
"EUR" => Currency::Eur,
other => return Err(format!("TK15: unsupported currency {other:?}")),
};
} else if line.starts_with("20") {
let payer = field(line, 3, 10).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let reference = field(line, 11, 35).ok_or("TK20: missing reference")?.trim().to_string();
let amount = field(line, 36, 53).ok_or("TK20: missing amount")?.trim();
let minor = amount.parse::<u64>().map_err(|_| format!("TK20: bad amount {amount:?}"))?;
out.push(PaymentLine {
source: Source::BgMax,
reference,
amount: Money { minor, currency: bg_currency },
paid_unix_ms: bg_date,
payer_account: payer,
});
} else if line.starts_with('3') && line.len() >= 90 {
let date = field(line, 24, 29).ok_or("KTL: missing payment date")?;
let paid = parse_ymd(date, 2).ok_or_else(|| format!("KTL: bad date {date:?}"))?;
let reference = field(line, 46, 65).ok_or("KTL: missing reference")?.trim().trim_start_matches('0').to_string();
let currency = match field(line, 78, 78) {
Some("1") => Currency::Eur,
other => return Err(format!("KTL: unsupported currency code {other:?}")),
};
let amount = field(line, 80, 89).ok_or("KTL: missing amount")?.trim();
let minor = amount.parse::<u64>().map_err(|_| format!("KTL: bad amount {amount:?}"))?;
if field(line, 90, 90) == Some("1") {
return Err("KTL: reversal record (position 90 = 1) is not a payment".into());
}
out.push(PaymentLine {
source: Source::Ktl,
reference,
amount: Money { minor, currency },
paid_unix_ms: paid,
payer_account: None,
});
}
// Everything else: skipped on purpose (see module doc).
}
Ok(out)
}
/// 1-based inclusive fixed-width slice, `None` if the line is too short.
fn field(line: &str, from: usize, to: usize) -> Option<&str> {
line.get(from - 1..to)
}
/// `YYYYMMDD` (`year_digits == 4`) or `YYMMDD` (`== 2`, taken as 20YY) → unix ms.
fn parse_ymd(s: &str, year_digits: usize) -> Option<u64> {
if s.len() != year_digits + 4 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let y: i64 = s[..year_digits].parse().ok()?;
let y = if year_digits == 2 { 2000 + y } else { y };
let m: u32 = s[year_digits..year_digits + 2].parse().ok()?;
let d: u32 = s[year_digits + 2..].parse().ok()?;
ymd_to_unix_ms(y, m, d)
}
/// Re-exported so the importer has one place to look.
pub use crate::time::ymd_to_unix_ms;