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

//! **The guest side of a monetize payment vendor** — what a vendor plugin
//! author compiles against.
//!
//! A vendor (Stripe, Klarna, Swish) used to be a Rust crate linked into the
//! deployment binary. It can now be a **signed `.wasm`** the server takes in
//! while it runs, behind the same [`monetize_pay::Payment`] (ten ops) the ledger
//! and the transaction already call — they cannot tell the two apart.
//!
//! | piece | what it is |
//! |---|---|
//! | [`VendorGuest`] | the trait a vendor implements — [`monetize_pay::Payment`]'s ten ops (A24 §2.6: `manifest`, `page`, `status`, `capture`, `release`, `refund`, `callback`, `settlement`, `paid_since`, `livemode`), which the host answers `Payment` from one for one, with the host and the block's settings handed in, because a guest has no socket, no clock, no key and no memory of its own |
//! | [`Host`] | the doors, shared with product guests: `http` (the HOST adds the vendor's credential per host from a secret file), `hmac` (a webhook signature checked on the host), `state` (the host's memory for this vendor), `clock`, `log` |
//! | [`ops`] | one number per trait method, 120..129 (100..118 retired; products use 1..99, clouds 200..) |
//! | [`wire`] | [`wire::Call`] (settings + request) and the error envelope |
//! | [`dispatch`] | the op switch, used by the wasm export and by native tests |
//! | [`export_vendor!`] | the exports, over the scaffolding every guest kind shares |
//!
//! # The reversal contract
//!
//! A [`monetize_pay::Reversal`] of kind `Refunded` carries the charge's
//! refunded TOTAL, re-read from the vendor, never one refund's own amount: a
//! second partial refund of 20 after one of 30 is sent as 50. The host refuses
//! a total below the one it recorded.
//!
//! # What the guest never holds
//!
//! The API key, the webhook secret, the client certificate: each is a file on
//! the host (`secret.<name>`) that the host reads at the moment it adds a
//! header, verifies an HMAC or opens a TLS connection. The guest names none of
//! them and can read none of them.

use monetize_pay::payment::{Charge, Of, Page, PayManifest, Return, Standing};
use monetize_pay::signup::{CardCheck, PaidSignup, SignupAtVendor, SignupCheckout, SignupOrder};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Money, PaidAtVendor, PayError, RawCallback, Settlement};

pub use monetize_pay;
pub use monetize_product_guest::{abi, grants, kinds, Host, Manifest, NoHost};

/// The re-exports the macro reaches through, so a vendor guest needs exactly
/// one dependency.
#[doc(hidden)]
pub mod __reexport {
    pub use monetize_product_guest;
}

/// **The vendor op table** (A24 §2.6): ten ops, one per vendor action, the same ten
/// [`monetize_pay::Payment`] has. Numbers are the wire; names are for logs.
///
/// The v2 table starts at 120. The twenty ops of the table before it (100..=118:
/// `check`, `markets`, `reserve`, `redirect`, `start_signup`, `test_payer`,
/// `pay_signup_as_test`, `start_card_check`, `card_check`, `signup_at_vendor`,
/// `holds`, …) are RETIRED and never reused, so a guest built on the old table
/// meets this host's first call (`manifest`) with the unknown-op words and is
/// refused at load, by name, rather than read as another op.
pub mod ops {
    /// The id — the same op number every kind answers (not a vendor op).
    pub const ID: u32 = monetize_product_guest::ops::ID;
    /// Markets, setting names, `holds`, the test payer; and an `Err` when the
    /// block's settings are not ones this vendor can work with (asked once at
    /// load; a refusal keeps the vendor out, by name).
    pub const MANIFEST: u32 = 120;
    /// ONE hosted page: an order (held), a sign-up (charged), a card check, a test sign-up.
    pub const PAGE: u32 = 121;
    /// Where a charge, a page, a sign-up or a card check stands.
    pub const STATUS: u32 = 122;
    pub const CAPTURE: u32 = 123;
    pub const RELEASE: u32 = 124;
    pub const REFUND: u32 = 125;
    pub const CALLBACK: u32 = 126;
    pub const SETTLEMENT: u32 = 127;
    pub const PAID_SINCE: u32 = 128;
    pub const LIVEMODE: u32 = 129;

    /// The words a guest answers an op it does not have with (frozen: the
    /// guests already deployed are the contract).
    pub const UNKNOWN_OP_PHRASE: &str = "is not in this vendor guest's op table";

    /// The ten vendor ops, in A24's order.
    pub const VENDOR: &[(u32, &str)] = &[
        (MANIFEST, "manifest"),
        (PAGE, "page"),
        (STATUS, "status"),
        (CAPTURE, "capture"),
        (RELEASE, "release"),
        (REFUND, "refund"),
        (CALLBACK, "callback"),
        (SETTLEMENT, "settlement"),
        (PAID_SINCE, "paid_since"),
        (LIVEMODE, "livemode"),
    ];

    /// Every op a vendor guest answers: the shared `id` and the ten.
    pub const ALL: &[(u32, &str)] = &[
        (ID, "id"),
        (MANIFEST, "manifest"),
        (PAGE, "page"),
        (STATUS, "status"),
        (CAPTURE, "capture"),
        (RELEASE, "release"),
        (REFUND, "refund"),
        (CALLBACK, "callback"),
        (SETTLEMENT, "settlement"),
        (PAID_SINCE, "paid_since"),
        (LIVEMODE, "livemode"),
    ];

    pub fn name(op: u32) -> Option<&'static str> {
        ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
    }
}

pub mod wire {
    use super::*;

    pub use monetize_product_guest::wire::{decode, encode};

    /// **Every vendor op's request**: the block's settings — the
    /// `[vendors.<name>]` keys the server does not read itself, NEVER a
    /// secret — and the op's own arguments. A guest is a fresh instance per
    /// call and cannot remember its configuration, so the configuration comes
    /// with every call.
    #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
    pub struct Call<T> {
        pub settings: serde_json::Value,
        pub req: T,
    }

    /// [`PayError`] on the wire, every arm kept: the server answers a webhook
    /// 401, 200 or "a stranger paid" by the ARM, so an arm lost here is a
    /// vendor endpoint disabled or a customer lost.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub enum WirePayError {
        Declined(String),
        Unavailable(String),
        Unverified(String),
        Ignored(String),
        Signup(Box<monetize_pay::PaidSignup>),
        UnknownHandle(ChargeHandle),
        /// Added LAST, so every older arm keeps its tag (g-disputes).
        Reversal(Box<monetize_pay::Reversal>),
    }

    impl From<PayError> for WirePayError {
        fn from(e: PayError) -> Self {
            match e {
                PayError::Declined(s) => WirePayError::Declined(s),
                PayError::Unavailable(s) => WirePayError::Unavailable(s),
                PayError::Unverified(s) => WirePayError::Unverified(s),
                PayError::Ignored(s) => WirePayError::Ignored(s),
                PayError::Signup(p) => WirePayError::Signup(p),
                PayError::UnknownHandle(h) => WirePayError::UnknownHandle(h),
                PayError::Reversal(r) => WirePayError::Reversal(r),
            }
        }
    }

    impl From<WirePayError> for PayError {
        fn from(e: WirePayError) -> Self {
            match e {
                WirePayError::Declined(s) => PayError::Declined(s),
                WirePayError::Unavailable(s) => PayError::Unavailable(s),
                WirePayError::Unverified(s) => PayError::Unverified(s),
                WirePayError::Ignored(s) => PayError::Ignored(s),
                WirePayError::Signup(p) => PayError::Signup(p),
                WirePayError::UnknownHandle(h) => PayError::UnknownHandle(h),
                WirePayError::Reversal(r) => PayError::Reversal(r),
            }
        }
    }

    /// Every op's answer envelope.
    pub type PayAnswer<T> = Result<T, WirePayError>;

    /// [`Charge`] on the wire (owned).
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub enum WireCharge {
        Order(ChargeRequest),
        Signup(SignupOrder),
        CardCheck(SignupOrder),
        TestSignup(SignupOrder),
    }

    impl WireCharge {
        pub fn of(c: &Charge<'_>) -> WireCharge {
            match c {
                Charge::Order(r) => WireCharge::Order((*r).clone()),
                Charge::Signup(o) => WireCharge::Signup((*o).clone()),
                Charge::CardCheck(o) => WireCharge::CardCheck((*o).clone()),
                Charge::TestSignup(o) => WireCharge::TestSignup((*o).clone()),
            }
        }
        pub fn charge(&self) -> Charge<'_> {
            match self {
                WireCharge::Order(r) => Charge::Order(r),
                WireCharge::Signup(o) => Charge::Signup(o),
                WireCharge::CardCheck(o) => Charge::CardCheck(o),
                WireCharge::TestSignup(o) => Charge::TestSignup(o),
            }
        }
    }

    /// [`ops::PAGE`]'s request: what the page is for, and where the payer comes back to.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub struct PageReq {
        pub charge: WireCharge,
        /// [`Return::paid`]; empty: the vendor's configured page.
        pub paid: String,
        /// [`Return::cancel`]; empty: a cancel follows `paid`.
        pub cancel: String,
    }

    /// [`Page`] on the wire.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub enum WirePage {
        Order { handle: ChargeHandle, url: Option<String> },
        Signup(Option<SignupCheckout>),
        CardCheck(Option<SignupCheckout>),
        TestSignup(Option<PaidSignup>),
    }

    impl From<Page> for WirePage {
        fn from(p: Page) -> Self {
            match p {
                Page::Order { handle, url } => WirePage::Order { handle, url },
                Page::Signup(c) => WirePage::Signup(c),
                Page::CardCheck(c) => WirePage::CardCheck(c),
                Page::TestSignup(s) => WirePage::TestSignup(s),
            }
        }
    }

    impl From<WirePage> for Page {
        fn from(p: WirePage) -> Self {
            match p {
                WirePage::Order { handle, url } => Page::Order { handle, url },
                WirePage::Signup(c) => Page::Signup(c),
                WirePage::CardCheck(c) => Page::CardCheck(c),
                WirePage::TestSignup(s) => Page::TestSignup(s),
            }
        }
    }

    /// [`Of`] on the wire (owned).
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub enum WireOf {
        Charge(ChargeHandle),
        Page(ChargeHandle),
        Signup { session: String },
        CardCheck { session: String },
    }

    impl WireOf {
        pub fn of(o: &Of<'_>) -> WireOf {
            match o {
                Of::Charge(h) => WireOf::Charge((*h).clone()),
                Of::Page(h) => WireOf::Page((*h).clone()),
                Of::Signup { session } => WireOf::Signup { session: (*session).to_owned() },
                Of::CardCheck { session } => WireOf::CardCheck { session: (*session).to_owned() },
            }
        }
        pub fn of_ref(&self) -> Of<'_> {
            match self {
                WireOf::Charge(h) => Of::Charge(h),
                WireOf::Page(h) => Of::Page(h),
                WireOf::Signup { session } => Of::Signup { session },
                WireOf::CardCheck { session } => Of::CardCheck { session },
            }
        }
    }

    /// [`Standing`] on the wire.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub enum WireStanding {
        Charge(ChargeStatus),
        Page(Option<String>),
        Signup(Option<SignupAtVendor>),
        CardCheck(Option<CardCheck>),
    }

    impl From<Standing> for WireStanding {
        fn from(s: Standing) -> Self {
            match s {
                Standing::Charge(c) => WireStanding::Charge(c),
                Standing::Page(u) => WireStanding::Page(u),
                Standing::Signup(s) => WireStanding::Signup(s),
                Standing::CardCheck(c) => WireStanding::CardCheck(c),
            }
        }
    }

    impl From<WireStanding> for Standing {
        fn from(s: WireStanding) -> Self {
            match s {
                WireStanding::Charge(c) => Standing::Charge(c),
                WireStanding::Page(u) => Standing::Page(u),
                WireStanding::Signup(s) => Standing::Signup(s),
                WireStanding::CardCheck(c) => Standing::CardCheck(c),
            }
        }
    }

    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub struct RefundReq {
        pub handle: ChargeHandle,
        pub amount: Money,
    }

    /// An inbound webhook, as the server received it.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub struct CallbackReq {
        pub headers: Vec<(String, String)>,
        pub body: Vec<u8>,
    }

    /// [`CallbackEvent`] on the wire.
    #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub struct WireCallbackEvent {
        pub reference: String,
        pub handle: ChargeHandle,
        pub status: ChargeStatus,
    }

    #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    pub struct PaidSinceReq {
        pub since_unix_ms: i64,
    }
}

use wire::{Call, CallbackReq, PageReq, PaidSinceReq, RefundReq, WireCallbackEvent, WireOf, WirePage, WirePayError, WireStanding};

/// **A payment vendor, as a guest writes it: [`monetize_pay::Payment`]'s ten ops**, each
/// with the host and the block's settings handed in, because a guest has no socket, no
/// clock, no key and no memory of its own. The host answers `Payment` from them
/// (`monetize-product-wasm`'s `GuestVendor`); the contracts are those ops', word for word.
///
/// [`VendorGuest::manifest`] is the MODULE's (kind, id, grants: read with no settings at
/// load, before any op); [`VendorGuest::pay_manifest`] answers the `manifest` op, per block.
/// A vendor whose logic already has the `Payment` shape (Stripe, Klarna, Swish over their
/// guest environment) writes each op as one line over it.
pub trait VendorGuest {
    /// `Manifest::of_kind(kinds::VENDOR, "<name>", &[grants…])`.
    fn manifest(&self) -> Manifest;
    /// The `manifest` op: markets, setting names, `holds`, the test payer (`None` on a
    /// live key). `Err`: these settings are not ones this vendor can work with — asked
    /// once at load, so a block that could never take money is refused where an operator
    /// is looking, not at the first customer. The host names the instance itself.
    fn pay_manifest(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<PayManifest, String>;
    fn page(&self, host: &dyn Host, settings: &serde_json::Value, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError>;
    fn status(&self, host: &dyn Host, settings: &serde_json::Value, of: &Of<'_>) -> Result<Standing, PayError>;
    fn capture(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn release(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn refund(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError>;
    fn callback(&self, host: &dyn Host, settings: &serde_json::Value, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError>;
    fn settlement(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Option<Settlement>;
    fn paid_since(&self, host: &dyn Host, settings: &serde_json::Value, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError> {
        let _ = (host, settings, since_unix_ms);
        Err(monetize_pay::reconcile::cannot_be_asked())
    }
    /// `None` unless the guest knows.
    fn livemode(&self, host: &dyn Host, settings: &serde_json::Value) -> Option<bool> {
        let _ = (host, settings);
        None
    }
}

/// **The op switch** — the wasm export and a native test drive the same one.
/// An unknown op or an undecodable request is an `Err` in the envelope (as
/// `Unavailable`, which the ledger retries rather than acts on), never a trap.
pub fn dispatch(guest: &dyn VendorGuest, host: &dyn Host, op: u32, payload: &[u8]) -> Vec<u8> {
    fn call<T: serde::de::DeserializeOwned>(op: u32, payload: &[u8]) -> Result<Call<T>, WirePayError> {
        wire::decode(payload).map_err(|e| WirePayError::Unavailable(format!("op {op} ({}): undecodable request: {e}", ops::name(op).unwrap_or("?"))))
    }
    fn ans<T: serde::Serialize>(r: Result<T, WirePayError>) -> Vec<u8> {
        wire::encode(&r)
    }
    fn pay<T>(r: Result<T, PayError>) -> Result<T, WirePayError> {
        r.map_err(WirePayError::from)
    }
    match op {
        ops::ID => ans::<String>(Ok(guest.manifest().product)),
        ops::MANIFEST => ans(call::<()>(op, payload).and_then(|c| guest.pay_manifest(host, &c.settings).map_err(WirePayError::Unavailable))),
        ops::PAGE => ans(call::<PageReq>(op, payload).and_then(|c| {
            let back = Return { paid: c.req.paid.clone(), cancel: c.req.cancel.clone() };
            pay(guest.page(host, &c.settings, &c.req.charge.charge(), &back)).map(WirePage::from)
        })),
        ops::STATUS => ans(call::<WireOf>(op, payload).and_then(|c| pay(guest.status(host, &c.settings, &c.req.of_ref())).map(WireStanding::from))),
        ops::CAPTURE => ans(call::<ChargeHandle>(op, payload).and_then(|c| pay(guest.capture(host, &c.settings, &c.req)))),
        ops::RELEASE => ans(call::<ChargeHandle>(op, payload).and_then(|c| pay(guest.release(host, &c.settings, &c.req)))),
        ops::REFUND => ans(call::<RefundReq>(op, payload).and_then(|c| pay(guest.refund(host, &c.settings, &c.req.handle, c.req.amount)))),
        ops::CALLBACK => ans(call::<CallbackReq>(op, payload).and_then(|c| {
            let raw = RawCallback { headers: &c.req.headers, body: &c.req.body };
            pay(guest.callback(host, &c.settings, raw)).map(|e| WireCallbackEvent { reference: e.reference, handle: e.handle, status: e.status })
        })),
        ops::SETTLEMENT => ans(call::<ChargeHandle>(op, payload).map(|c| guest.settlement(host, &c.settings, &c.req))),
        ops::PAID_SINCE => ans(call::<PaidSinceReq>(op, payload).and_then(|c| pay(guest.paid_since(host, &c.settings, c.req.since_unix_ms)))),
        ops::LIVEMODE => ans(call::<()>(op, payload).map(|c| guest.livemode(host, &c.settings))),
        other => ans::<()>(Err(WirePayError::Unavailable(format!("op {other} {}", ops::UNKNOWN_OP_PHRASE)))),
    }
}


/// Generate the exports for a [`VendorGuest`] (which must also be `Default`).
///
/// ```ignore
/// monetize_pay_guest::export_vendor!(StripeGuest, grants = [http, hmac, state]);
/// ```
///
/// The grant tokens must match the manifest's grants — the host's gate B
/// refuses the module at load otherwise.
#[macro_export]
macro_rules! export_vendor {
    ($guest:ty, grants = [$($grant:ident),* $(,)?] $(,)?) => {
        $crate::__reexport::monetize_product_guest::__export_guest!($guest, $crate::VendorGuest, $crate::dispatch, grants = [$($grant),*]);
    };
}

#[cfg(test)]
mod tests {
    use super::*;
    use monetize_pay::{Currency, Market};
    use wire::WireCharge;

    /// A vendor that holds; a page for every charge kind it sells.
    #[derive(Default)]
    struct Toy;
    impl VendorGuest for Toy {
        fn manifest(&self) -> Manifest {
            Manifest::of_kind(kinds::VENDOR, "toy", &[])
        }
        fn pay_manifest(&self, _: &dyn Host, s: &serde_json::Value) -> Result<PayManifest, String> {
            if s["broken"].as_bool() == Some(true) {
                return Err("toy: `broken` is set".into());
            }
            Ok(PayManifest { vendor: "toy".into(), markets: vec![Market::Se], settings: vec!["TOY_KEY".into()], holds: true, test_payer: Some("pm_toy".into()) })
        }
        fn page(&self, _: &dyn Host, s: &serde_json::Value, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError> {
            if s["refuse"].as_bool() == Some(true) {
                return Err(PayError::Declined("told to".into()));
            }
            Ok(match charge {
                Charge::Order(req) => Page::Order { handle: ChargeHandle { vendor: "toy".into(), id: req.reference.clone() }, url: Some(format!("{}|{}", back.paid, back.cancel)) },
                Charge::Signup(o) => Page::Signup(Some(SignupCheckout { url: format!("https://pay/signup/{}", o.username), session: "cs_s".into() })),
                Charge::CardCheck(_) => Page::CardCheck(None),
                Charge::TestSignup(_) => Page::TestSignup(None),
            })
        }
        fn status(&self, _: &dyn Host, _: &serde_json::Value, of: &Of<'_>) -> Result<Standing, PayError> {
            Ok(match of {
                Of::Charge(_) => Standing::Charge(ChargeStatus::Reserved),
                Of::Page(h) => Standing::Page(Some(format!("https://pay/{}", h.id))),
                Of::Signup { .. } => Standing::Signup(Some(SignupAtVendor::Open)),
                Of::CardCheck { .. } => Standing::CardCheck(None),
            })
        }
        fn capture(&self, _: &dyn Host, _: &serde_json::Value, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            Ok(ChargeStatus::Captured { at_unix_ms: 1, vendor_ref: "r".into() })
        }
        fn release(&self, _: &dyn Host, _: &serde_json::Value, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            Err(PayError::UnknownHandle(h.clone()))
        }
        fn refund(&self, _: &dyn Host, _: &serde_json::Value, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
            Ok(ChargeStatus::Released)
        }
        fn callback(&self, _: &dyn Host, _: &serde_json::Value, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
            Err(PayError::Unverified(format!("{} bytes, no signature", raw.body.len())))
        }
        fn settlement(&self, _: &dyn Host, _: &serde_json::Value, _: &ChargeHandle) -> Option<Settlement> {
            None
        }
    }

    fn req<T: serde::Serialize>(settings: serde_json::Value, r: T) -> Vec<u8> {
        wire::encode(&Call { settings, req: r })
    }

    fn charge() -> ChargeRequest {
        ChargeRequest { reference: "gunnar/acme/2026-09".into(), amount: Money { minor: 100, currency: Currency::Sek }, market: Market::Se, description: "d".into(), payer: None }
    }

    fn order() -> SignupOrder {
        SignupOrder {
            username: "olle".into(),
            email: "olle@mock.invalid".into(),
            product: "gunnar".into(),
            plan: "gunnar/10gb-1m".into(),
            amount: Money { minor: 10_000, currency: Currency::Sek },
            market: Market::Se,
            description: "d".into(),
            success_url: "https://x.mock.invalid/welcome?session={session}".into(),
            cancel_url: String::new(),
        }
    }

    /// ★ **A24 §2.6: the vendor op table IS the ten ops of `Payment`**, by name and in its
    /// order, plus the `id` every kind answers — and none of the twenty before it.
    #[test]
    fn the_vendor_op_table_is_the_ten_payment_ops() {
        let names: Vec<&str> = ops::VENDOR.iter().map(|(_, n)| *n).collect();
        assert_eq!(names, ["manifest", "page", "status", "capture", "release", "refund", "callback", "settlement", "paid_since", "livemode"]);
        assert_eq!(ops::ALL.len(), 11, "the ten and the id");
        for retired in ["check", "markets", "reserve", "redirect", "start_signup", "parse_callback", "test_payer", "pay_signup_as_test", "start_card_check", "card_check", "signup_at_vendor", "holds"] {
            assert!(ops::ALL.iter().all(|(_, n)| *n != retired), "{retired} is retired");
        }
        for old in 100..=118 {
            assert_eq!(ops::name(old), None, "op {old} of the old table is never reused");
        }
    }

    /// The four money flows cross the ONE `page` op: an order (held) with its way back, a
    /// sign-up (charged), a card check and a test sign-up, each answered in its own arm.
    #[test]
    fn every_charge_kind_crosses_the_one_page_op_with_its_way_back() {
        let page = |c: WireCharge, paid: &str, cancel: &str| -> wire::PayAnswer<WirePage> {
            wire::decode(&dispatch(&Toy, &NoHost, ops::PAGE, &req(serde_json::json!({}), PageReq { charge: c, paid: paid.into(), cancel: cancel.into() }))).unwrap()
        };
        assert_eq!(
            page(WireCharge::Order(charge()), "https://a/paid", "https://a/cancel"),
            Ok(WirePage::Order { handle: ChargeHandle { vendor: "toy".into(), id: "gunnar/acme/2026-09".into() }, url: Some("https://a/paid|https://a/cancel".into()) })
        );
        assert_eq!(page(WireCharge::Signup(order()), "", ""), Ok(WirePage::Signup(Some(SignupCheckout { url: "https://pay/signup/olle".into(), session: "cs_s".into() }))));
        assert_eq!(page(WireCharge::CardCheck(order()), "", ""), Ok(WirePage::CardCheck(None)), "None is an answer");
        assert_eq!(page(WireCharge::TestSignup(order()), "", ""), Ok(WirePage::TestSignup(None)));
        let o = order();
        assert_eq!(WireCharge::of(&Charge::CardCheck(&o)).charge(), Charge::CardCheck(&o), "the wire charge is the charge");
    }

    /// Every question crosses the ONE `status` op and comes back in its own arm.
    #[test]
    fn every_question_crosses_the_one_status_op() {
        let h = ChargeHandle { vendor: "toy".into(), id: "x".into() };
        let ask = |of: WireOf| -> wire::PayAnswer<WireStanding> { wire::decode(&dispatch(&Toy, &NoHost, ops::STATUS, &req(serde_json::Value::Null, of))).unwrap() };
        assert_eq!(ask(WireOf::Charge(h.clone())), Ok(WireStanding::Charge(ChargeStatus::Reserved)));
        assert_eq!(ask(WireOf::Page(h.clone())), Ok(WireStanding::Page(Some("https://pay/x".into()))));
        assert_eq!(ask(WireOf::Signup { session: "cs".into() }), Ok(WireStanding::Signup(Some(SignupAtVendor::Open))));
        assert_eq!(ask(WireOf::CardCheck { session: "cs".into() }), Ok(WireStanding::CardCheck(None)));
        assert_eq!(WireOf::of(&Of::Signup { session: "cs" }).of_ref(), Of::Signup { session: "cs" });
        assert_eq!(Standing::from(WireStanding::from(Standing::Page(None))), Standing::Page(None));
    }

    /// The `manifest` op is the old `check` + `markets` + `holds` + `test_payer` in one: a
    /// block the vendor refuses is an `Err` by name.
    #[test]
    fn the_manifest_op_answers_the_vendors_facts_or_refuses_the_block() {
        let m: wire::PayAnswer<PayManifest> = wire::decode(&dispatch(&Toy, &NoHost, ops::MANIFEST, &req(serde_json::json!({}), ()))).unwrap();
        let m = m.unwrap();
        assert_eq!((m.markets, m.holds, m.test_payer.as_deref()), (vec![Market::Se], true, Some("pm_toy")));
        let no: wire::PayAnswer<PayManifest> = wire::decode(&dispatch(&Toy, &NoHost, ops::MANIFEST, &req(serde_json::json!({"broken": true}), ()))).unwrap();
        assert_eq!(no, Err(WirePayError::Unavailable("toy: `broken` is set".into())));
    }

    #[test]
    fn every_arm_of_the_error_survives_the_wire_and_the_settings_ride_every_call() {
        let paged = |s: serde_json::Value| -> wire::PayAnswer<WirePage> { wire::decode(&dispatch(&Toy, &NoHost, ops::PAGE, &req(s, PageReq { charge: WireCharge::Order(charge()), paid: String::new(), cancel: String::new() }))).unwrap() };
        assert!(paged(serde_json::json!({})).is_ok());
        assert!(matches!(PayError::from(paged(serde_json::json!({"refuse": true})).unwrap_err()), PayError::Declined(s) if s == "told to"));
        let h = ChargeHandle { vendor: "toy".into(), id: "x".into() };
        let unk: wire::PayAnswer<ChargeStatus> = wire::decode(&dispatch(&Toy, &NoHost, ops::RELEASE, &req(serde_json::Value::Null, &h))).unwrap();
        assert!(matches!(PayError::from(unk.unwrap_err()), PayError::UnknownHandle(x) if x == h));
        let cb = CallbackReq { headers: vec![], body: b"{}".to_vec() };
        let unv: wire::PayAnswer<WireCallbackEvent> = wire::decode(&dispatch(&Toy, &NoHost, ops::CALLBACK, &req(serde_json::Value::Null, &cb))).unwrap();
        assert!(matches!(PayError::from(unv.unwrap_err()), PayError::Unverified(_)));
        let since: wire::PayAnswer<Vec<PaidAtVendor>> = wire::decode(&dispatch(&Toy, &NoHost, ops::PAID_SINCE, &req(serde_json::Value::Null, PaidSinceReq { since_unix_ms: 0 }))).unwrap();
        assert!(matches!(PayError::from(since.unwrap_err()), PayError::Unavailable(s) if s == monetize_pay::reconcile::CANNOT_BE_ASKED));
        let bad: wire::PayAnswer<()> = wire::decode(&dispatch(&Toy, &NoHost, ops::PAGE, b"nope")).unwrap();
        assert!(matches!(PayError::from(bad.unwrap_err()), PayError::Unavailable(s) if s.contains("undecodable")));
        let id: wire::PayAnswer<String> = wire::decode(&dispatch(&Toy, &NoHost, ops::ID, &[])).unwrap();
        assert_eq!(id.unwrap(), "toy");
        assert_eq!(Toy.manifest().kind, kinds::VENDOR);
    }

    /// MON-OSSF (2026-10-31): **every arm of [`PayError`] crosses the wire both ways unchanged**.
    /// The server answers a webhook by the arm (401 for Unverified, 200 for Ignored, a new
    /// account for Signup, a ledger row for Reversal), so an arm that turned into another on the
    /// way out of a guest is a vendor endpoint disabled or a customer lost.
    #[test]
    fn every_pay_error_arm_round_trips_through_the_wire_error_and_its_bytes() {
        let h = ChargeHandle { vendor: "toy".into(), id: "cs_1".into() };
        let signup = monetize_pay::PaidSignup {
            vendor: "toy".into(),
            session: "cs_1".into(),
            reference: "pi_1".into(),
            email: "olle@mock.invalid".into(),
            username: "olle".into(),
            product: "gunnar".into(),
            plan: "gunnar/10gb-1m".into(),
            amount: Money { minor: 10_000, currency: Currency::Sek },
            tax: Money { minor: 0, currency: Currency::Sek },
            paid_at_unix_ms: 1,
            customer_country: None,
        };
        let reversal = monetize_pay::Reversal {
            vendor: "toy".into(),
            handle: h.clone(),
            reference: "gunnar/acme/2026-10".into(),
            kind: monetize_pay::ReversalKind::DisputeOpened,
            id: "dp_1".into(),
            amount: Money { minor: 12_500, currency: Currency::Sek },
            reason: "fraudulent".into(),
        };
        let all = [
            PayError::Declined("d".into()),
            PayError::Unavailable("u".into()),
            PayError::Unverified("v".into()),
            PayError::Ignored("i".into()),
            PayError::Signup(Box::new(signup)),
            PayError::UnknownHandle(h),
            PayError::Reversal(Box::new(reversal)),
        ];
        for e in all {
            let words = e.to_string();
            let tag = std::mem::discriminant(&e);
            let w = WirePayError::from(e);
            let back: WirePayError = wire::decode(&wire::encode(&w)).expect("the wire error decodes");
            assert_eq!(back, w, "the bytes keep the arm and its payload");
            let e = PayError::from(back);
            assert_eq!(std::mem::discriminant(&e), tag, "{words}: the arm survives");
            assert_eq!(e.to_string(), words, "and so does what it says");
        }
    }

    /// A guest that writes only the required ops still answers the two with defaults, with
    /// "cannot": no live/test word, no reconcile.
    #[test]
    fn the_defaulted_ops_answer_cannot_and_the_required_ones_reach_the_guest() {
        let none = serde_json::Value::Null;
        let h = ChargeHandle { vendor: "toy".into(), id: "x".into() };
        let live: wire::PayAnswer<Option<bool>> = wire::decode(&dispatch(&Toy, &NoHost, ops::LIVEMODE, &req(none.clone(), ()))).unwrap();
        assert_eq!(live, Ok(None), "a guest that cannot tell its ledger says so");
        let cap: wire::PayAnswer<ChargeStatus> = wire::decode(&dispatch(&Toy, &NoHost, ops::CAPTURE, &req(none.clone(), &h))).unwrap();
        assert_eq!(cap, Ok(ChargeStatus::Captured { at_unix_ms: 1, vendor_ref: "r".into() }));
        let refund = RefundReq { handle: h.clone(), amount: Money { minor: 100, currency: Currency::Sek } };
        let rf: wire::PayAnswer<ChargeStatus> = wire::decode(&dispatch(&Toy, &NoHost, ops::REFUND, &req(none.clone(), refund))).unwrap();
        assert_eq!(rf, Ok(ChargeStatus::Released));
        let set: wire::PayAnswer<Option<Settlement>> = wire::decode(&dispatch(&Toy, &NoHost, ops::SETTLEMENT, &req(none, &h))).unwrap();
        assert_eq!(set, Ok(None));
    }

    /// Every op in the table is wired to the switch: given bytes it cannot decode, each answers
    /// "undecodable request" under its own name, never the unknown-op words, which only an op
    /// outside the table gets — an old guest's op among them.
    #[test]
    fn every_op_in_the_table_is_switched_and_only_an_unknown_op_gets_the_unknown_words() {
        for (op, name) in ops::VENDOR {
            let a: wire::PayAnswer<serde_json::Value> = wire::decode(&dispatch(&Toy, &NoHost, *op, b"\xff not a request")).unwrap();
            match a.map_err(PayError::from) {
                Err(PayError::Unavailable(s)) => {
                    assert!(s.contains("undecodable request") && s.contains(&format!("({name})")), "{name}: {s}");
                    assert!(!s.contains(ops::UNKNOWN_OP_PHRASE), "{name} is in the table: {s}");
                }
                other => panic!("{name}: {other:?}"),
            }
        }
        for unknown in [999, 100, 102, 118] {
            let a: wire::PayAnswer<()> = wire::decode(&dispatch(&Toy, &NoHost, unknown, &[])).unwrap();
            match a.map_err(PayError::from) {
                Err(PayError::Unavailable(s)) => assert_eq!(s, format!("op {unknown} {}", ops::UNKNOWN_OP_PHRASE)),
                other => panic!("{other:?}"),
            }
        }
        let mut numbers: Vec<u32> = ops::ALL.iter().map(|(n, _)| *n).collect();
        numbers.sort();
        numbers.dedup();
        assert_eq!(numbers.len(), ops::ALL.len(), "no op number is used twice");
    }
}