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/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;