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/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;