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