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