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

//! **The `Payment` conformance suite** — one test every [`Payment`] runs against its
//! fake (Stripe at the fake Stripe, Klarna at the fake Klarna, Swish at the fake
//! Swish, the host's guest adapter over a signed module at any of them). It holds a
//! vendor to the contract the ledger relies on, and to its own word on `holds`:
//!
//! 1. the manifest names the vendor and sells in the order's market; the fake is
//!    never live money;
//! 2. `page(Order)` mints a page: a handle of this vendor, nothing drawn —
//!    `status` is `AwaitingCustomer` and `capture` draws nothing;
//! 3. the customer pays (the fixture plays the customer and, where the vendor
//!    learns of it by a notice, delivers that notice through `callback`);
//! 4. **`holds` honesty**: a vendor that says it holds now answers `Reserved`; one
//!    that says it does not answers `Captured` at once;
//! 5. `capture` answers `Captured`, and again `Captured` (idempotent, never drawn twice);
//! 6. `refund` of a part answers the charge's standing after;
//! 7. `settlement` and `paid_since` answer without panicking;
//! 8. a second order is RELEASED: before the customer pays where the vendor cannot
//!    hold, after the customer paid where it can — and reads `Released` after;
//! 9. an unverifiable notice is `Unverified` or `Ignored`, never an event.
//!
//! The suite is a function, not a `#[test]`, so each vendor's own test builds its
//! fixture (its fake, its order, its customer) and calls it. It panics with the step
//! named.

use crate::payment::{Charge, Of, Page, Payment, Return, Standing};
use crate::{ChargeHandle, ChargeRequest, ChargeStatus, Money, PayError, RawCallback};

/// **What one implementation hands the suite.**
pub struct PayFixture<'a> {
    pub payment: &'a dyn Payment,
    /// An order in a market the vendor sells in; the suite derives a second one from it.
    pub order: ChargeRequest,
    /// **The customer pays the page behind `handle`** at the fake — and, where the
    /// vendor learns of the payment by a notice, delivers that notice to
    /// `payment.callback` (the fixture knows the fake's notice; the suite does not).
    pub customer_pays: &'a dyn Fn(&dyn Payment, &ChargeHandle),
    /// Less than the order's amount.
    pub refund: Money,
    /// A notice nobody signed, for step 9. `None` skips the step.
    pub forged_notice: Option<Notice>,
}

/// A notice as it arrives: its headers and its body.
pub type Notice = (Vec<(String, String)>, Vec<u8>);

fn standing(p: &dyn Payment, h: &ChargeHandle, step: &str) -> ChargeStatus {
    match p.status(&Of::Charge(h)).unwrap_or_else(|e| panic!("{step}: status answers: {e:?}")) {
        Standing::Charge(s) => s,
        other => panic!("{step}: status answered {other:?} to a charge"),
    }
}

fn mint(p: &dyn Payment, order: &ChargeRequest, step: &str) -> (ChargeHandle, Option<String>) {
    match p.page(&Charge::Order(order), &Return::vendors_own()).unwrap_or_else(|e| panic!("{step}: page(Order) mints: {e:?}")) {
        Page::Order { handle, url } => (handle, url),
        other => panic!("{step}: page answered {other:?} to an order"),
    }
}

/// **pay → hold or capture → capture → refund; release; `holds` honesty.**
pub fn pay_hold_capture_refund(f: &PayFixture<'_>) {
    let p = f.payment;

    // 1. the manifest
    let m = p.manifest();
    assert!(!m.vendor.trim().is_empty(), "manifest: the vendor is named");
    assert!(m.markets.contains(&f.order.market), "manifest: the order's market {:?} is sold: {:?}", f.order.market, m.markets);
    assert_ne!(p.livemode(), Some(true), "livemode: a fake is never live money");
    assert!(f.refund.minor < f.order.amount.minor, "fixture: the refund is a part of the order");

    // 2. the page, nothing drawn
    let (h, url) = mint(p, &f.order, "page");
    assert!(!h.id.is_empty(), "page: a handle");
    if let Some(u) = &url {
        assert!(u.contains("://"), "page: the URL is a URL (https or the app's own scheme): {u}");
    }
    assert_eq!(standing(p, &h, "status.before"), ChargeStatus::AwaitingCustomer, "status: nothing held before the customer pays");
    assert_eq!(p.capture(&h).expect("capture.before: answers"), ChargeStatus::AwaitingCustomer, "capture: draws nothing before the customer pays");
    let page_again = match p.status(&Of::Page(&h)) {
        Ok(Standing::Page(u)) => u,
        Ok(other) => panic!("status(Page): answered {other:?}"),
        Err(e) => panic!("status(Page): {e:?}"),
    };
    if url.is_some() {
        assert_eq!(page_again, url, "status(Page): the page's URL, read again");
    }

    // 3. the customer pays
    (f.customer_pays)(p, &h);

    // 4. holds honesty
    let after = standing(p, &h, "status.paid");
    if m.holds {
        assert_eq!(after, ChargeStatus::Reserved, "holds = true: the money is HELD once the customer paid");
    } else {
        assert!(matches!(after, ChargeStatus::Captured { .. }), "holds = false: the money MOVED when the customer paid: {after:?}");
    }

    // 5. capture, twice
    assert!(matches!(p.capture(&h).expect("capture: answers"), ChargeStatus::Captured { .. }), "capture: drawn");
    assert!(matches!(p.capture(&h).expect("capture.again: answers"), ChargeStatus::Captured { .. }), "capture: idempotent");
    assert!(matches!(standing(p, &h, "status.captured"), ChargeStatus::Captured { .. }));

    // 6. a partial refund
    let r = p.refund(&h, f.refund).expect("refund: a part is given back");
    assert!(matches!(r, ChargeStatus::Captured { .. }), "refund: the charge stands captured after a partial refund: {r:?}");

    // 7. the back office
    // A settlement row, when the vendor says one, is in the order's currency (it was `let _ =`).
    if let Some(s) = p.settlement(&h) {
        assert_eq!(s.fee.currency, f.order.amount.currency, "settlement: the fee is in the order's currency");
        assert_eq!(s.net.currency, f.order.amount.currency, "settlement: the net is in the order's currency");
    }
    match p.paid_since(0) {
        Ok(_) => {}
        Err(PayError::Unavailable(_)) => {}
        Err(e) => panic!("paid_since: an outage or a list, never {e:?}"),
    }

    // 8. a second order, released
    let second = ChargeRequest { reference: format!("{}+2", f.order.reference), ..f.order.clone() };
    let (h2, _) = mint(p, &second, "page.second");
    if m.holds {
        (f.customer_pays)(p, &h2);
        assert_eq!(standing(p, &h2, "status.second"), ChargeStatus::Reserved);
    }
    assert_eq!(p.release(&h2).expect("release: answers"), ChargeStatus::Released, "release: unlocked without drawing");
    assert_eq!(standing(p, &h2, "status.released"), ChargeStatus::Released, "status: released, read again");
    assert_eq!(p.release(&h2).expect("release.again: answers"), ChargeStatus::Released, "release: idempotent");

    // 9. a forged notice
    if let Some((headers, body)) = &f.forged_notice {
        match p.callback(RawCallback { headers, body }) {
            Err(PayError::Unverified(_)) | Err(PayError::Ignored(_)) => {}
            Ok(ev) => panic!("callback: a notice nobody signed became an event: {ev:?}"),
            Err(e) => panic!("callback: a notice nobody signed is Unverified or Ignored, not {e:?}"),
        }
    }
}