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/mod.rs

//! **`invoice` — pay by bank transfer against a reference number. SE and FI, no partner.**
//!
//! The one vendor that needs no contract: monetize mints the reference number, the
//! customer pays it through their bank, the bank file says it arrived. The two-phase
//! shape maps like this:
//!
//! | trait call | invoice meaning | state |
//! |---|---|---|
//! | `reserve` | mint the reference number (Bankgiro OCR for `Market::Se`, Finnish creditor reference for `Market::Fi`); nothing is sent | `Reserved` |
//! | `capture` | **issue** the invoice — the receivable exists, the customer owes | `Issued` → `ChargeStatus::Captured` |
//! | `release` | void it (before issue: forget it; after issue, unpaid: credit note) | `Void` → `Released` |
//! | `status` | the ledger's view, fed by [`InvoiceProvider::record_payment`] | see below |
//! | `parse_callback` | verify + decode one bank-file record ([`bankfile`]) | does **not** move state |
//! | `refund` | after payment only; partial allowed; recorded, the money moves by hand | |
//!
//! **Why `Captured` at issue and not at payment.** `Payment::capture` is
//! "draw the locked money"; for an invoice the draw is the invoice itself — the
//! receivable is booked and the entitlement is granted on the customer's promise,
//! which is how B2B invoicing works. Whether the customer has actually paid is the
//! settlement, a second axis the trait does not have: [`InvoiceState`] carries it,
//! `status()` reports it by moving `Captured::at_unix_ms` from the issue time to the
//! payment time once a payment lands (`vendor_ref` is always the reference number).
//! Dunning reads [`InvoiceProvider::state`], never `status()`.
//!
//! **Verification.** A bank file is not signed. The callback is verified by the
//! import token (`X-Monetize-Import-Token`, constant-time compare) that only our own
//! importer holds, then by the reference number's check digits, then by the ledger:
//! a number this provider never minted is refused as `Unverified`, however valid it
//! looks. `parse_callback` decodes; [`InvoiceProvider::ingest`] decodes AND records —
//! that is the entry point for the bank-file importer. Re-importing the same file is
//! a no-op: a payment is keyed by (date, amount) per reference.
//!
//! **Idempotency.** `reserve` on a reference already minted returns the same handle;
//! `capture`/`release` twice return the same status; the same payment twice records
//! once. The handle id IS the reference number.
//!
//! **State lives in memory.** This is the vendor seam; the durable ledger is the
//! caller's (`ChargeHandle` "is written to the ledger before `capture` is attempted").
//! A restart forgets reservations; `status` then answers `UnknownHandle` and the
//! ledger re-`reserve`s, which mints the same number for the same reference (the
//! number is a deterministic hash of the reference).

pub mod bankfile;
pub mod ocr;
#[cfg(test)]
mod tests;

use std::collections::HashMap;
use std::fmt;
use std::sync::Mutex;

use crate::{
    ct_eq, now_unix_ms, CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Currency,
    Market, Money, PayError, RawCallback,
};

pub const VENDOR: &str = "invoice";
/// Header the importer must present. Value: `InvoiceConfig::import_token`.
pub const IMPORT_TOKEN_HEADER: &str = "x-monetize-import-token";

#[derive(Clone, Debug)]
pub struct InvoiceConfig {
    /// Shared secret the bank-file importer presents. Read it from a config file or
    /// env (`MONETIZE_INVOICE_IMPORT_TOKEN`); never a literal in the tree.
    pub import_token: String,
    /// Our Bankgiro number, printed on the invoice (`XXXX-XXXX`). Informational.
    pub bankgiro: Option<String>,
}

/// Every environment name [`InvoiceConfig::from_env`] reads, in that order. THE
/// one writer of the list: `monetize-server`'s install forwards exactly these
/// into the unit's 0600 `EnvironmentFile=`, so a name added to `from_env`
/// without being added here cannot reach a deployed service.
pub const CONFIG_ENVS: &[&str] = &["MONETIZE_INVOICE_IMPORT_TOKEN", "MONETIZE_INVOICE_BANKGIRO"];

/// How a server links this vendor: `[vendors.invoice.<instance>] enabled = true`
/// (e.g. `[vendors.invoice.gunnar]`), the two environment names above in the
/// unit's env file, nothing in the block.
pub const FACTORY: crate::factory::VendorFactory = crate::factory::VendorFactory { name: VENDOR, config_envs: CONFIG_ENVS, build };

fn build(ctx: &crate::factory::VendorBuild<'_>) -> Result<Box<dyn crate::Payment>, String> {
    let cfg = InvoiceConfig::from_env().map_err(|e| ctx.err(e))?;
    Ok(Box::new(InvoiceProvider::new(cfg)))
}

impl InvoiceConfig {
    /// `MONETIZE_INVOICE_IMPORT_TOKEN` (required), `MONETIZE_INVOICE_BANKGIRO` (optional).
    pub fn from_env() -> Result<Self, PayError> {
        let import_token = std::env::var("MONETIZE_INVOICE_IMPORT_TOKEN")
            .map_err(|_| PayError::Unavailable("invoice: MONETIZE_INVOICE_IMPORT_TOKEN not set".into()))?;
        Ok(InvoiceConfig { import_token, bankgiro: std::env::var("MONETIZE_INVOICE_BANKGIRO").ok() })
    }
}

/// The settlement axis the trait does not have.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum InvoiceState {
    /// Number minted, invoice not issued.
    Reserved,
    /// Issued; `paid_minor` so far (< amount).
    Issued { issued_at: u64, paid_minor: u64 },
    /// Paid in full (or over); `paid_at` is the payment that completed it.
    Paid { issued_at: u64, paid_at: u64, paid_minor: u64, refunded_minor: u64 },
    /// Voided before payment.
    Void,
}

#[derive(Clone, PartialEq, Eq, Debug)]
pub enum InvoiceError {
    /// Neither a reference number we minted nor a ledger reference we know.
    UnknownReference(String),
    /// A payment against an invoice that is not issued (or is void).
    OutOfOrder { reference: String, state: InvoiceState },
    WrongCurrency { expected: Currency, got: Currency },
}

impl fmt::Display for InvoiceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            InvoiceError::UnknownReference(r) => write!(f, "unknown reference {r}"),
            InvoiceError::OutOfOrder { reference, state } => {
                write!(f, "payment for {reference} out of order: invoice is {state:?}")
            }
            InvoiceError::WrongCurrency { expected, got } => {
                write!(f, "wrong currency: expected {expected:?}, got {got:?}")
            }
        }
    }
}
impl std::error::Error for InvoiceError {}

#[derive(Clone, Debug)]
enum Phase {
    Reserved,
    Issued { at: u64 },
    Void,
}

#[derive(Clone, Debug)]
struct Entry {
    reference: String,
    amount: Money,
    phase: Phase,
    /// (paid_at, minor), deduplicated on the pair.
    payments: Vec<(u64, u64)>,
    refunded_minor: u64,
}

impl Entry {
    fn state(&self) -> InvoiceState {
        match self.phase {
            Phase::Reserved => InvoiceState::Reserved,
            Phase::Void => InvoiceState::Void,
            Phase::Issued { at } => {
                let paid_minor: u64 = self.payments.iter().map(|p| p.1).sum();
                if paid_minor >= self.amount.minor {
                    // The payment that crossed the line, in order of arrival time.
                    let mut sorted = self.payments.clone();
                    sorted.sort();
                    let mut acc = 0u64;
                    let mut paid_at = 0u64;
                    for (at, minor) in sorted {
                        acc += minor;
                        paid_at = at;
                        if acc >= self.amount.minor {
                            break;
                        }
                    }
                    InvoiceState::Paid { issued_at: at, paid_at, paid_minor, refunded_minor: self.refunded_minor }
                } else {
                    InvoiceState::Issued { issued_at: at, paid_minor }
                }
            }
        }
    }

    fn charge_status(&self, id: &str) -> ChargeStatus {
        match self.state() {
            InvoiceState::Reserved => ChargeStatus::Reserved,
            InvoiceState::Void => ChargeStatus::Released,
            InvoiceState::Issued { issued_at, .. } => {
                ChargeStatus::Captured { at_unix_ms: issued_at, vendor_ref: id.to_string() }
            }
            InvoiceState::Paid { paid_at, .. } => {
                ChargeStatus::Captured { at_unix_ms: paid_at, vendor_ref: id.to_string() }
            }
        }
    }
}

#[derive(Default)]
struct Ledger {
    /// reference number → entry
    by_number: HashMap<String, Entry>,
    /// ledger reference → reference number
    by_reference: HashMap<String, String>,
}

impl Ledger {
    fn resolve(&self, key: &str) -> Option<String> {
        if self.by_number.contains_key(key) {
            Some(key.to_string())
        } else {
            self.by_reference.get(key).cloned()
        }
    }

    fn lookup(&mut self, handle: &ChargeHandle) -> Result<&mut Entry, PayError> {
        if handle.vendor != VENDOR {
            return Err(PayError::UnknownHandle(handle.clone()));
        }
        self.by_number.get_mut(&handle.id).ok_or_else(|| PayError::UnknownHandle(handle.clone()))
    }
}

pub struct InvoiceProvider {
    cfg: InvoiceConfig,
    ledger: Mutex<Ledger>,
}

impl InvoiceProvider {
    pub fn new(cfg: InvoiceConfig) -> Self {
        InvoiceProvider { cfg, ledger: Mutex::new(Ledger::default()) }
    }

    /// Our payee number for the invoice document, if configured.
    pub fn payee_bankgiro(&self) -> Option<&str> {
        self.cfg.bankgiro.as_deref()
    }

    /// The reference number minted for a ledger reference, if any.
    pub fn reference_number(&self, reference: &str) -> Option<String> {
        self.ledger.lock().unwrap_or_else(|e| e.into_inner()).by_reference.get(reference).cloned()
    }

    /// The settlement view. `None` for a handle this provider did not mint.
    pub fn state(&self, handle: &ChargeHandle) -> Option<InvoiceState> {
        if handle.vendor != VENDOR {
            return None;
        }
        self.ledger.lock().unwrap_or_else(|e| e.into_inner()).by_number.get(&handle.id).map(Entry::state)
    }

    /// **The entry point for money arriving.** `key` is the reference number from the
    /// bank file or the ledger reference an operator typed. Refused before the
    /// invoice is issued (`OutOfOrder`) — a payment we cannot book against a
    /// receivable is an operator problem, not something to absorb silently. The same
    /// (at, amount) twice records once.
    pub fn record_payment(&self, key: &str, amount: Money, at_unix_ms: u64) -> Result<InvoiceState, InvoiceError> {
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let number = ledger.resolve(key).ok_or_else(|| InvoiceError::UnknownReference(key.to_string()))?;
        let entry = ledger.by_number.get_mut(&number).ok_or_else(|| InvoiceError::UnknownReference(key.to_string()))?;
        if !matches!(entry.phase, Phase::Issued { .. }) {
            return Err(InvoiceError::OutOfOrder { reference: entry.reference.clone(), state: entry.state() });
        }
        if amount.currency != entry.amount.currency {
            return Err(InvoiceError::WrongCurrency { expected: entry.amount.currency, got: amount.currency });
        }
        let pair = (at_unix_ms, amount.minor);
        if !entry.payments.contains(&pair) {
            entry.payments.push(pair);
        }
        Ok(entry.state())
    }

    /// Verify, decode AND record one bank-file record. What the importer calls.
    pub fn ingest(&self, raw: RawCallback<'_>) -> Result<(CallbackEvent, InvoiceState), PayError> {
        let (line, event) = self.decode(raw)?;
        let state = self
            .record_payment(&event.handle.id, line.amount, line.paid_unix_ms)
            .map_err(|e| PayError::Declined(format!("invoice: {e}")))?;
        Ok((event, state))
    }

    fn decode(&self, raw: RawCallback<'_>) -> Result<(bankfile::PaymentLine, CallbackEvent), PayError> {
        // 1. The importer's token, constant-time.
        let token = raw
            .headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(IMPORT_TOKEN_HEADER))
            .map(|(_, v)| v.as_bytes())
            .unwrap_or(&[]);
        if !ct_eq(token, self.cfg.import_token.as_bytes()) {
            return Err(PayError::Unverified("invoice: import token missing or wrong".into()));
        }
        // 2. Exactly one payment record.
        let mut lines = bankfile::parse(raw.body).map_err(|e| PayError::Unverified(format!("invoice: {e}")))?;
        if lines.len() != 1 {
            return Err(PayError::Unverified(format!(
                "invoice: expected one payment record, got {}",
                lines.len()
            )));
        }
        let line = lines.remove(0);
        // 3. The number's own check digits.
        let valid = match line.source {
            bankfile::Source::BgMax => ocr::bankgiro_verify(&line.reference),
            bankfile::Source::Ktl => ocr::finnish_verify(&line.reference),
        };
        if !valid {
            return Err(PayError::Unverified(format!("invoice: reference {} fails its check digit", line.reference)));
        }
        // 4. We minted it.
        let ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let entry = ledger
            .by_number
            .get(&line.reference)
            .ok_or_else(|| PayError::Unverified(format!("invoice: reference {} was never minted here", line.reference)))?;
        let handle = ChargeHandle { vendor: VENDOR.into(), id: line.reference.clone() };
        let event = CallbackEvent {
            reference: entry.reference.clone(),
            handle,
            status: ChargeStatus::Captured { at_unix_ms: line.paid_unix_ms, vendor_ref: line.reference.clone() },
        };
        Ok((line, event))
    }
}

/// FNV-1a 64 of `reference` plus a salt, as 12 digits with a non-zero lead.
fn body_digits(reference: &str, salt: u32) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in reference.bytes().chain(salt.to_le_bytes()) {
        h ^= b as u64;
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("1{:011}", h % 100_000_000_000)
}

fn mint(market: Market, reference: &str, salt: u32) -> String {
    let body = body_digits(reference, salt);
    match market {
        Market::Se => ocr::bankgiro_ocr(&body),
        Market::Fi => ocr::finnish_reference(&body),
        // Never reached: `reserve` refuses DK before a number is minted.
        Market::Dk => body,
    }
}

#[allow(dead_code)]
impl InvoiceProvider {
    pub fn vendor(&self) -> &'static str {
        VENDOR
    }

    pub fn markets(&self) -> &[Market] {
        &[Market::Se, Market::Fi]
    }

    pub fn reserve(&self, req: &ChargeRequest) -> Result<ChargeHandle, PayError> {
        // A Danish invoice has its own reference scheme (FIK), which this vendor
        // does not mint: DK is not one of its markets, and a direct call says so.
        if !self.markets().contains(&req.market) {
            return Err(PayError::Declined(format!("invoice: {:?} is not a market this vendor invoices in (SE, FI)", req.market)));
        }
        if req.amount.currency != req.market.currency() {
            return Err(PayError::Declined(format!(
                "invoice: currency {:?} is not {:?}'s {:?}",
                req.amount.currency,
                req.market,
                req.market.currency()
            )));
        }
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        if let Some(number) = ledger.by_reference.get(&req.reference) {
            return Ok(ChargeHandle { vendor: VENDOR.into(), id: number.clone() });
        }
        let mut salt = 0u32;
        let number = loop {
            let n = mint(req.market, &req.reference, salt);
            if !ledger.by_number.contains_key(&n) {
                break n;
            }
            salt += 1;
        };
        ledger.by_number.insert(
            number.clone(),
            Entry {
                reference: req.reference.clone(),
                amount: req.amount,
                phase: Phase::Reserved,
                payments: Vec::new(),
                refunded_minor: 0,
            },
        );
        ledger.by_reference.insert(req.reference.clone(), number.clone());
        Ok(ChargeHandle { vendor: VENDOR.into(), id: number })
    }

    pub fn draw(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let entry = ledger.lookup(handle)?;
        match entry.phase {
            Phase::Reserved => entry.phase = Phase::Issued { at: now_unix_ms() },
            Phase::Issued { .. } => {}
            Phase::Void => {
                return Err(PayError::Declined(format!("invoice {} is void; reserve again", handle.id)))
            }
        }
        Ok(entry.charge_status(&handle.id))
    }

    pub fn unlock(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let entry = ledger.lookup(handle)?;
        match entry.state() {
            InvoiceState::Reserved | InvoiceState::Issued { paid_minor: 0, .. } => entry.phase = Phase::Void,
            InvoiceState::Void => {}
            InvoiceState::Issued { .. } | InvoiceState::Paid { .. } => {
                return Err(PayError::Declined(format!(
                    "invoice {} has payments recorded; refund instead of release",
                    handle.id
                )))
            }
        }
        Ok(ChargeStatus::Released)
    }

    pub fn charge_status(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let entry = ledger.lookup(handle)?;
        Ok(entry.charge_status(&handle.id))
    }

    pub fn parse_callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
        self.decode(raw).map(|(_, ev)| ev)
    }

    /// **None, and it is a fact rather than an omission.**
    ///
    /// A bankgiro invoice has no checkout page. The customer is not sent
    /// anywhere: they are given a reference number (the handle id IS the OCR /
    /// creditor reference) and they pay it from their own bank, days later,
    /// possibly by handing the slip to somebody in accounts payable. There is no
    /// URL that could mean anything here, and minting one from a template — which
    /// is what monetize did before this method existed — put a dead link in front
    /// of an operator on every invoice sale.
    pub fn redirect(&self, _handle: &ChargeHandle) -> Option<String> {
        None
    }

    /// **`None`, and for the reason [`InvoiceProvider::redirect`] is `None`:
    /// there is no page.**
    ///
    /// A signup checkout is a hosted page a stranger types a card into, and this
    /// vendor's whole shape is that there is neither — the customer is handed an
    /// OCR reference and pays it from their own bank, days later, possibly by
    /// handing the slip to somebody in accounts payable. A signup cannot wait
    /// days: the account is made when the money lands, and a visitor who had
    /// just picked a name would sit on a return page for a week.
    ///
    /// `None` is what makes a deployment whose only vendor is this one fall back
    /// honestly — `Signup.Start` answers UNIMPLEMENTED and the console keeps
    /// whatever link its operator configured. Inventing a URL here would put a
    /// dead page in front of somebody who wants to pay.
    pub fn start_signup(
        &self,
        _order: &crate::signup::SignupOrder,
    ) -> Result<Option<crate::signup::SignupCheckout>, PayError> {
        Ok(None)
    }

    /// **`None`: a bankgiro payment has no vendor between us and the money.**
    /// What the bank charges for the account is not part of this payment and is not
    /// visible from it — it is on the bank's own statement, not on the OCR line.
    pub fn settled(&self, _handle: &ChargeHandle) -> Option<crate::Settlement> {
        None
    }

    pub fn give_back(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
        let mut ledger = self.ledger.lock().unwrap_or_else(|e| e.into_inner());
        let entry = ledger.lookup(handle)?;
        if amount.currency != entry.amount.currency {
            return Err(PayError::Declined(format!(
                "invoice: refund currency {:?} is not {:?}",
                amount.currency, entry.amount.currency
            )));
        }
        let paid: u64 = entry.payments.iter().map(|p| p.1).sum();
        if paid == 0 {
            return Err(PayError::Declined(format!("invoice {}: nothing paid, nothing to refund", handle.id)));
        }
        if entry.refunded_minor + amount.minor > paid {
            return Err(PayError::Declined(format!(
                "invoice {}: refund {} exceeds paid {} minus refunded {}",
                handle.id, amount.minor, paid, entry.refunded_minor
            )));
        }
        entry.refunded_minor += amount.minor;
        Ok(entry.charge_status(&handle.id))
    }
}
#[allow(unused_variables)]
impl crate::payment::Payment for InvoiceProvider {
    fn manifest(&self) -> crate::payment::PayManifest {
        crate::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
    }
    fn page(&self, charge: &crate::payment::Charge<'_>, back: &crate::payment::Return) -> Result<crate::payment::Page, crate::PayError> {
        Ok(match charge {
            crate::payment::Charge::Order(req) => crate::payment::Page::Order { handle: self.reserve(req)?, url: None },
            crate::payment::Charge::Signup(o) => crate::payment::Page::Signup(self.start_signup(o)?),
            crate::payment::Charge::CardCheck(o) => crate::payment::Page::CardCheck(None),
            crate::payment::Charge::TestSignup(o) => crate::payment::Page::TestSignup(None),
        })
    }
    fn status(&self, of: &crate::payment::Of<'_>) -> Result<crate::payment::Standing, crate::PayError> {
        Ok(match of {
            crate::payment::Of::Charge(h) => crate::payment::Standing::Charge(self.charge_status(h)?),
            crate::payment::Of::Page(h) => crate::payment::Standing::Page(self.redirect(h)),
            crate::payment::Of::Signup { session } => crate::payment::Standing::Signup(None),
            crate::payment::Of::CardCheck { session } => crate::payment::Standing::CardCheck(None),
        })
    }
    fn capture(&self, h: &crate::ChargeHandle) -> Result<crate::ChargeStatus, crate::PayError> {
        self.draw(h)
    }
    fn release(&self, h: &crate::ChargeHandle) -> Result<crate::ChargeStatus, crate::PayError> {
        self.unlock(h)
    }
    fn refund(&self, h: &crate::ChargeHandle, amount: crate::Money) -> Result<crate::ChargeStatus, crate::PayError> {
        self.give_back(h, amount)
    }
    fn callback(&self, raw: crate::RawCallback<'_>) -> Result<crate::CallbackEvent, crate::PayError> {
        self.parse_callback(raw)
    }
    fn settlement(&self, h: &crate::ChargeHandle) -> Option<crate::Settlement> {
        self.settled(h)
    }
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<crate::reconcile::PaidAtVendor>, crate::PayError> {
        Err(crate::reconcile::cannot_be_asked())
    }
    fn livemode(&self) -> Option<bool> {
        None
    }
}