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

//! **`Payment` — the payment trait (A24 §2.6, ten ops).**
//!
//! A vendor takes money four ways (an estate order held then captured, a bundle the
//! same, a stranger's sign-up that charges, a card check that holds nothing) and is
//! asked about it afterwards. The design keeps ONE op per vendor action:
//!
//! | op | what it is |
//! |---|---|
//! | `manifest` | the vendor's name, markets, setting NAMES, `holds`, its test payer |
//! | `page` | ONE hosted page: an order's (held), a sign-up's (charged), a card check's, a test sign-up's |
//! | `status` | where a charge, a page, a sign-up or a card check stands, read from the vendor |
//! | `capture`, `release`, `refund` | draw, unlock, give back |
//! | `callback` | verify and decode an inbound notice |
//! | `settlement`, `paid_since`, `livemode` | the vendor's cut, reconcile, live or test money |
//!
//! The host holds a vendor as a [`Vendor`]: the instance's NAME and MARKETS are data the
//! host read once (from the config block and the manifest), never asked per call, and the
//! ten ops are the vendor's. [`Vendor`]'s few helpers decode the one answer arm each
//! question expects (an order's `page` is a handle), written once here.
//!
//! # `holds`
//!
//! A vendor that HOLDS (Stripe, Klarna) answers `Reserved` once the customer has
//! paid and draws the money at `capture`. A vendor that cannot hold (Swish) answers
//! `AwaitingCustomer` until the payer pays and `Captured` at once after; its
//! `capture` is a read. The manifest says which, and the conformance suite
//! ([`crate::conformance`]) holds every vendor to its word.

use crate::signup::{CardCheck, PaidSignup, SignupAtVendor, SignupCheckout, SignupOrder};
use crate::{reconcile::PaidAtVendor, CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Market, Money, PayError, RawCallback, Settlement};

/// **What a vendor is**: its name, its markets, the NAMES of the settings it reads
/// (never a value), whether it can hold money, and the payer that pays without a
/// person on a test key (`None`: it has none, or the key is live).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PayManifest {
    pub vendor: String,
    pub markets: Vec<Market>,
    pub settings: Vec<String>,
    pub holds: bool,
    pub test_payer: Option<String>,
}

/// **What a page is for.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Charge<'a> {
    /// An order over a ledger reference: the money is HELD (where the vendor holds)
    /// and drawn at `capture`.
    Order(&'a ChargeRequest),
    /// A stranger's sign-up: CHARGED, nothing held, the name carried in the session.
    Signup(&'a SignupOrder),
    /// A card checked and saved with nothing charged.
    CardCheck(&'a SignupOrder),
    /// A sign-up paid at once by the vendor's test payer, no person on the page.
    /// Only on a `livemode() == Some(false)` instance.
    TestSignup(&'a SignupOrder),
}

/// **Where the payer comes back to.** Empty: the vendor's configured page.
/// `cancel` empty: a cancel follows `paid`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Return {
    pub paid: String,
    pub cancel: String,
}

impl Return {
    /// The vendor's own pages.
    pub fn vendors_own() -> Return {
        Return::default()
    }
    pub fn to(paid: &str, cancel: &str) -> Return {
        Return { paid: paid.to_string(), cancel: cancel.to_string() }
    }
}

/// **What `page` minted**, one arm per [`Charge`] arm. `None` inside an arm is an
/// answer: this vendor sells no such page.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Page {
    /// `url`: the hosted page, when the vendor had it with the handle (Klarna, Swish:
    /// from memory). `None`: ask [`Payment::status`] with [`Of::Page`] — a vendor that
    /// must read the page again (Stripe, a guest) answers it there, when a console asks.
    Order { handle: ChargeHandle, url: Option<String> },
    Signup(Option<SignupCheckout>),
    CardCheck(Option<SignupCheckout>),
    TestSignup(Option<PaidSignup>),
}

/// **What `status` is asked about.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Of<'a> {
    /// Where the charge stands at the vendor.
    Charge(&'a ChargeHandle),
    /// The hosted page's URL for a charge, read again later (a console re-renders it).
    Page(&'a ChargeHandle),
    /// Where a sign-up's checkout stands.
    Signup { session: &'a str },
    /// Where a card check stands.
    CardCheck { session: &'a str },
}

/// **What `status` answers**, one arm per [`Of`] arm.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Standing {
    Charge(ChargeStatus),
    Page(Option<String>),
    Signup(Option<SignupAtVendor>),
    CardCheck(Option<CardCheck>),
}

/// The `Standing` arm did not match the `Of` arm: a vendor's bug, said by name.
pub fn wrong_standing(verb: &str, got: &Standing) -> PayError {
    PayError::Unavailable(format!("{verb}: the vendor answered {got:?} to a {verb}"))
}

/// **A payment vendor, in ten verbs.** Sync on purpose: the caller (the transaction in
/// `monetize`) owns the runtime; a vendor that needs async bridges it privately.
///
/// `None` is an ANSWER wherever an op returns an `Option` (`settlement`, a page's URL, a
/// sign-up page a vendor does not sell): "this vendor has no such thing" is a fact an
/// operator reads, and none of these may fail once money has moved; a vendor that cannot
/// reach its API answers `None` there rather than turning a good capture into an error.
pub trait Payment: Send + Sync {
    /// The name, the markets, the setting NAMES, `holds`, the test payer. Pure.
    fn manifest(&self) -> PayManifest;
    /// ONE hosted page, minted; nothing is drawn. Idempotent on the vendor's side
    /// by the charge's reference.
    fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError>;
    /// The vendor's view — the ledger reconciles against this, never against memory.
    fn status(&self, of: &Of<'_>) -> Result<Standing, PayError>;
    /// Draw the held money. Idempotent. A vendor that cannot hold answers what the
    /// payer did (`Captured` once paid, `AwaitingCustomer` before).
    fn capture(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    /// Unlock without drawing. Idempotent.
    fn release(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    /// After capture only. Partial allowed. Answers the charge's standing after.
    fn refund(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError>;
    /// Verify and decode an inbound notice: `Unverified` → 401, `Ignored` → 200.
    fn callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError>;
    /// The vendor's cut of this charge, once drawn. `None` is an answer.
    fn settlement(&self, handle: &ChargeHandle) -> Option<Settlement>;
    /// Every FINAL payment since `since_unix_ms`, in the vendor's words (reconcile).
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError>;
    /// `Some(true)` live money, `Some(false)` test, `None`: the vendor does not say.
    fn livemode(&self) -> Option<bool>;
}

/// **A vendor as the host holds it**: the instance's name and markets (DATA, read once by
/// whoever built the vendor: the registry's slot, the config block, the manifest) and its
/// ten ops. `Copy`; it derefs to the [`Payment`] it holds, so the ten ops are called on it
/// directly. Its helpers decode the one [`Page`] or [`Standing`] arm each question expects.
#[derive(Clone, Copy)]
pub struct Vendor<'a> {
    /// The instance's name (`stripe/gunnar`): what every handle it mints carries.
    pub name: &'a str,
    /// The markets it takes money in.
    pub markets: &'a [Market],
    pay: &'a dyn Payment,
}

impl std::fmt::Debug for Vendor<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Vendor").field("name", &self.name).field("markets", &self.markets).finish()
    }
}

impl<'a> std::ops::Deref for Vendor<'a> {
    type Target = dyn Payment + 'a;
    fn deref(&self) -> &Self::Target {
        self.pay
    }
}

impl<'a> Vendor<'a> {
    pub fn new(name: &'a str, markets: &'a [Market], pay: &'a dyn Payment) -> Vendor<'a> {
        Vendor { name, markets, pay }
    }
    /// **A linked vendor, its name and markets read from its manifest** (interned once per
    /// distinct value). For a vendor whose manifest is cheap: a linked one or a test double.
    /// A guest's manifest asks the guest, so the registry's slot holds its name and markets
    /// and hands out [`Vendor::new`] instead.
    pub fn of(pay: &'a dyn Payment) -> Vendor<'a> {
        let m = pay.manifest();
        Vendor { name: crate::instance::intern(&m.vendor), markets: crate::instance::intern_markets(&m.markets), pay }
    }
    /// The ten ops, as a trait object.
    pub fn payment(&self) -> &'a dyn Payment {
        self.pay
    }
    /// **An order's page minted, the money held where the vendor holds**: its handle.
    /// `back`: where the payer comes back to ([`Return::vendors_own`]: the vendor's pages).
    pub fn reserve(&self, req: &ChargeRequest, back: &Return) -> Result<ChargeHandle, PayError> {
        match self.pay.page(&Charge::Order(req), back)? {
            Page::Order { handle, .. } => Ok(handle),
            other => Err(PayError::Unavailable(format!("reserve: the vendor answered {other:?} to an order"))),
        }
    }
    /// Where the charge stands at the vendor.
    pub fn charge(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        match self.pay.status(&Of::Charge(handle))? {
            Standing::Charge(s) => Ok(s),
            other => Err(wrong_standing("status", &other)),
        }
    }
    /// The hosted page's URL for a charge, read again (a console re-renders it). `None`: the
    /// vendor has no such page, or could not say; it never fails.
    pub fn page_url(&self, handle: &ChargeHandle) -> Option<String> {
        match self.pay.status(&Of::Page(handle)) {
            Ok(Standing::Page(url)) => url,
            _ => None,
        }
    }
    /// A stranger's sign-up page (charged). `None`: this vendor sells none.
    pub fn signup_page(&self, order: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
        match self.pay.page(&Charge::Signup(order), &Return::vendors_own())? {
            Page::Signup(p) => Ok(p),
            other => Err(PayError::Unavailable(format!("signup page: the vendor answered {other:?} to a signup"))),
        }
    }
    /// A card-check page (nothing charged). `None`: this vendor cannot check a card.
    pub fn card_check_page(&self, order: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
        match self.pay.page(&Charge::CardCheck(order), &Return::vendors_own())? {
            Page::CardCheck(p) => Ok(p),
            other => Err(PayError::Unavailable(format!("card check page: the vendor answered {other:?} to a card check"))),
        }
    }
    /// A sign-up paid by the vendor's test payer, test mode only. `None`: this vendor cannot.
    pub fn test_signup(&self, order: &SignupOrder) -> Result<Option<PaidSignup>, PayError> {
        match self.pay.page(&Charge::TestSignup(order), &Return::vendors_own())? {
            Page::TestSignup(p) => Ok(p),
            other => Err(PayError::Unavailable(format!("test signup: the vendor answered {other:?} to a test signup"))),
        }
    }
    /// Where a card check stands. `None`: this vendor cannot say.
    pub fn card_check(&self, session: &str) -> Result<Option<CardCheck>, PayError> {
        match self.pay.status(&Of::CardCheck { session })? {
            Standing::CardCheck(c) => Ok(c),
            other => Err(wrong_standing("card_check", &other)),
        }
    }
    /// Where a paid sign-up stands. `None`: this vendor cannot say (the webhook decides).
    pub fn signup_standing(&self, session: &str) -> Result<Option<SignupAtVendor>, PayError> {
        match self.pay.status(&Of::Signup { session })? {
            Standing::Signup(s) => Ok(s),
            other => Err(wrong_standing("signup_at_vendor", &other)),
        }
    }
    /// The payer that pays without a person on a test key (the manifest's). `None` on a live key.
    pub fn test_payer(&self) -> Option<String> {
        self.pay.manifest().test_payer
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Currency;
    use std::sync::Mutex;

    /// A vendor that holds, in memory.
    #[derive(Default)]
    struct Toy {
        pages: Mutex<Vec<(String, Return)>>,
        paid: Mutex<Vec<String>>,
        captured: Mutex<Vec<String>>,
        /// A settlement row in this currency for every charge (`None`: it says none).
        settles_in: Mutex<Option<Currency>>,
        released: Mutex<Vec<String>>,
    }

    impl Payment for Toy {
        fn manifest(&self) -> PayManifest {
            PayManifest { vendor: "toy".into(), markets: vec![Market::Se], settings: vec!["TOY_KEY".into()], holds: true, test_payer: Some("pm_toy".into()) }
        }
        fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError> {
            match charge {
                Charge::Order(req) => {
                    self.pages.lock().unwrap().push((req.reference.clone(), back.clone()));
                    Ok(Page::Order { handle: ChargeHandle { vendor: "toy".into(), id: format!("toy:{}", req.reference) }, url: Some(format!("https://toy.example/pay/{}", req.reference)) })
                }
                Charge::Signup(_) => Ok(Page::Signup(None)),
                Charge::CardCheck(_) => Ok(Page::CardCheck(None)),
                Charge::TestSignup(_) => Ok(Page::TestSignup(None)),
            }
        }
        fn status(&self, of: &Of<'_>) -> Result<Standing, PayError> {
            Ok(match of {
                Of::Charge(h) => Standing::Charge(if self.released.lock().unwrap().contains(&h.id) {
                    ChargeStatus::Released
                } else if self.captured.lock().unwrap().contains(&h.id) {
                    ChargeStatus::Captured { at_unix_ms: 1, vendor_ref: h.id.clone() }
                } else if self.paid.lock().unwrap().contains(&h.id) {
                    ChargeStatus::Reserved
                } else {
                    ChargeStatus::AwaitingCustomer
                }),
                Of::Page(h) => Standing::Page(Some(format!("https://toy.example/pay/{}", h.id.trim_start_matches("toy:")))),
                Of::Signup { .. } => Standing::Signup(None),
                Of::CardCheck { .. } => Standing::CardCheck(None),
            })
        }
        fn capture(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            if self.paid.lock().unwrap().contains(&h.id) {
                self.captured.lock().unwrap().push(h.id.clone());
            }
            match Payment::status(self, &Of::Charge(h))? {
                Standing::Charge(s) => Ok(s),
                other => Err(wrong_standing("capture", &other)),
            }
        }
        fn release(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            self.released.lock().unwrap().push(h.id.clone());
            Ok(ChargeStatus::Released)
        }
        fn refund(&self, h: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
            Ok(ChargeStatus::Captured { at_unix_ms: 1, vendor_ref: h.id.clone() })
        }
        fn callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
            Err(PayError::Ignored("toy".into()))
        }
        fn settlement(&self, _: &ChargeHandle) -> Option<Settlement> {
            let currency = (*self.settles_in.lock().unwrap())?;
            let money = |minor: u64| serde_json::to_value(Money { minor, currency }).unwrap();
            Some(serde_json::from_value(serde_json::json!({ "fee": money(300), "net": money(12_200), "vendor_ref": "txn_toy", "available_unix_ms": null })).unwrap())
        }
        fn paid_since(&self, _: i64) -> Result<Vec<PaidAtVendor>, PayError> {
            Err(crate::reconcile::cannot_be_asked())
        }
        fn livemode(&self) -> Option<bool> {
            Some(false)
        }
    }

    fn req() -> ChargeRequest {
        ChargeRequest { reference: "gunnar/acme/2026-10".into(), amount: Money { minor: 12_500, currency: Currency::Sek }, market: Market::Se, description: "gunnar".into(), payer: None }
    }

    fn suite(toy: &Toy) {
        let pays = |_: &dyn Payment, h: &ChargeHandle| toy.paid.lock().unwrap().push(h.id.clone());
        crate::conformance::pay_hold_capture_refund(&crate::conformance::PayFixture { payment: toy, order: req(), customer_pays: &pays, refund: Money { minor: 2_500, currency: Currency::Sek }, forged_notice: None });
    }

    /// The suite over the in-memory vendor, with a settlement row in the order's currency.
    #[test]
    fn a_vendor_that_holds_passes_the_conformance_suite() {
        let toy = Toy::default();
        *toy.settles_in.lock().unwrap() = Some(Currency::Sek);
        suite(&toy);
    }

    /// ★ **The suite reads the settlement it is given** (S2: it was `let _ =`): a vendor's fee
    /// booked in another currency than the charge is a broken back office, by name.
    #[test]
    #[should_panic(expected = "settlement: the fee is in the order's currency")]
    fn a_settlement_in_another_currency_fails_the_suite() {
        let toy = Toy::default();
        *toy.settles_in.lock().unwrap() = Some(Currency::Eur);
        suite(&toy);
    }

    #[test]
    fn the_vendor_handle_decodes_each_answer_from_the_ten_ops() {
        let toy = Toy::default();
        let p = Vendor::new("toy", &[Market::Se], &toy);
        let h = p.reserve(&req(), &Return::vendors_own()).unwrap();
        assert_eq!(h.id, "toy:gunnar/acme/2026-10");
        assert_eq!(toy.pages.lock().unwrap()[0].1, Return::vendors_own(), "a plain reserve returns to the vendor's own page");
        p.reserve(&req(), &Return::to("https://one.example/sites", "")).unwrap();
        assert_eq!(toy.pages.lock().unwrap()[1].1, Return::to("https://one.example/sites", ""));
        p.reserve(&req(), &Return::to("https://a/paid", "https://a/cancel")).unwrap();
        assert_eq!(toy.pages.lock().unwrap()[2].1, Return::to("https://a/paid", "https://a/cancel"));
        assert_eq!(p.page_url(&h).as_deref(), Some("https://toy.example/pay/gunnar/acme/2026-10"));
        assert_eq!(p.charge(&h).unwrap(), ChargeStatus::AwaitingCustomer);
        assert_eq!(p.capture(&h).unwrap(), ChargeStatus::AwaitingCustomer, "nothing to draw before the customer pays");
        toy.paid.lock().unwrap().push(h.id.clone());
        assert_eq!(p.charge(&h).unwrap(), ChargeStatus::Reserved);
        assert!(matches!(p.capture(&h).unwrap(), ChargeStatus::Captured { .. }));
        assert_eq!(p.test_payer().as_deref(), Some("pm_toy"));
        assert_eq!(p.livemode(), Some(false));
        assert_eq!((p.name, p.markets), ("toy", &[Market::Se][..]), "the name and markets are the host's data");
        assert!(p.signup_page(&signup()).unwrap().is_none(), "None is an answer");
        assert!(p.card_check_page(&signup()).unwrap().is_none());
        assert!(p.test_signup(&signup()).unwrap().is_none());
        assert!(p.card_check("s").unwrap().is_none());
        assert!(p.signup_standing("s").unwrap().is_none());
        assert!(matches!(p.paid_since(0), Err(PayError::Unavailable(_))));
    }

    /// A vendor that answers the wrong arm is refused by name, never read as another answer.
    #[test]
    fn a_wrong_answer_arm_is_a_named_refusal() {
        struct Odd;
        impl Payment for Odd {
            fn manifest(&self) -> PayManifest {
                PayManifest { vendor: "odd".into(), markets: vec![], settings: vec![], holds: true, test_payer: None }
            }
            fn page(&self, _: &Charge<'_>, _: &Return) -> Result<Page, PayError> {
                Ok(Page::Signup(None))
            }
            fn status(&self, _: &Of<'_>) -> Result<Standing, PayError> {
                Ok(Standing::Page(None))
            }
            fn capture(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
                Ok(ChargeStatus::Released)
            }
            fn release(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
                Ok(ChargeStatus::Released)
            }
            fn refund(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
                Ok(ChargeStatus::Released)
            }
            fn callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
                Err(PayError::Ignored("odd".into()))
            }
            fn settlement(&self, _: &ChargeHandle) -> Option<Settlement> {
                None
            }
            fn paid_since(&self, _: i64) -> Result<Vec<PaidAtVendor>, PayError> {
                Ok(vec![])
            }
            fn livemode(&self) -> Option<bool> {
                None
            }
        }
        let v = Vendor::new("odd", &[], &Odd);
        assert!(v.reserve(&req(), &Return::vendors_own()).unwrap_err().to_string().contains("reserve: the vendor answered Signup"));
        assert!(v.charge(&ChargeHandle { vendor: "odd".into(), id: "x".into() }).unwrap_err().to_string().contains("status: the vendor answered Page"));
        assert!(v.card_check_page(&signup()).is_err() && v.test_signup(&signup()).is_err() && v.card_check("s").is_err() && v.signup_standing("s").is_err());
    }

    fn signup() -> SignupOrder {
        SignupOrder { username: "nina".into(), email: "nina@mock.invalid".into(), product: "gunnar".into(), plan: "free".into(), amount: Money { minor: 0, currency: Currency::Sek }, market: Market::Se, description: String::new(), success_url: String::new(), cancel_url: String::new() }
    }
}