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
}
}