crates/monetize-pay/src/sandbox/mod.rs
//! **`sandbox` — the vendor that always has money, and never has a network.**
//!
//! A clone of a real vendor, compiled outside release, that never talks to the
//! real one and always has money.
//!
//! This is that. It implements [`Payment`] completely and honestly in
//! memory: a reservation is reserved, a capture captures, a release releases,
//! `status` answers from its own ledger, and a callback must VERIFY before it is
//! believed. What it does not have is a socket. There is no `reqwest` on this
//! path, no base URL, no credential, and nothing to point at a real vendor by
//! accident.
//!
//! # Why this is not `MockPay`
//!
//! [`monetize::testing::MockPay`] already fakes a vendor for unit tests, and it
//! stays where it is. It is a test double: it records calls, it can be told to
//! fail, and it is constructed by hand in a test body. This is a **deployable
//! vendor** — `registry::build_vendor` can hand it out by name, so the whole
//! money path (`Purchase.Start` → reserve → `ensure` → capture → a signed
//! `EntitlementFact` pushed to gunnar) runs on a real box, over real gRPC,
//! against a real product, with no merchant agreement and no money.
//!
//! # It is compiled out, not switched off
//!
//! The suite's doctrine (gunnar's `metered`, `wg-client`, `dhcp-client`): a
//! capability a deployment must not have is one it does not CONTAIN. `sandbox` is
//! a cargo feature; a build without it does not compile this file at all and
//! `registry::build_vendor` refuses the name by saying it is "not compiled into
//! this server" — asserted in both directions by `monetize_server::registry`'s
//! tests, whichever way the feature is set. Since monetize v2.0 P1 (owner
//! 2026-10-24: the open DEFAULT build runs money-free out of the box) the open
//! server's `default` turns it on: a fresh build sells and lays through this
//! vendor and the sandbox cloud until a payment guest and a cloud guest are fed.
//!
//! **OWED:** gunnar proves the same claim harder, by reading the SYMBOL TABLE of
//! the built binary (`gunnar-recipe/tests/metered_symbols.rs`). That is the
//! difference between "compiled out" as a cargo claim and as a measured fact, and
//! this crate does not have it yet. Until it does, the guarantee here rests on
//! the feature being a feature plus the registry refusal — which is real,
//! but is not a reading of the artifact.
//!
//! # Every fact it produces says so
//!
//! The reference it captures against travels into the entitlement's `source` as
//! `payment:sandbox:<reference>`, which gunnar appends verbatim to
//! `attestations.jsonl`. So a tenant that was paid for with play money is
//! distinguishable forever, in a signed log, from one that was not. That
//! property is the whole reason this is safe to point at a production product.
//!
//! # What it refuses to pretend
//!
//! * a callback that does not verify is [`PayError::Unverified`], exactly as a
//! real vendor's would be — the forged-callback law is not relaxed here, it is
//! the one behaviour most worth exercising;
//! * `capture` on a handle it never minted is [`PayError::UnknownHandle`];
//! * `capture` after `release` is refused, and vice versa, because a vendor that
//! lets you do both is a vendor no ledger can reconcile against;
//! * it never invents a currency: the amount and currency it was asked for are
//! the amount and currency it reports.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::signup::{PaidSignup, SignupCheckout, SignupOrder, SIGNUP_SESSION_TOKEN};
use crate::{
ChargeHandle, ChargeRequest, ChargeStatus, CallbackEvent, Market, Money, PayError,
RawCallback,
};
/// The vendor name, in `ChargeHandle::vendor`, in `registry::build_vendor`, and
/// in every `payment:sandbox:<ref>` source line.
pub const VENDOR: &str = "sandbox";
/// Environment names [`SandboxConfig::from_env`] reads. The one writer of the
/// list, like every other vendor's.
pub const CONFIG_ENVS: &[&str] = &["MONETIZE_SANDBOX_CALLBACK_SECRET", "MONETIZE_SANDBOX_STATE"];
/// How a server links the rehearsal vendor. It is a REHEARSAL vendor: every
/// entitlement it pays for carries `payment:sandbox:<reference>` into the
/// product's attestation log, so a tenant bought with play money stays
/// distinguishable from a real one forever, in a signed line. That property is
/// what makes it safe to point at a production product.
pub const FACTORY: crate::factory::VendorFactory = crate::factory::VendorFactory { name: VENDOR, config_envs: CONFIG_ENVS, build };
fn build(ctx: &crate::factory::VendorBuild<'_>) -> Result<Box<dyn crate::Payment>, String> {
let mut sb = SandboxConfig::from_env();
// A DEPLOYED sandbox keeps its charges beside the ledger. Without this its
// handles evaporate on restart and a captured attempt can be neither
// finished nor abandoned — measured on gunnar.rs 2026-09-04.
// `MONETIZE_SANDBOX_STATE` still wins if an operator set one; the default
// is simply not "forget everything".
sb.state_path.get_or_insert_with(|| ctx.data_dir.join("sandbox-charges.json"));
Ok(Box::new(SandboxProvider::new(sb)))
}
/// The header a callback presents its signature in.
pub const SIGNATURE_HEADER: &str = "x-monetize-sandbox-signature";
#[derive(Clone, Debug, Default)]
pub struct SandboxConfig {
/// Shared secret a callback must sign with. **Empty disables callbacks
/// entirely** — every push is `Unverified` — which is safer than believing
/// an unauthenticated one, and is the same choice klarna's impl makes.
pub callback_secret: String,
/// **Where the vendor's own ledger lives.** `None` keeps it in memory, which
/// is right for a test and WRONG for a deployment.
///
/// A real vendor's state lives at the vendor and outlives our process. The
/// sandbox's "vendor" IS the process, so without this its handles evaporate
/// on restart — and MEASURED on gunnar.rs 2026-09-04, that is exactly what
/// happened: a purchase reserved and captured, the service was restarted,
/// and the refund came back `unknown handle sandbox:sbx_…`. An attempt could
/// then be neither finished nor abandoned.
///
/// Worse than the inconvenience: monetize's whole ledger design is about
/// resuming an attempt after a crash, and a vendor that forgets across a
/// restart cannot rehearse the one property most worth rehearsing.
pub state_path: Option<PathBuf>,
}
impl SandboxConfig {
/// `MONETIZE_SANDBOX_CALLBACK_SECRET` (optional; absent ⇒ callbacks refused)
/// and `MONETIZE_SANDBOX_STATE` (optional; absent ⇒ in memory).
pub fn from_env() -> Self {
SandboxConfig {
callback_secret: std::env::var("MONETIZE_SANDBOX_CALLBACK_SECRET").unwrap_or_default(),
state_path: std::env::var_os("MONETIZE_SANDBOX_STATE").map(PathBuf::from).filter(|p| !p.as_os_str().is_empty()),
}
}
}
/// The vendor's own ledger, as it is stored.
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Stored {
rows: BTreeMap<String, Row>,
by_reference: BTreeMap<String, String>,
/// Signup checkouts this vendor has minted, by its own session id. `default`
/// because a state file written before signups existed has no such key and
/// must still load — a vendor that refused to parse its own older ledger
/// would forget every charge in it, which is the failure `SandboxProvider::new`
/// already warns about.
#[serde(default)]
signups: BTreeMap<String, PaidSignup>,
}
/// One reservation, as the vendor remembers it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct Row {
reference: String,
amount: Money,
status: ChargeStatus,
}
/// A payment vendor with infinite funds and no socket.
pub struct SandboxProvider {
cfg: SandboxConfig,
/// handle id → row. The vendor's own truth, which `status` answers from.
rows: Mutex<BTreeMap<String, Row>>,
/// reference → handle id, so a second `reserve` for a reference already
/// captured is idempotent instead of minting a second charge.
by_reference: Mutex<BTreeMap<String, String>>,
/// session id → the signup that page is selling. See [`Vendor::signup_page`].
signups: Mutex<BTreeMap<String, PaidSignup>>,
clock: Box<dyn Fn() -> u64 + Send + Sync>,
markets: Vec<Market>,
}
impl std::fmt::Debug for SandboxProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SandboxProvider").field("rows", &self.rows.lock().map(|r| r.len()).unwrap_or(0)).finish_non_exhaustive()
}
}
impl SandboxProvider {
pub fn new(cfg: SandboxConfig) -> Self {
// A file that does not parse must not silently become an empty vendor:
// that is how a restart quietly forgets money. Say so and start empty,
// which is at least visible in the log.
let stored: Stored = match cfg.state_path.as_deref().map(std::fs::read) {
Some(Ok(bytes)) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
eprintln!("monetize-pay/sandbox: {} does not parse ({e}); starting with no charges", cfg.state_path.as_deref().unwrap_or(Path::new("?")).display());
Stored::default()
}),
_ => Stored::default(),
};
SandboxProvider {
cfg,
rows: Mutex::new(stored.rows),
by_reference: Mutex::new(stored.by_reference),
signups: Mutex::new(stored.signups),
clock: Box::new(crate::now_unix_ms),
markets: vec![Market::Se, Market::Fi],
}
}
/// Write the vendor's ledger, if it has a home. Called after every change:
/// a vendor that remembers only until the next restart is not a vendor.
fn persist(&self) {
let Some(path) = self.cfg.state_path.as_deref() else { return };
let stored = Stored {
rows: self.rows.lock().unwrap_or_else(|e| e.into_inner()).clone(),
by_reference: self.by_reference.lock().unwrap_or_else(|e| e.into_inner()).clone(),
signups: self.signups.lock().unwrap_or_else(|e| e.into_inner()).clone(),
};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_vec_pretty(&stored).map_err(|e| e.to_string()).and_then(|b| std::fs::write(path, b).map_err(|e| e.to_string())) {
Ok(()) => {}
// Loud, because the next restart is when it matters and by then the
// charge is gone.
Err(e) => eprintln!("monetize-pay/sandbox: cannot write {}: {e} — charges will not survive a restart", path.display()),
}
}
/// A fixed clock, so a test can assert the captured timestamp.
pub fn with_clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
self.clock = Box::new(clock);
self
}
/// The signature a caller must present for [`Payment::callback`]
/// to believe it: HMAC-free by design (no crypto dependency for a feature that
/// must not ship), a BLAKE-free constant-time compare over
/// `<secret>:<reference>:<handle id>`. It is a shared secret, not a
/// public-key scheme, and it is checked with [`crate::ct_eq`] so a forgery
/// learns nothing from timing.
pub fn signature(secret: &str, reference: &str, handle_id: &str) -> String {
// Sum-free, allocation-light, and deterministic: the point is that a
// caller without the secret cannot produce it, not cryptographic novelty.
let material = format!("{secret}:{reference}:{handle_id}");
material.bytes().fold(String::new(), |mut acc, b| {
acc.push_str(&format!("{b:02x}"));
acc
})
}
/// **The reference a signup callback signs**, in place of a ledger
/// reference. A signup holds none — that is what makes it a signup — so the
/// session id stands in both halves of [`SandboxProvider::signature`] and a
/// signature minted for a charge cannot be replayed as one for a signup.
pub const SIGNUP_REFERENCE: &'static str = "signup";
/// The signature a `{"signup": "<session>"}` callback must present.
pub fn signup_signature(secret: &str, session: &str) -> String {
Self::signature(secret, Self::SIGNUP_REFERENCE, session)
}
/// **The customer paid on the rehearsal page.** Always a [`PayError`],
/// because that is the channel `parse_callback` answers a signup on
/// ([`PayError::Signup`]) — and the refusals are the other arms of it.
fn signup_callback(&self, raw: &RawCallback<'_>, session: &str) -> PayError {
let offered = raw
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(SIGNATURE_HEADER))
.map(|(_, v)| v.as_str())
.unwrap_or_default();
let expected = Self::signup_signature(&self.cfg.callback_secret, session);
if !crate::ct_eq(expected.as_bytes(), offered.as_bytes()) {
return PayError::Unverified("sandbox: signup callback signature does not verify".into());
}
// Verified, and only now may it be believed. A session this vendor never
// minted is `Ignored` and not `Unverified`: the delivery was perfectly
// good and is about nothing we hold, which is the distinction that keeps
// a real endpoint from being disabled.
match self.signups.lock().unwrap_or_else(|e| e.into_inner()).get(session) {
Some(signup) => PayError::Signup(Box::new(signup.clone())),
None => PayError::Ignored(format!("sandbox: no signup checkout {session}")),
}
}
fn row(&self, handle: &ChargeHandle) -> Result<Row, PayError> {
if handle.vendor != VENDOR {
return Err(PayError::UnknownHandle(handle.clone()));
}
self.rows.lock().unwrap_or_else(|e| e.into_inner()).get(&handle.id).cloned().ok_or_else(|| PayError::UnknownHandle(handle.clone()))
}
}
#[allow(dead_code)]
impl SandboxProvider {
pub fn vendor(&self) -> &'static str {
VENDOR
}
pub fn markets(&self) -> &[Market] {
&self.markets
}
pub fn reserve(&self, req: &ChargeRequest) -> Result<ChargeHandle, PayError> {
if req.reference.trim().is_empty() {
return Err(PayError::Declined("sandbox: a charge with no reference".into()));
}
if req.amount.minor == 0 {
return Err(PayError::Declined("sandbox: a charge of zero".into()));
}
if req.amount.currency != req.market.currency() {
return Err(PayError::Declined(format!(
"sandbox: {:?} is not {:?}'s currency",
req.amount.currency, req.market
)));
}
// Idempotent on the reference: the ledger key is the reference, so a
// retried `Purchase.Start` must not mint a second charge.
let mut by_ref = self.by_reference.lock().unwrap_or_else(|e| e.into_inner());
if let Some(existing) = by_ref.get(&req.reference) {
return Ok(ChargeHandle { vendor: VENDOR.into(), id: existing.clone() });
}
let id = format!("sbx_{}", crate::now_unix_ms());
let id = {
// Collisions inside one millisecond are real under a test loop.
let mut candidate = id;
let rows = self.rows.lock().unwrap_or_else(|e| e.into_inner());
let mut n = 0u32;
while rows.contains_key(&candidate) {
n += 1;
candidate = format!("sbx_{}_{n}", crate::now_unix_ms());
}
candidate
};
self.rows.lock().unwrap_or_else(|e| e.into_inner()).insert(
id.clone(),
Row { reference: req.reference.clone(), amount: req.amount, status: ChargeStatus::Reserved },
);
by_ref.insert(req.reference.clone(), id.clone());
drop(by_ref);
self.persist();
Ok(ChargeHandle { vendor: VENDOR.into(), id })
}
pub fn draw(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
let mut rows = self.rows.lock().unwrap_or_else(|e| e.into_inner());
let row = rows.get_mut(&handle.id).filter(|_| handle.vendor == VENDOR).ok_or_else(|| PayError::UnknownHandle(handle.clone()))?;
match &row.status {
// Idempotent: capturing a captured charge answers the same thing.
ChargeStatus::Captured { .. } => Ok(row.status.clone()),
// The rehearsal vendor has infinite funds and no customer to wait for,
// so a reserve here always holds — `AwaitingCustomer` is listed to say
// that out loud rather than to be reachable.
ChargeStatus::Reserved | ChargeStatus::AwaitingCustomer => {
row.status = ChargeStatus::Captured { at_unix_ms: (self.clock)(), vendor_ref: format!("sbxcap_{}", handle.id) };
let out = row.status.clone();
drop(rows);
self.persist();
Ok(out)
}
ChargeStatus::Released => Err(PayError::Declined(format!("sandbox: {} was released; nothing to capture", handle.id))),
ChargeStatus::Declined { reason } => Err(PayError::Declined(format!("sandbox: {} was declined: {reason}", handle.id))),
}
}
pub fn unlock(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
let mut rows = self.rows.lock().unwrap_or_else(|e| e.into_inner());
let row = rows.get_mut(&handle.id).filter(|_| handle.vendor == VENDOR).ok_or_else(|| PayError::UnknownHandle(handle.clone()))?;
match &row.status {
ChargeStatus::Released => Ok(ChargeStatus::Released),
ChargeStatus::Reserved | ChargeStatus::AwaitingCustomer | ChargeStatus::Declined { .. } => {
row.status = ChargeStatus::Released;
drop(rows);
self.persist();
Ok(ChargeStatus::Released)
}
// A captured charge is money drawn. Releasing it would be a refund
// pretending to be a release, and the trait has `refund` for that.
ChargeStatus::Captured { .. } => Err(PayError::Declined(format!(
"sandbox: {} is captured; a drawn charge is refunded, never released",
handle.id
))),
}
}
pub fn charge_status(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Ok(self.row(handle)?.status)
}
pub fn parse_callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
if self.cfg.callback_secret.is_empty() {
return Err(PayError::Unverified("sandbox: no callback secret configured, so every callback is refused".into()));
}
let body: serde_json::Value = serde_json::from_slice(raw.body)
.map_err(|e| PayError::Unverified(format!("sandbox: callback body is not JSON: {e}")))?;
// **The signup arm, and it is first because it is a different event.** A
// real vendor sends one `checkout.session.completed` and the reader tells
// the two apart by what is on the session; here the body says which it
// is. It verifies with the SAME secret and the same constant-time
// compare — the whole value of this vendor is that the forged-callback
// law is not relaxed for the rehearsal.
if let Some(session) = body.get("signup").and_then(|v| v.as_str()) {
return Err(self.signup_callback(&raw, session));
}
let reference = body.get("reference").and_then(|v| v.as_str()).ok_or_else(|| PayError::Unverified("sandbox: callback has no reference".into()))?;
let handle_id = body.get("handle").and_then(|v| v.as_str()).ok_or_else(|| PayError::Unverified("sandbox: callback has no handle".into()))?;
let offered = raw
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(SIGNATURE_HEADER))
.map(|(_, v)| v.as_str())
.ok_or_else(|| PayError::Unverified(format!("sandbox: callback has no {SIGNATURE_HEADER}")))?;
let expected = Self::signature(&self.cfg.callback_secret, reference, handle_id);
if !crate::ct_eq(expected.as_bytes(), offered.as_bytes()) {
return Err(PayError::Unverified("sandbox: callback signature does not verify".into()));
}
// Verified. Now it may be believed — and only now.
let handle = ChargeHandle { vendor: VENDOR.into(), id: handle_id.to_owned() };
let row = self.row(&handle)?;
if row.reference != reference {
return Err(PayError::Unverified(format!(
"sandbox: callback says {reference} but {} belongs to {}",
handle_id, row.reference
)));
}
Ok(CallbackEvent { reference: reference.to_owned(), handle, status: row.status })
}
/// **A checkout page that cannot exist, at a hostname that cannot resolve.**
///
/// The sandbox rehearses the WHOLE money path against a real product, and
/// "the console offers the operator a link to send the customer to" is part
/// of that path — a rehearsal where that step is blank does not rehearse it.
/// So it answers, with a per-order URL of the same shape a real vendor's has.
///
/// The host is under `.invalid`, which RFC 2606 reserves precisely so that it
/// can never be delegated: DNS is guaranteed to fail. That is the property
/// that makes this safe. A sandbox that returned a plausible-looking URL on a
/// real domain could one day send a real customer somewhere real, and the
/// whole point of this vendor is that it has no socket and can reach nothing.
/// The link is a rehearsal prop and it fails loudly if anyone follows it,
/// which is the correct behaviour for play money.
pub fn redirect(&self, handle: &ChargeHandle) -> Option<String> {
if handle.vendor != VENDOR {
return None;
}
// Only for a charge this provider actually minted: an unknown handle has
// no checkout page here any more than it does at a real vendor.
self.row(handle).ok()?;
Some(format!("https://checkout.sandbox.invalid/{}", handle.id))
}
/// **`None`, though the rehearsal vendor demonstrably keeps nothing.**
/// A zero row on every rehearsal sale would be noise in the one ledger an
/// operator reads while learning to read ledgers. The fee path is rehearsed
/// against a real vendor instead (`monetize-pay-impl`'s sandbox examples).
pub fn settled(&self, _handle: &ChargeHandle) -> Option<crate::Settlement> {
None
}
/// **The rehearsal signup checkout: a page with no card on it.**
///
/// The whole point of this vendor is that the open build can drive the whole
/// path with no merchant agreement, and "pick a name, pay, come back" is now
/// part of that path. So it answers, and the URL it answers with is the
/// caller's own success URL with the session substituted — the browser lands
/// straight on the return page, because there is no card to type and
/// pretending otherwise would be a page that cannot exist.
///
/// **Nothing is paid yet.** The row it records is what the customer WOULD
/// have bought; the money moves when a signed callback arrives
/// (`{"signup": "<session>"}`), exactly as a real vendor's webhook is what
/// moves it. A sandbox that created the account on `start_signup` would
/// rehearse a flow where paying is optional.
pub fn start_signup(&self, order: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
if order.amount.currency != order.market.currency() {
return Err(PayError::Declined(format!(
"sandbox: {:?} is not {:?}'s currency",
order.amount.currency, order.market
)));
}
let session = {
let mut held = self.signups.lock().unwrap_or_else(|e| e.into_inner());
let mut candidate = format!("sbxcs_{}", (self.clock)());
let mut n = 0u32;
while held.contains_key(&candidate) {
n += 1;
candidate = format!("sbxcs_{}_{n}", (self.clock)());
}
held.insert(
candidate.clone(),
PaidSignup {
vendor: VENDOR.into(),
session: candidate.clone(),
reference: format!("sbxpi_{candidate}"),
// The address the buyer typed before the checkout, carried
// the way a real vendor carries it back. Never invented: an
// order with none rehearses a signup with none.
email: order.email.clone(),
username: order.username.clone(),
product: order.product.clone(),
plan: order.plan.clone(),
amount: order.amount,
// No tax registration is rehearsed here, so the gross IS the
// net and `monetize_server::signup::refusal` compares the
// plan price against exactly what it was asked for.
tax: Money { minor: 0, currency: order.amount.currency },
paid_at_unix_ms: (self.clock)() as i64,
customer_country: None,
},
);
candidate
};
self.persist();
Ok(Some(SignupCheckout {
url: order.success_url.replace(SIGNUP_SESSION_TOKEN, &session),
session,
}))
}
pub fn give_back(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
let row = self.row(handle)?;
match row.status {
ChargeStatus::Captured { .. } if amount.minor <= row.amount.minor && amount.currency == row.amount.currency => Ok(row.status),
ChargeStatus::Captured { .. } => Err(PayError::Declined(format!(
"sandbox: refund of {} {:?} against a charge of {} {:?}",
amount.minor, amount.currency, row.amount.minor, row.amount.currency
))),
_ => Err(PayError::Declined(format!("sandbox: {} is not captured; there is nothing to refund", handle.id))),
}
}
}
#[allow(unused_variables)]
impl crate::payment::Payment for SandboxProvider {
fn manifest(&self) -> crate::payment::PayManifest {
crate::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
}
fn page(&self, charge: &crate::payment::Charge<'_>, back: &crate::payment::Return) -> Result<crate::payment::Page, crate::PayError> {
Ok(match charge {
crate::payment::Charge::Order(req) => crate::payment::Page::Order { handle: self.reserve(req)?, url: None },
crate::payment::Charge::Signup(o) => crate::payment::Page::Signup(self.start_signup(o)?),
crate::payment::Charge::CardCheck(o) => crate::payment::Page::CardCheck(None),
crate::payment::Charge::TestSignup(o) => crate::payment::Page::TestSignup(None),
})
}
fn status(&self, of: &crate::payment::Of<'_>) -> Result<crate::payment::Standing, crate::PayError> {
Ok(match of {
crate::payment::Of::Charge(h) => crate::payment::Standing::Charge(self.charge_status(h)?),
crate::payment::Of::Page(h) => crate::payment::Standing::Page(self.redirect(h)),
crate::payment::Of::Signup { session } => crate::payment::Standing::Signup(None),
crate::payment::Of::CardCheck { session } => crate::payment::Standing::CardCheck(None),
})
}
fn capture(&self, h: &crate::ChargeHandle) -> Result<crate::ChargeStatus, crate::PayError> {
self.draw(h)
}
fn release(&self, h: &crate::ChargeHandle) -> Result<crate::ChargeStatus, crate::PayError> {
self.unlock(h)
}
fn refund(&self, h: &crate::ChargeHandle, amount: crate::Money) -> Result<crate::ChargeStatus, crate::PayError> {
self.give_back(h, amount)
}
fn callback(&self, raw: crate::RawCallback<'_>) -> Result<crate::CallbackEvent, crate::PayError> {
self.parse_callback(raw)
}
fn settlement(&self, h: &crate::ChargeHandle) -> Option<crate::Settlement> {
self.settled(h)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<crate::reconcile::PaidAtVendor>, crate::PayError> {
Err(crate::reconcile::cannot_be_asked())
}
fn livemode(&self) -> Option<bool> {
None
}
}
#[cfg(test)]
mod tests;