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