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

//! **Asking the vendor what it actually took** — the seam behind vendor
//! reconciliation.
//!
//! Every other way money reaches this deployment's books starts with the
//! vendor telling us: a webhook (`Payment::callback`) or a
//! `status` read about a handle the ledger already holds. Both presuppose
//! that monetize is in a position to listen. MEASURED 2026-09-14 on the front:
//! it was not. An Apple Pay checkout (`cs_live_a1MHA6…`, 11:10 UTC, 125.00 SEK)
//! was `complete` and `paid` at Stripe, the charge `succeeded, captured` — and
//! every webhook for it was refused, because the box's clock ran two hours
//! fast and the signed `t=` fell outside the tolerance. monetize recorded
//! nothing. The customer read "This session is not known to us". Hours later
//! Stripe's retry landed and the signup appeared; between the two, the only
//! trace anywhere was a journald line.
//!
//! The refusal ring (`monetize_server::refusals`) makes the REFUSAL visible.
//! It cannot make the PAYMENT visible, because a refused delivery is by
//! definition one we did not believe — nothing in it can be read back as fact.
//! What CAN be read back as fact is the vendor's own ledger, over the key this
//! deployment already holds: not a POST from the internet, but an answer to a
//! question we asked, authenticated by us. That is this method.
//!
//! ```text
//!   webhook     vendor ──POST──▶ monetize      (refused when we cannot listen)
//!   reconcile   monetize ──GET──▶ vendor       (asked when we can, about what we missed)
//! ```
//!
//! # The contract
//!
//! [`Payment::paid_since`](crate::Payment::paid_since) answers
//! **every payment the vendor took for this account since `since`, that is
//! FINAL** — the money moved, the vendor says so, nothing is pending. A
//! reservation that is only authorised, a session a customer has not finished,
//! an asynchronous rail still clearing: none of those is in the list, because
//! the caller creates things from it and must never create anything from a
//! payment that did not happen.
//!
//! It is a **fact list, not a diff**. The same payment is answered again on
//! every call whose window covers it; the caller (`monetize_server::reconcile`)
//! is keyed on `(vendor, session)` and is what makes a repeated answer a no-op.
//! A vendor impl must not try to remember what it has already answered — a
//! process that died between "answered" and "recorded" would then lose the
//! payment for good, which is the failure this exists to end.
//!
//! **The default implementation answers [`CANNOT_BE_ASKED`]**, as
//! [`crate::PayError::Unavailable`]. That is a real answer and not a stub:
//! `invoice` has no vendor to ask (the bank statement is the reconciliation),
//! `sandbox` holds no money, and a private vendor whose API has no such read
//! says so by leaving the method alone. The caller treats exactly that message
//! as "this vendor does not reconcile" and every OTHER `Unavailable` as an
//! outage worth a log line — so an impl that CAN be asked must not reuse the
//! sentence for a network failure.

use std::collections::BTreeMap;

/// **The default answer**: this vendor has no way to be asked, and the caller
/// must not read that as an outage. Compared by value in
/// `monetize_server::reconcile`; one spelling, here.
pub const CANNOT_BE_ASKED: &str = "this vendor cannot be asked what it took";

/// The [`crate::PayError`] the default `paid_since` answers with.
pub fn cannot_be_asked() -> crate::PayError {
    crate::PayError::Unavailable(CANNOT_BE_ASKED.to_owned())
}

/// **One payment the vendor says it took**, in the vendor's own words.
///
/// It is deliberately NOT a [`crate::PaidSignup`] and not a
/// [`crate::CallbackEvent`]: the vendor does not know which of the two a
/// payment was, or whether it was either. It hands up what it holds — the
/// session, the money, the labels it was asked to carry — and the server
/// decides, against its own stores, what this payment is to monetize. A
/// vendor impl that classified here would be a second copy of the server's
/// rules, one that drifts.
///
/// Nothing in it is validated on the way up, for the reason
/// [`crate::signup`]'s header gives: a field that arrived wrong is refused BY
/// NAME further up, where the refusal is stored and shown, not normalised at
/// the edge.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct PaidAtVendor {
    /// The vendor that answered — [`crate::Vendor::name`].
    pub vendor: String,
    /// **The vendor's handle for the checkout session** (`cs_…` at Stripe), and
    /// with `vendor` the key of the whole seam: it is the same id the webhook
    /// would have carried, so a payment found here and one that arrived by
    /// callback are one row, not two.
    pub session: String,
    /// The vendor's reference for the money itself (a payment intent, a charge
    /// id) — what an operator types into the vendor's dashboard to find it.
    pub reference: String,
    /// When the money moved, by the vendor's clock. Signed milliseconds, the
    /// unit [`crate::PaidSignup::paid_at_unix_ms`] uses, so the two can be
    /// compared without a conversion in between.
    pub paid_at_unix_ms: i64,
    /// **What the vendor took, tax included**, in the currency's minor units.
    /// The customer's statement figure, never the deployment's price for it.
    pub amount_minor: u64,
    /// The tax inside `amount_minor`, as the vendor calculated it. `0` when it
    /// collected none.
    pub tax_minor: u64,
    /// The ISO 4217 code AS THE VENDOR SPELLS IT (`sek`, `eur`, `usd`). A
    /// string and not [`crate::Currency`], because a payment in a currency
    /// monetize cannot express is still a payment the vendor took, and it
    /// must reach the operator's screen rather than be dropped at the edge
    /// for not fitting an enum. [`crate::Currency::from_code`] is the server's
    /// reading of it.
    pub currency: String,
    /// The vendor's own live/test flag for this payment. An impl answers only
    /// the mode its key is in; this is carried so the server can show it.
    pub livemode: bool,
    /// **The labels the vendor was asked to carry**, verbatim: `product`,
    /// `plan`, `username` for a signup; `reference` for a purchase. Exactly
    /// what the webhook's session would have carried — the server reads a
    /// signup or a purchase off these with the same code it reads a callback.
    pub metadata: BTreeMap<String, String>,
    /// The e-mail the vendor's page collected, if it did. Carried for ONE
    /// reader — the signup sink, which has to reach the customer — and listed
    /// by nothing: a console is not a mailing list.
    pub email: String,
    /// **The customer's billing country as the vendor collected it** (ISO
    /// 3166-1 alpha-2, upper case), carried into [`crate::PaidSignup::customer_country`]
    /// so a signup found here is taxed where the webhook's would be (stinky
    /// monetize-server #15). `None`: the vendor did not say.
    #[serde(default)]
    pub customer_country: Option<String>,
}

impl PaidAtVendor {
    /// `<vendor>:<session>`, the way every log line and every row names one.
    pub fn key(&self) -> String {
        format!("{}:{}", self.vendor, self.session)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ChargeHandle, ChargeRequest, ChargeStatus, Market, Money, PayError, RawCallback};

    /// A vendor that wrote every required method and never heard of
    /// reconciliation — every private impl, the day this seam landed.
    struct Unasked;

    #[allow(dead_code)]
    impl Unasked {
        pub fn vendor(&self) -> &'static str {
            "unasked"
        }
        pub fn markets(&self) -> &[Market] {
            &[Market::Se]
        }
        pub fn reserve(&self, _: &ChargeRequest) -> Result<ChargeHandle, PayError> {
            unreachable!()
        }
        pub fn draw(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            unreachable!()
        }
        pub fn unlock(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            unreachable!()
        }
        pub fn charge_status(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
            unreachable!()
        }
        pub fn parse_callback(&self, _: RawCallback<'_>) -> Result<crate::CallbackEvent, PayError> {
            unreachable!()
        }
        pub fn give_back(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
            unreachable!()
        }
        pub fn settled(&self, _: &ChargeHandle) -> Option<crate::Settlement> {
            None
        }
        pub fn redirect(&self, _: &ChargeHandle) -> Option<String> {
            None
        }
        pub fn start_signup(&self, _: &crate::SignupOrder) -> Result<Option<crate::SignupCheckout>, PayError> {
            Ok(None)
        }
    }
    #[allow(unused_variables)]
    impl crate::payment::Payment for Unasked {
        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
        }
    }

    /// (r) **The seam's default is a named "cannot be asked", not an empty
    /// list.** An empty `Ok(vec![])` from a vendor nobody wrote the method for
    /// would read as "nothing was paid", which is the silent loss this seam
    /// exists to end. RED if the default ever becomes `Ok(Vec::new())`.
    #[test]
    fn the_default_answers_unavailable_by_name_and_never_an_empty_list() {
        match crate::Payment::paid_since(&Unasked, 0) {
            Err(PayError::Unavailable(why)) => assert_eq!(why, CANNOT_BE_ASKED),
            other => panic!("the default must be Unavailable(CANNOT_BE_ASKED), got {other:?}"),
        }
        assert_eq!(cannot_be_asked().to_string(), format!("vendor unavailable: {CANNOT_BE_ASKED}"));
    }

    #[test]
    fn a_fact_is_named_by_vendor_and_session() {
        let f = PaidAtVendor {
            vendor: "stripe".into(),
            session: "cs_live_a1MHA6".into(),
            reference: "pi_1".into(),
            paid_at_unix_ms: 1,
            amount_minor: 12_500,
            tax_minor: 2_500,
            currency: "sek".into(),
            livemode: true,
            metadata: BTreeMap::new(),
            email: String::new(),
            customer_country: None,
        };
        assert_eq!(f.key(), "stripe:cs_live_a1MHA6");
    }
}