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

//! **`Payment` — how monetize takes money from an end user.** One trait, one
//! impl per vendor, each behind a cargo feature so a deployment links only the vendors
//! it has a contract with.
//!
//! # The transaction is two-phase, and this trait is shaped by it
//!
//! A payment must not draw money if the resources could not be bought: the money is
//! LOCKED for the length of the transaction. So there is no `charge`.
//! There is:
//!
//! ```text
//!   page     ──▶  (monetize provisions the resource, or confirms it is on hand)
//!                         │ ok                          │ failed
//!                         ▼                             ▼
//!                      capture                       release
//!                   money drawn                   nothing drawn
//! ```
//!
//! A reservation that is neither captured nor released is a bug the vendor will
//! eventually expire for us — but [`Payment::status`] exists so the ledger can
//! find and finish those, never guess.
//!
//! **There is no account balance.** Money that is taken sits nowhere: it is either
//! captured against a provisioned resource or it was never drawn. A vendor that cannot
//! do authorize-then-capture cannot implement this trait honestly; it must not fake it
//! with charge-then-refund, because a refund is money the customer saw leave.
//!
//! # Idempotency
//!
//! Every call carries the caller's `reference` (`ChargeRequest::reference`: product,
//! namespace, period). A vendor callback for a reference already captured must be a
//! no-op, and [`Payment::callback`] must **verify** the callback
//! (signature / shared secret / mTLS) before it is believed — a forged callback that
//! moves an entitlement is the one defect this crate exists to make impossible.
//! A callback that verifies and is about nothing we hold is [`PayError::Ignored`],
//! not [`PayError::Unverified`]: it is a delivery that succeeded, and telling the
//! vendor otherwise is how an endpoint gets disabled.
//!
//! # Vendors, in order
//!
//! | feature | market | impl | where |
//! |---|---|---|---|
//! | `invoice` | SE, FI | Bankgiro OCR / Finnish reference number; no partner needed, `status` is fed by a bank-file import or by hand | **here**, open |
//! | `sandbox` | any | infinite funds, no socket — the rehearsal vendor | **here**, open |
//! | `klarna`  | SE first | Klarna Payments: authorize → capture | `monetize-pay-impl`, private |
//! | `stripe`  | Nordic | PaymentIntent with manual capture; covers Swish, cards, SEPA, MobilePay | `monetize-pay-impl`, private |
//! | `swish`   | SE | Swish Handel (Commerce API), mTLS with a bank cert; needs the merchant agreement | `monetize-pay-impl`, private |
//!
//! The private ones plug in through [`factory`] — the server never names them.
//! [`time`], [`ct_eq`] and [`now_unix_ms`] are shared with them from here.
//!
//! Pure Rust, and nothing is shelled out to: HTTPS via `reqwest`/`hyper` on
//! `rustls`, no vendor SDK.

use std::fmt;

/// Where the customer is, which decides currency, tax and which vendors are legal.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize)]
pub enum Market {
    /// Sweden — SEK, 25 % VAT, first market.
    Se,
    /// Finland — EUR, 25.5 % VAT.
    Fi,
    /// Denmark — DKK, 25 % VAT (owner 2026-09-28: holger sells in DK; the
    /// Danish company's Stripe account, Stripe Tax exclusive).
    Dk,
}

/// Minor units (öre, cents) and the ISO 4217 code. Never a float.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Money {
    pub minor: u64,
    pub currency: Currency,
}

impl Money {
    /// **The amount as WORDS, for a customer to read.**
    ///
    /// The one writer of a rendered price in this workspace, and it exists
    /// because of a limit the CONSOLE has rather than one monetize has.
    /// `gunnar-ui` renders what monetize sends and may contain no price, no
    /// currency literal and no money arithmetic — so it cannot turn
    /// `Money { minor, currency }` into text at all: that needs a division by
    /// 100 and a map from an enum to a symbol, and both are the thing it is
    /// forbidden. It reached that conclusion twice, once for a quote and once
    /// for a purchase history, and both times the honest answer was the same:
    /// **if a price is to appear there, monetize must send it as words.**
    ///
    /// One function and not two so that a quote and a history AGREE BY
    /// CONSTRUCTION. Two formatters staying in step is a thing that holds until
    /// somebody edits one of them.
    ///
    /// # Integer arithmetic, and the ISO code rather than a symbol
    ///
    /// Never a float: `220.00 SEK` is `minor / 100` and `minor % 100`, which is
    /// exact. And the ISO 4217 code and not `kr` or `€`, because a symbol is a
    /// localisation decision — where it goes, whether it is spaced, which of
    /// the several `kr` it means — and monetize does not know the reader's
    /// locale. A code is unambiguous in every locale and wrong in none.
    ///
    /// **Both currencies here are two-decimal.** A zero-decimal currency (JPY)
    /// or a three-decimal one (KWD) would render wrongly, so the day
    /// [`Currency`] gains one this function has to gain a minor-unit exponent
    /// with it. Written down rather than discovered.
    pub fn in_words(&self) -> String {
        format!(
            "{}.{:02} {}",
            self.minor / 100,
            self.minor % 100,
            self.currency.code()
        )
    }
}

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub enum Currency {
    Sek,
    Eur,
    /// Danish kroner — two decimals (øre), like SEK and EUR.
    Dkk,
}

/// What monetize asks a vendor to reserve.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ChargeRequest {
    /// The idempotency key and the ledger key: `<product>/<namespace>/<period>`.
    pub reference: String,
    pub amount: Money,
    pub market: Market,
    /// What the customer sees on the statement / invoice line.
    pub description: String,
    /// Vendor-specific payer hint (a Swish number, a Klarna session token, a Stripe
    /// customer id). Opaque here.
    pub payer: Option<String>,
}

/// The vendor's handle for one reservation. Opaque outside the impl that minted it,
/// durable (it is written to the ledger before `capture` is attempted).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ChargeHandle {
    pub vendor: String,
    pub id: String,
}

impl ChargeHandle {
    /// **Whether this payment was real money**, by the vendor's own word — see
    /// [`livemode_of`].
    pub fn livemode(&self) -> Option<bool> {
        livemode_of(&self.vendor, &self.id)
    }
}

/// **Was this payment real money (`Some(true)`), test money (`Some(false)`),
/// or did the vendor not say (`None`)?** Read off the PAYMENT, never off a
/// label on the deployment (owner 2026-10-02: every estate is an equal estate;
/// the only difference is which keys it holds, and a console that totals money
/// must count what each payment was, not what the estate is called).
///
/// * `stripe`: Stripe writes its mode into the ids of the objects a payment is
///   made of — a Checkout Session is `cs_live_…` or `cs_test_…` — the same flag
///   its API answers as `livemode`. An id that carries neither (a bare
///   `pi_…`) is `None`, never a guess.
/// * `sandbox`: the open play vendor moves no money at all: always test.
/// * any other vendor: `None`. An invoice is not "live" because nobody said it
///   was test; the console shows such money apart from both.
///
/// This is the one place outside an impl that reads a handle's id, and it
/// reads only the mode marker the vendor itself put there.
pub fn livemode_of(vendor: &str, id: &str) -> Option<bool> {
    // ★ A32-15: the kind's own shape says what its ids mean ([`shapes`]); no vendor is named here.
    shapes::of(vendor).and_then(|s| s.mode_of(id))
}

#[cfg(test)]
mod livemode_tests {
    use super::*;

    /// ★ owner 2026-10-02: test money stays out of the live total, decided by
    /// the payment's own mode and never by the estate's name.
    #[test]
    fn a_payments_mode_is_the_vendors_word_and_silence_is_not_live() {
        assert_eq!(livemode_of("stripe", "cs_live_a1B2c3"), Some(true));
        assert_eq!(livemode_of("stripe", "cs_test_a1B2c3"), Some(false));
        assert_eq!(livemode_of("stripe", "pi_3Nx"), None, "a bare intent carries no mode: not a guess");
        assert_eq!(livemode_of("sandbox", "sbx-1"), Some(false), "play money is never real");
        assert_eq!(livemode_of("invoice", "inv_live_1"), None, "only Stripe's own marker is read");
        assert_eq!(livemode_of("stripe/gunnar", "cs_live_a1B2c3"), Some(true), "an instance is read by its kind");
        assert_eq!(livemode_of("sandbox/play", "sbx-1"), Some(false));
        assert_eq!(ChargeHandle { vendor: "stripe".into(), id: "cs_live_x".into() }.livemode(), Some(true));
    }
}

#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum ChargeStatus {
    /// **The customer has been given a way to pay, and nothing is held.**
    ///
    /// A hosted checkout page nobody has opened; an invoice nobody has paid. The
    /// attempt is live and the vendor is waiting for a person — so `reserve`
    /// succeeded, and yet no money is committed to anything.
    ///
    /// It is separate from [`ChargeStatus::Reserved`] because the transaction
    /// must ACT differently: `Reserved` means the funds are held and it is safe
    /// to spend a cloud's money provisioning against them, and this means it is
    /// not. Conflating the two is how a deployment buys iron for customers who
    /// never come back — the loss is real money at a real provider, and it was
    /// invisible while both states had one name.
    AwaitingCustomer,
    /// **Money is held at the vendor**, not yet captured or released. Safe to
    /// provision against: what is drawn later is already committed now.
    Reserved,
    /// Money drawn.
    Captured { at_unix_ms: u64, vendor_ref: String },
    /// Reservation released by us, or expired by the vendor. Nothing drawn.
    Released,
    /// The vendor refused the reservation. Nothing drawn.
    Declined { reason: String },
}

/// **What the vendor kept, and what actually arrived.**
///
/// The ledger records what the customer was charged. That is not what reached the
/// merchant: a card payment arrives minus the vendor's cut, and the cut is not
/// returned when the sale is refunded. Without this, a deployment's gross cannot be
/// reconciled against its bank statement and a refund looks free in the books when
/// it is not.
///
/// Measured, never declared: it is the vendor's own settlement row (Stripe's
/// balance transaction), read back after the money moved.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Settlement {
    /// What the vendor kept out of the captured amount. Zero is a real answer.
    pub fee: Money,
    /// What reached the merchant's balance: captured − fee.
    pub net: Money,
    /// The vendor's id for this settlement row, for an operator who has to find it
    /// at the vendor (`txn_…` at Stripe).
    pub vendor_ref: String,
    /// When the money becomes available to pay out, when the vendor says. A hold
    /// period is the difference between "captured" and "in the bank".
    pub available_unix_ms: Option<u64>,
    /// **The tax the vendor charged on top**, when it computed one (Stripe Tax's
    /// `total_details.amount_tax`). `None` = the vendor did not say, never zero:
    /// a reverse-charged EU B2B sale is `Some(0)`, and the tax report must read
    /// that 0 rather than recompute 25 % off the market (g-b2b-reverse).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tax: Option<Money>,
    /// **The rate the vendor computed that tax at**, in basis points (2500 =
    /// 25 %), for the buyer's country (owner 2026-10-11: Stripe decides per
    /// country, each payment is a row). Stripe Tax's
    /// `total_details.breakdown.taxes[].rate.percentage`; 0 for a reverse
    /// charge. `None`: the vendor did not say — never a guess off the market.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tax_basis_points: Option<u32>,
    /// **The customer's tax registration number**, as the vendor collected and
    /// validated it (Stripe `customer_details.tax_ids`, e.g. `DE123456789`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub customer_tax_id: Option<String>,
    /// **The customer's country** (ISO 3166-1 alpha-2) off the billing address,
    /// which is what the tax was computed for.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub customer_country: Option<String>,
    /// **The address the customer gave the vendor at the checkout** (Stripe
    /// `customer_details.email`). Where a "your room is ready" mail goes for an
    /// account that never signed up with one (test2, 2026-10-04: `room.ready …
    /// NOT sent — no address`). `None`: the vendor did not say.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub customer_email: Option<String>,
}

/// **Money that went BACK after it was drawn, or is contested** — the events a
/// vendor sends long after the sale closed (g-disputes, owner 2026-09-28):
/// a refund made in the vendor's dashboard, a chargeback opened, won or lost.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum ReversalKind {
    /// Money given back (by the vendor's dashboard, or by our own refund verb).
    Refunded,
    /// A chargeback was opened: the money is withdrawn from the balance while
    /// the dispute runs.
    DisputeOpened,
    /// The dispute closed in the merchant's favour: the money stays.
    DisputeWon,
    /// The dispute closed in the customer's favour: the money is gone for good.
    DisputeLost,
}

impl ReversalKind {
    /// The one spelling, for the ledger row, the log and the wire.
    pub fn name(self) -> &'static str {
        match self {
            ReversalKind::Refunded => "refunded",
            ReversalKind::DisputeOpened => "dispute-opened",
            ReversalKind::DisputeWon => "dispute-won",
            ReversalKind::DisputeLost => "dispute-lost",
        }
    }
    pub fn from_name(s: &str) -> Option<ReversalKind> {
        [ReversalKind::Refunded, ReversalKind::DisputeOpened, ReversalKind::DisputeWon, ReversalKind::DisputeLost].into_iter().find(|k| k.name() == s)
    }
    /// Did the customer end up with this money back? A refund and a lost
    /// dispute: yes. An open dispute is not decided yet; a won one kept it.
    pub fn money_went_back(self) -> bool {
        matches!(self, ReversalKind::Refunded | ReversalKind::DisputeLost)
    }
}

/// One verified reversal, re-read from the vendor (never off the webhook body).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Reversal {
    /// The vendor that verified it.
    pub vendor: String,
    /// The charge it is about, as the ledger holds it (the checkout session).
    pub handle: ChargeHandle,
    /// Our reference for the order (`metadata.reference`); EMPTY for a paid
    /// signup, which has none — then `handle` is the signup's session.
    pub reference: String,
    pub kind: ReversalKind,
    /// The vendor's id for THIS reversal (`re_…`, `dp_…`) — the idempotency key
    /// of recording it: the same id twice is one row.
    pub id: String,
    /// How much it is about. **For a refund: the charge's refunded TOTAL so
    /// far, as re-read from the vendor** (Stripe's `amount_refunded`), never
    /// one refund's own amount. The ledger keeps the largest; the same total
    /// under another id is the echo of a refund already written, and a total
    /// below the recorded one is refused as a broken vendor.
    pub amount: Money,
    /// The vendor's reason (a dispute's `reason`, a refund's `reason`), or empty.
    pub reason: String,
}

/// A verified event the vendor sent us.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CallbackEvent {
    pub reference: String,
    pub handle: ChargeHandle,
    pub status: ChargeStatus,
}

/// Raw inbound webhook, as the BFF received it. Verification is the impl's job.
pub struct RawCallback<'a> {
    pub headers: &'a [(String, String)],
    pub body: &'a [u8],
}

#[derive(Debug)]
pub enum PayError {
    /// The vendor said no. Not retryable.
    Declined(String),
    /// Network / vendor outage. Retryable; the reservation may or may not exist —
    /// call `status` before retrying `reserve`.
    Unavailable(String),
    /// A callback that did not verify. Log it, count it, never act on it.
    Unverified(String),
    /// **A callback that DID verify and asks nothing of us.**
    ///
    /// A vendor's endpoint receives everything that vendor emits, and most of it is
    /// about objects this deployment does not hold: another system's payment on the
    /// same merchant account, an event type the ledger has no answer for (a dispute,
    /// a refund notification), a payload we labelled nothing on.
    ///
    /// It is separate from [`PayError::Unverified`] because the two must be
    /// **answered** differently, not because they read differently. `Unverified` is
    /// a refusal the vendor should hear: 401. `Ignored` is a receipt: 200, delivery
    /// succeeded, nothing happened. Answering 401 to a vendor's own genuine traffic
    /// is how an endpoint gets retried for days and then disabled — taking the
    /// events that DO matter with it.
    ///
    /// Only [`Payment::callback`] returns this. A `page`, `capture`
    /// or `release` with nothing to do is not an error at all.
    Ignored(String),
    /// **A callback that DID verify and carries a PAID SIGNUP** — a customer
    /// who did not exist when the money moved. See [`signup`].
    ///
    /// It rides in the `Err` channel beside [`PayError::Ignored`] for exactly
    /// the reason that one does, and the shape is deliberate rather than
    /// convenient. `parse_callback` answers `Result<CallbackEvent, PayError>`,
    /// and a `CallbackEvent` is a fact about an attempt the ledger is holding
    /// open: a reference, a handle, a charge status. A signup has none of those
    /// — there is no reference and no tenant, which is the whole point of it.
    /// Widening `CallbackEvent` into an enum would have made every vendor,
    /// including the two open ones and every private impl, rewrite a method
    /// that has nothing to do with signups. This arm leaves them alone: **a
    /// provider that does not know signups answers exactly as it did before**,
    /// and the server's `match` grew one arm.
    ///
    /// Boxed because a `PaidSignup` is seven strings wide and every other arm
    /// is one; an unboxed variant would make every `PayError` in the crate that
    /// size.
    ///
    /// Like `Ignored`, it is answered **200**: the delivery succeeded. What
    /// became of the signup is read back over `Signup.Status`, not off the
    /// webhook's status code, because the vendor is not the party that cares.
    Signup(Box<signup::PaidSignup>),
    /// `capture`/`release` on a handle the vendor does not know.
    UnknownHandle(ChargeHandle),
    /// **A verified event that says money went back, or is contested** — a
    /// refund made at the vendor, a dispute opened or closed. Answered 200 like
    /// `Ignored`, and the server writes it into the ledger (g-disputes). A
    /// vendor that knows no reversals never returns it.
    Reversal(Box<Reversal>),
}

impl fmt::Display for PayError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PayError::Declined(r) => write!(f, "declined: {r}"),
            PayError::Unavailable(r) => write!(f, "vendor unavailable: {r}"),
            PayError::Unverified(r) => write!(f, "callback did not verify: {r}"),
            PayError::Ignored(r) => write!(f, "callback ignored: {r}"),
            PayError::Signup(s) => write!(f, "paid signup {}:{} for {:?} on {}", s.vendor, s.session, s.username, s.plan),
            PayError::UnknownHandle(h) => write!(f, "unknown handle {}:{}", h.vendor, h.id),
            PayError::Reversal(r) => write!(
                f,
                "reversal {} {} of {} on {}:{} ({}): {}",
                r.kind.name(),
                r.id,
                r.amount.in_words(),
                r.handle.vendor,
                r.handle.id,
                r.reference,
                r.reason
            ),
        }
    }
}
impl std::error::Error for PayError {}

// ---------------------------------------------------------------------------
// Vendor modules and the small helpers they share. Each vendor is a cargo feature (see Cargo.toml).
// ---------------------------------------------------------------------------

/// **The payment trait (A24 §2.6): ten ops**, and [`Vendor`], a vendor as the host
/// holds it: its instance name and markets (data) over the ten ops.
pub mod payment;
/// A32-15: what differs per payment vendor kind, as data.
pub mod shapes;
pub use payment::{Payment, Vendor};

/// **The `Payment` conformance suite**: pay → hold or capture → refund, release,
/// `holds` honesty, run by every vendor against its fake.
pub mod conformance;

/// How a vendor gets into a server without the server knowing its name.
pub mod factory;

/// One vendor KIND, several INSTANCES: `[vendors.stripe.gunnar]` is the
/// vendor `stripe/gunnar`, and the translation to and from the kind.
pub mod instance;

#[cfg(feature = "invoice")]
pub mod invoice;
/// The vendor with infinite funds and no socket, for rehearsing the money path
/// against a real product without a merchant agreement. Compiled out by default.
#[cfg(feature = "sandbox")]
pub mod sandbox;

/// **The PAID SIGNUP seam** — a customer who did not exist when the money
/// moved, and the sink that brings them into being. See the module doc.
pub mod signup;

/// **The RECONCILIATION seam** — asking the vendor what it actually took, so
/// a payment that arrived while monetize could not listen is found rather than
/// lost. See the module doc for the incident.
pub mod reconcile;

/// Calendar arithmetic (bank-file dates, RFC 3339), dependency-free. Shared
/// with the private vendors, which is why it is `pub`.
pub mod time;

pub use reconcile::PaidAtVendor;
pub use signup::{
    email_refusal, CardCheck, CardCheckState, SignupAtVendor, CustomerNotice, NameVerdict, PaidSignup, SignupCheckout, SignupError, SignupOrder, SignupOutcome, SignupSink,
};

/// Milliseconds since the Unix epoch, the clock `ChargeStatus::Captured` uses.
pub fn now_unix_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Constant-time byte equality for shared secrets and tokens: a forged callback
/// must not learn the secret one byte at a time from the response latency.
/// Length mismatch still returns `false`, but only after touching every byte of
/// the longer input.
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
    let mut diff = (a.len() ^ b.len()) as u8;
    let n = a.len().max(b.len());
    for i in 0..n {
        let x = a.get(i).copied().unwrap_or(0);
        let y = b.get(i).copied().unwrap_or(0);
        diff |= x ^ y;
    }
    diff == 0
}

/// **Minor units and a currency CODE as a person reads them**: `(12500, "sek")`
/// → `125.00 SEK`. For the figures that carry their currency as text (a cloud
/// run's `currency`, a vendor's `sek`); [`Money::in_words`] for a [`Money`].
/// An empty code prints the amount alone, never a bare öre count.
pub fn major_words(minor: u64, code: &str) -> String {
    let code = code.trim().to_ascii_uppercase();
    let amount = format!("{}.{:02}", minor / 100, minor % 100);
    if code.is_empty() { amount } else { format!("{amount} {code}") }
}

#[cfg(test)]
mod major_words_tests {
    #[test]
    fn minor_units_with_a_text_code_read_as_major_units() {
        assert_eq!(super::major_words(12_500, "sek"), "125.00 SEK");
        assert_eq!(super::major_words(5, "EUR"), "0.05 EUR");
        assert_eq!(super::major_words(2_900, ""), "29.00");
    }
}

impl Money {
    /// **The amount as a person reads it**: `125.00 SEK`, never `12500 SEK`.
    ///
    /// `minor` is öre/cents; printed bare beside a currency code it reads as a
    /// hundred times the money (measured 2026-09-15: a 125,00 kr signup was
    /// logged "for 12500 SEK"). Both currencies monetize expresses have two
    /// decimals, so this is exact integer arithmetic, never a float.
    pub fn major(&self) -> String {
        format!("{}.{:02} {}", self.minor / 100, self.minor % 100, self.currency.code())
    }
}

impl Currency {
    /// ISO 4217 code, lowercase for Stripe, uppercase for Klarna — callers pick.
    pub fn code(&self) -> &'static str {
        match self {
            Currency::Sek => "SEK",
            Currency::Eur => "EUR",
            Currency::Dkk => "DKK",
        }
    }

    /// **The ISO 4217 minor-unit exponent**: how many decimals `minor` holds.
    /// Exhaustive, so a currency added here states its own.
    pub fn minor_digits(&self) -> u32 {
        match self {
            Currency::Sek | Currency::Eur | Currency::Dkk => 2,
        }
    }

    /// **A vendor's ISO code back to the two currencies monetize can express**,
    /// in either case. `None` for anything else — and `None` is an answer the
    /// caller must SHOW, not swallow: a payment in a currency this deployment
    /// cannot file is still a payment the vendor took
    /// (`reconcile::PaidAtVendor::currency` says why it is a string).
    pub fn from_code(code: &str) -> Option<Currency> {
        match code.trim().to_ascii_lowercase().as_str() {
            "sek" => Some(Currency::Sek),
            "eur" => Some(Currency::Eur),
            "dkk" => Some(Currency::Dkk),
            _ => None,
        }
    }
}

impl Market {
    /// ISO 3166-1 alpha-2.
    pub fn country(&self) -> &'static str {
        match self {
            Market::Se => "SE",
            Market::Fi => "FI",
            Market::Dk => "DK",
        }
    }
    /// VAT in basis points: SE 25 %, FI 25.5 % (since 2024-09-01), DK 25 %.
    pub fn vat_basis_points(&self) -> u32 {
        match self {
            Market::Se => 2500,
            Market::Fi => 2550,
            Market::Dk => 2500,
        }
    }
    /// The market's currency — the only one an invoice in it may be in.
    pub fn currency(&self) -> Currency {
        match self {
            Market::Se => Currency::Sek,
            Market::Fi => Currency::Eur,
            Market::Dk => Currency::Dkk,
        }
    }
}

impl Currency {
    /// **The market a price in this currency is sold in.**
    ///
    /// The inverse of [`Market::currency`], and it exists because a
    /// `[signups.<product>.plans]` row carries a price and no market: a signup is one
    /// bundle bought whole by a stranger whose country nobody asked, and the
    /// currency is the only thing on the row that says where. It is a total
    /// function only because there are two markets and they do not share a
    /// currency; a third market on the euro makes this a decision the config
    /// has to state instead, and this is where that will be caught.
    pub fn market(&self) -> Market {
        match self {
            Currency::Sek => Market::Se,
            Currency::Eur => Market::Fi,
            Currency::Dkk => Market::Dk,
        }
    }
}

#[cfg(test)]
mod error_tests {
    use super::*;

    /// The two callback outcomes must not read alike either: an operator reading
    /// the log has to see which one closed the endpoint's account with the vendor.
    #[test]
    fn a_refused_callback_and_an_ignored_one_say_different_things() {
        let refused = PayError::Unverified("bad signature".into()).to_string();
        let ignored = PayError::Ignored("customer.created is not ours".into()).to_string();
        assert_eq!(refused, "callback did not verify: bad signature");
        assert_eq!(ignored, "callback ignored: customer.created is not ours");
        assert_ne!(refused, ignored);
        // And the rest still say what they said.
        assert_eq!(PayError::Declined("no funds".into()).to_string(), "declined: no funds");
        assert_eq!(PayError::Unavailable("down".into()).to_string(), "vendor unavailable: down");
        // A signup is a third answer and reads as neither.
        let s = crate::signup::PaidSignup {
            vendor: "stripe".into(),
            session: "cs_1".into(),
            reference: "pi_1".into(),
            email: "a@b.se".into(),
            username: "bob".into(),
            product: "gunnar".into(),
            plan: "gunnar/10gb-1m".into(),
            amount: Money { minor: 10_000, currency: Currency::Sek },
        // No tax registration when this fixture was written: the vendor took the
        // plan price and nothing on top, so gross and net are the same number.
        tax: Money { minor: 0, currency: Currency::Sek },
            paid_at_unix_ms: 1,
            customer_country: None,
        };
        let signup = PayError::Signup(Box::new(s)).to_string();
        assert_eq!(signup, "paid signup stripe:cs_1 for \"bob\" on gunnar/10gb-1m");
        assert_ne!(signup, ignored);
        assert_eq!(
            PayError::UnknownHandle(ChargeHandle { vendor: "stripe".into(), id: "pi_1".into() }).to_string(),
            "unknown handle stripe:pi_1"
        );
    }
}

#[cfg(test)]
mod ct_tests {
    use super::ct_eq;
    #[test]
    fn ct_eq_matches_only_equal_bytes() {
        assert!(ct_eq(b"abc", b"abc"));
        assert!(!ct_eq(b"abc", b"abd"));
        assert!(!ct_eq(b"abc", b"abcd"));
        assert!(!ct_eq(b"", b"a"));
        assert!(ct_eq(b"", b""));
    }

    /// A vendor's spelling of a code, either case, back to the enum — and
    /// a code monetize cannot express is `None`, never a default currency.
    #[test]
    fn a_currency_code_round_trips_in_either_case_and_an_unknown_one_is_none() {
        use crate::Currency;
        assert_eq!(Currency::from_code("sek"), Some(Currency::Sek));
        assert_eq!(Currency::from_code("EUR"), Some(Currency::Eur));
        assert_eq!(Currency::from_code(" Sek "), Some(Currency::Sek));
        assert_eq!(Currency::from_code("usd"), None);
        assert_eq!(Currency::from_code(""), None);
        for c in [Currency::Sek, Currency::Eur, Currency::Dkk] {
            assert_eq!(Currency::from_code(c.code()), Some(c));
        }
    }

    /// ★ **Denmark is a market** (owner 2026-09-28: holger sells in SE, FI and DK —
    /// DK 75 DKK base, 12.50 DKK per GiB-month, Stripe Tax exclusive, DK 25 %). DKK
    /// is two-decimal (øre), so `in_words` needs no exponent; the market and the
    /// currency map one to one, as SE/SEK and FI/EUR do.
    #[test]
    fn denmark_is_a_market_in_danish_kroner_at_25_percent() {
        use crate::{Currency, Market, Money};
        assert_eq!((Market::Dk.country(), Market::Dk.vat_basis_points(), Market::Dk.currency()), ("DK", 2500, Currency::Dkk));
        assert_eq!(Currency::Dkk.market(), Market::Dk);
        assert_eq!(Currency::from_code("dkk"), Some(Currency::Dkk));
        assert_eq!(Currency::Dkk.code(), "DKK");
        assert_eq!(Money { minor: 7_500, currency: Currency::Dkk }.in_words(), "75.00 DKK");
        for m in [Market::Se, Market::Fi, Market::Dk] {
            assert_eq!(m.currency().market(), m, "{m:?}: one currency, one market");
        }
    }
}

#[cfg(test)]
mod money_words {
    use super::{Currency, Money};

    /// ★ **The rendered price is exact, and it is integer arithmetic.**
    ///
    /// A customer reads this off a page about their own money, so a rounding
    /// artefact is not a cosmetic defect. `1` minor unit is one öre and must
    /// render as `0.01`, not `0.1` or `0.010000000000000002`.
    #[test]
    fn an_amount_renders_exactly_and_never_as_a_float() {
        let sek = |minor| Money { minor, currency: Currency::Sek }.in_words();
        assert_eq!(sek(0), "0.00 SEK");
        assert_eq!(sek(1), "0.01 SEK");
        assert_eq!(sek(9), "0.09 SEK");
        assert_eq!(sek(10), "0.10 SEK");
        assert_eq!(sek(100), "1.00 SEK");
        assert_eq!(sek(22_000), "220.00 SEK");
        assert_eq!(sek(22_050), "220.50 SEK");
        // A number no float could carry without drifting.
        assert_eq!(sek(u64::MAX / 100 * 100 + 7), "184467440737095516.07 SEK");
    }

    /// The currency travels with the amount. An amount without one is a number
    /// a customer cannot act on.
    #[test]
    fn the_currency_is_the_iso_code_and_never_a_symbol() {
        assert_eq!(Money { minor: 1_000, currency: Currency::Eur }.in_words(), "10.00 EUR");
        assert_eq!(Money { minor: 1_000, currency: Currency::Sek }.in_words(), "10.00 SEK");
        for said in [
            Money { minor: 1, currency: Currency::Eur }.in_words(),
            Money { minor: 1, currency: Currency::Sek }.in_words(),
        ] {
            // A symbol is a localisation decision monetize cannot make: it does
            // not know the reader's locale, and `kr` alone is ambiguous across
            // three of them.
            assert!(!said.contains('€') && !said.contains("kr"), "{said}");
        }
    }

    /// **Two call sites, one string.** A quote and a history row must agree
    /// about what an amount looks like, and the only way that survives an edit
    /// is for there to be one function rather than two that match today.
    #[test]
    fn the_same_amount_reads_the_same_wherever_it_is_shown() {
        let price = Money { minor: 4_999, currency: Currency::Eur };
        assert_eq!(price.in_words(), price.in_words());
        assert_eq!(price.in_words(), "49.99 EUR");
    }
}

/// g-disputes / g-b2b-reverse (money 2026-09-28): a reversal is its own
/// verified answer, and a settlement can carry the tax the vendor charged and
/// who it charged it to.
#[cfg(test)]
mod reversal_tests {
    use super::*;

    #[test]
    fn a_reversal_is_a_verified_answer_that_names_its_charge_and_amount() {
        let r = Reversal {
            vendor: "stripe".into(),
            handle: ChargeHandle { vendor: "stripe".into(), id: "cs_test_1".into() },
            reference: "gunnar/acme/2026-09-28".into(),
            kind: ReversalKind::DisputeOpened,
            id: "dp_1".into(),
            amount: Money { minor: 12_500, currency: Currency::Dkk },
            reason: "fraudulent".into(),
        };
        let e = PayError::Reversal(Box::new(r.clone()));
        assert_eq!(e.to_string(), "reversal dispute-opened dp_1 of 125.00 DKK on stripe:cs_test_1 (gunnar/acme/2026-09-28): fraudulent");
        assert!(ReversalKind::DisputeLost.money_went_back() && ReversalKind::Refunded.money_went_back());
        assert!(!ReversalKind::DisputeOpened.money_went_back() && !ReversalKind::DisputeWon.money_went_back());
        for k in [ReversalKind::Refunded, ReversalKind::DisputeOpened, ReversalKind::DisputeWon, ReversalKind::DisputeLost] {
            assert_eq!(ReversalKind::from_name(k.name()), Some(k));
        }
    }

    #[test]
    fn a_settlement_written_before_the_tax_fields_still_reads_and_says_nothing_about_tax() {
        let old = r#"{"fee":{"minor":1,"currency":"Sek"},"net":{"minor":2,"currency":"Sek"},"vendor_ref":"txn_1","available_unix_ms":null}"#;
        let s: Settlement = serde_json::from_str(old).unwrap();
        assert_eq!((s.tax, s.customer_tax_id.as_deref(), s.customer_country.as_deref()), (None, None, None));
        // And a settlement with none of them serializes exactly as before.
        assert_eq!(serde_json::to_string(&s).unwrap(), old);
    }
}