crates/monetize-server/src/reversal.rs
//! **Money that goes back after a sale** — the vendor's refund and dispute
//! events (g-disputes) and the operator's own refund of an ORDER (g-refund),
//! owner 2026-09-28: "a dispute webhook → handled", "a refund verb,
//! operator-only, idempotent, written in the ledger".
//!
//! # What "handled" means here
//!
//! * **written down, once**: a `Step::Reversed` row on the order's own
//! reference (a signup's on `signup:<vendor>:<session>`), whatever number of
//! times the vendor delivers it — [`monetize::Ledger::append`] drops the
//! repeat;
//! * **credited back**: the tax view and the signup fold keep the sale as
//! charged in its own period and book what went back as a correction in
//! the period it went back in ([`monetize::tax::Books::view`],
//! [`crate::signup::tax_sales`]; stinky row 17);
//! * **said out loud**: a RED `monetize-server/reversal` status row and a
//! `REVERSAL` line on the log door, naming the tenant and what to do.
//!
//! * **the paid time taken back** when nothing of the sale is kept — all of
//! it refunded (the vendor's refund or the operator's), or a dispute LOST:
//! the tenant's fact is paid only up to now (never before the time this
//! sale started), signed and pushed, so the ladder moves it — Grace, then
//! Suspended — exactly as for a renewal nobody paid ([`take_back`]). The
//! room itself is the ladder's to move, never cut at once.
//!
//! A PARTIAL refund is goodwill and leaves the grant as it is; an opened
//! dispute can still be won. Both are said in the status row, and anything
//! more is the operator's act (`Entitlement.Set`). An Add-users order's
//! refund takes back the room it raised instead (`purchase::refund_users`).
use monetize::ledger::Step;
use monetize_pay::{ChargeHandle, Money, PayError, Reversal, ReversalKind};
use monetize_product::EntitlementFact;
use crate::signup::{RefundRefusal, SignupRefund};
use crate::state::AppState;
/// Where a reversal was written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recorded {
/// The ledger reference the row is on.
pub reference: String,
/// The row's seq (the existing one when this was a repeat).
pub seq: u32,
}
/// The reference a signup's reversal is written under: the same one the tax
/// fold names the sale by.
pub fn signup_reference(vendor: &str, session: &str) -> String {
format!("signup:{vendor}:{session}")
}
/// **Record one verified reversal.** `Err` names why it is not ours (a charge
/// this deployment holds neither as an order nor as a signup) — answered 200
/// ignored by the webhook, like any verified event about somebody else.
pub fn record(state: &AppState, r: &Reversal) -> Result<Recorded, String> {
let now = state.now();
// An ORDER: the reference the vendor carried, if the ledger holds it under
// this very handle. Never trusted on its own: a reference whose attempt was
// reserved under another handle is not this charge.
let order = if r.reference.is_empty() {
None
} else {
match state.ledger.attempt(&r.reference) {
Ok(Some(a)) if a.handle == r.handle => Some(r.reference.clone()),
Ok(_) => None,
Err(e) => return Err(format!("ledger: {e}")),
}
};
// ★ F-S-01: a private estate's sale (its order, a renewal, a plan change) — the estate's
// run is found by the charge, and takes the reversal itself.
if order.is_none() {
if let Some(done) = crate::estate_order::reversed::record(state, r)? {
return Ok(done);
}
}
let signup = match &order {
Some(_) => None,
None => state.signups.get(&r.handle.vendor, &r.handle.id).map_err(|e| format!("signup store: {e}"))?,
};
let reference = match (&order, &signup) {
(Some(reference), _) => reference.clone(),
(None, Some(_)) => signup_reference(&r.handle.vendor, &r.handle.id),
(None, None) => return Err(format!("{}:{} is neither an order, an estate's sale nor a signup this deployment holds", r.handle.vendor, r.handle.id)),
};
let seq = state
.ledger
.append(
now,
Step::Reversed { reference: reference.clone(), kind: r.kind, id: r.id.clone(), amount: r.amount, reason: r.reason.clone(), by: "vendor".into() },
)
.map_err(|e| format!("ledger: {e}"))?;
// A signup's money that went back is also its refund row, so the signup's
// owed-back queue and its tax fold agree with the ledger.
if let (Some(rec), true) = (&signup, r.kind.money_went_back()) {
let held = state.signups.refund_of(&r.handle.vendor, &r.handle.id).map_err(|e| format!("signup store: {e}"))?;
let covered = matches!(&held, Some(SignupRefund::Done { amount, .. }) if amount.minor >= r.amount.minor);
if !covered {
let done = SignupRefund::Done {
refund_id: r.id.clone(),
amount: Money { minor: r.amount.minor.min(rec.signup.amount.minor), currency: rec.signup.amount.currency },
reason: format!("{} at the vendor: {}", r.kind.name(), r.reason),
requested_at_unix_ms: now as i64,
at_unix_ms: now as i64,
forced: true,
};
state.signups.put_refund(&r.handle.vendor, &r.handle.id, &done).map_err(|e| format!("signup store: {e}"))?;
}
}
let taken = match (&order, &signup) {
(Some(reference), _) => take_back_order(state, reference)?,
(None, Some(rec)) => {
let all_back = r.kind == ReversalKind::DisputeLost
|| matches!(state.signups.refund_of(&r.handle.vendor, &r.handle.id), Ok(Some(SignupRefund::Done { amount, .. })) if amount.minor >= rec.signup.amount.minor);
match (&rec.state, all_back) {
(crate::signup::SignupState::Created { namespace, .. }, true) => {
take_back(state, &reference, &rec.signup.product, namespace, u64::try_from(rec.recorded_at_unix_ms).unwrap_or_default())?
}
_ => None,
}
}
(None, None) => None,
};
let grant = match &taken {
Some(f) => format!(
"nothing of the sale is kept, so its paid time is TAKEN BACK: {} is paid until {} and now {:?}",
f.tenant.0,
f.paid_until_unix_ms.unwrap_or_default(),
f.state
),
None => "the tenant's grant is UNCHANGED — take it back by hand (Entitlement.Set) if the customer should not keep the room".to_owned(),
};
let why = format!("REVERSAL {} {} of {} on {reference}: {}. What went back is credited in this period's tax; {grant}", r.kind.name(), r.id, r.amount.in_words(), r.reason);
// Green only for a dispute the merchant WON; everything else is a thing
// an operator should look at.
skidbladnir::functional_status("monetize-server/reversal", &reference, r.kind == ReversalKind::DisputeWon, &why);
eprintln!("monetize-server: {why}");
Ok(Recorded { reference, seq })
}
/// **An order's paid time, taken back when nothing of it is kept**: refunds
/// and lost disputes reach its gross, or a dispute was lost at all (a
/// chargeback is the customer saying they did not pay). An Add-users order
/// bought room, not time, and is left to `purchase::refund_users`.
pub fn take_back_order(state: &AppState, reference: &str) -> Result<Option<EntitlementFact>, String> {
let ledger = |e: monetize::ledger::LedgerError| format!("ledger: {e}");
let Some(attempt) = state.ledger.attempt(reference).map_err(ledger)? else { return Ok(None) };
if attempt.purchase.users.is_some() {
return Ok(None);
}
let rows = state.ledger.rows(reference).map_err(ledger)?;
let lost = rows.iter().any(|row| matches!(row.step, Step::Reversed { kind: ReversalKind::DisputeLost, .. }));
let back = monetize::ledger::Sale::fold(reference, 0, &rows).map_or(0, |s| s.back_minor());
if !lost && back < gross_of(&attempt) {
return Ok(None);
}
take_back(state, reference, &attempt.purchase.product, &attempt.purchase.tenant.0, attempt.purchase.starts_unix_ms)
}
/// **The tenant's fact, paid only up to now** — never before `from` (the
/// start of what the reversed sale bought, so a later period someone paid
/// for is kept), never later than it already was. The ladder's state for
/// that date, signed and pushed (a failed push is pending and retried like
/// any other). `None` when there was nothing to take: no fact, a fact with no
/// end, or one already paid no further — which makes a repeat a no-op.
pub fn take_back(state: &AppState, reference: &str, product: &str, tenant: &str, from: u64) -> Result<Option<EntitlementFact>, String> {
let ledger = |e: monetize::ledger::LedgerError| format!("ledger: {e}");
let Some(held) = state.ledger.entitlement(product, tenant).map_err(ledger)? else { return Ok(None) };
let Some(paid_until) = held.paid_until_unix_ms else { return Ok(None) };
let now = state.now();
let until = paid_until.min(now.max(from));
if until >= paid_until {
return Ok(None);
}
let Some(p) = state.registry.find_product(product) else {
return Err(format!("{product} is not linked into this server, so {tenant}'s paid time cannot be taken back for {reference}"));
};
let policy = state.terms.read().unwrap_or_else(|e| e.into_inner()).policy;
let fact = state.signer_for(product).sign_fact(EntitlementFact {
state: monetize::derive_state(&policy, Some(until), now),
paid_until_unix_ms: Some(until),
source: format!("reversal:{reference}"),
signature: Vec::new(),
..held
});
if let Err(e) = state.apply_fact(p, &fact).map_err(ledger)? {
eprintln!("monetize-server: {reference}: {tenant}'s paid time is taken back on the ledger and the push is pending ({e}); retried");
}
Ok(Some(fact))
}
/// What the customer paid for an order, tax included.
fn gross_of(attempt: &monetize::ledger::Attempt) -> u64 {
let tax = attempt.settlement.as_ref().and_then(|s| s.tax).map(|t| t.minor).unwrap_or_else(|| monetize::tax::tax_of(attempt.purchase.price, attempt.purchase.vat_basis_points_or_market()).minor);
attempt.purchase.price.minor.saturating_add(tax)
}
/// Find the ORDER a vendor handle belongs to: the newest reference whose
/// `Reserved` row names `(vendor, session)`. A full scan — an operator verb,
/// asked by hand, never on a request path.
pub fn order_of(state: &AppState, vendor: &str, session: &str) -> Result<Option<String>, String> {
let mut found = None;
for (reference, row) in state.ledger.export_rows().map_err(|e| format!("ledger: {e}"))? {
if let Step::Reserved { handle, .. } = &row.step {
if handle.vendor == vendor && handle.id == session {
found = Some(reference);
}
}
}
Ok(found)
}
/// **Give an ORDER's money back** — `SignupAdmin.Refund` when the session
/// names no signup (g-refund). Operator-only at the door; idempotent:
///
/// * `RefundRequested{cumulative, this_refund}` is written BEFORE the vendor is called;
/// * the vendor is asked under `<session>:refund:<amount>` (Stripe's key);
/// * `Reversed{Refunded, cumulative}` is written after its yes;
/// * a call that finds a request with no answer re-sends the SAME amount
/// (same key — one refund); one that finds the operator's refund done
/// answers it and calls nobody.
///
/// `amount_minor` 0 = everything not yet given back (gross, tax included).
///
/// A refund that leaves nothing of the sale kept takes its paid time back
/// ([`take_back_order`]); a call that finds the refund done finishes a
/// take-back an earlier one did not get to.
pub fn refund_order(state: &AppState, vendor: &str, session: &str, amount_minor: u64, reason: &str) -> Result<SignupRefund, RefundRefusal> {
let done = refund_order_by(state, vendor, session, amount_minor, reason, "operator")?;
if let Some(reference) = order_of(state, vendor, session).map_err(RefundRefusal::Unavailable)? {
take_back_order(state, &reference).map_err(RefundRefusal::Unavailable)?;
}
Ok(done)
}
/// [`refund_order`], journaled as `by` — `monetize` for the refund this server
/// makes ITSELF (an Add-users grant refused for good after the capture).
///
/// Held under the order's own lock from the first read to the last append, so
/// two refunds of one order are one after the other and the second sees the
/// first. What is left to give back counts refunds AND a lost dispute
/// ([`monetize::ledger::Sale::back_minor`]).
pub fn refund_order_by(state: &AppState, vendor: &str, session: &str, amount_minor: u64, reason: &str, by: &str) -> Result<SignupRefund, RefundRefusal> {
let reason = reason.trim();
if reason.is_empty() {
return Err(RefundRefusal::Invalid("a refund names its reason (the operator's words, recorded with it)".into()));
}
let Some(reference) = order_of(state, vendor, session).map_err(RefundRefusal::Unavailable)? else {
return Err(RefundRefusal::Unknown);
};
let _order = OrderLock::take(&reference);
let ledger = |e: monetize::ledger::LedgerError| RefundRefusal::Unavailable(format!("ledger: {e}"));
let attempt = state.ledger.attempt(&reference).map_err(ledger)?.ok_or(RefundRefusal::Unknown)?;
if !matches!(attempt.captured, Some(monetize_pay::ChargeStatus::Captured { .. })) {
return Err(RefundRefusal::NotRefundable(format!("{reference} ({vendor}:{session}) drew no money yet; release the order instead")));
}
let pay = state
.registry
.find_vendor(vendor)
.ok_or_else(|| RefundRefusal::NotRefundable(format!("{reference} was paid through {vendor:?}, which is not linked into this server")))?;
let currency = attempt.purchase.price.currency;
let gross = gross_of(&attempt);
// What the rows already say.
let rows = state.ledger.rows(&reference).map_err(ledger)?;
let (refunded, back) = monetize::ledger::Sale::fold(&reference, 0, &rows).map_or((0, 0), |s| (s.refunded_minor, s.back_minor()));
struct Asked {
cumulative: u64,
this: u64,
reason: String,
at: u64,
}
/// The refund already answered under `by`: its id, the amount, and when
/// it was asked and answered.
struct Ours {
id: String,
this: u64,
at_req: u64,
at: u64,
}
let (mut requested, mut ours): (Option<Asked>, Option<Ours>) = (None, None);
for row in rows {
match row.step {
Step::RefundRequested { amount, this_refund, reason, .. } => {
requested = Some(Asked { cumulative: amount.minor, this: this_refund.minor, reason, at: row.at_unix_ms });
}
Step::Reversed { kind: ReversalKind::Refunded, id, by: row_by, .. } if row_by == by => {
let (this, at_req) = requested.as_ref().map_or((0, row.at_unix_ms), |a| (a.this, a.at));
ours = Some(Ours { id, this, at_req, at: row.at_unix_ms });
}
_ => {}
}
}
let done = |id: String, this: u64, why: String, at_req: u64, at: u64| SignupRefund::Done {
refund_id: id,
amount: Money { minor: this, currency },
reason: why,
requested_at_unix_ms: at_req as i64,
at_unix_ms: at as i64,
forced: false,
};
// A request with no answer: the retry, under the recorded amount.
let (cumulative, this, why, requested_at) = match requested {
Some(a) if a.cumulative > refunded => (a.cumulative, a.this, a.reason, a.at),
_ => {
if let Some(Ours { id, this, at_req, at }) = ours {
if amount_minor == 0 || amount_minor == this {
return Ok(done(id, this, reason.to_owned(), at_req, at));
}
return Err(RefundRefusal::NotRefundable(format!(
"{reference} was already refunded {} by an operator; one refund per order through this verb (ask for 0 or {this} to read it back)",
Money { minor: this, currency }.in_words()
)));
}
let left = gross.saturating_sub(back);
let this = if amount_minor == 0 { left } else { amount_minor };
if this == 0 {
return Err(RefundRefusal::NotRefundable(format!("{reference}: all {} was already given back", Money { minor: gross, currency }.in_words())));
}
if this > left {
return Err(RefundRefusal::Invalid(format!(
"{reference}: {} is more than is left to give back ({} of {} paid)",
Money { minor: this, currency }.in_words(),
Money { minor: left, currency }.in_words(),
Money { minor: gross, currency }.in_words()
)));
}
let cumulative = refunded + this;
let at = state.now();
state
.ledger
.append(
at,
Step::RefundRequested {
reference: reference.clone(),
amount: Money { minor: cumulative, currency },
this_refund: Money { minor: this, currency },
reason: reason.to_owned(),
by: by.into(),
},
)
.map_err(ledger)?;
(cumulative, this, reason.to_owned(), at)
}
};
let handle = ChargeHandle { vendor: vendor.to_owned(), id: session.to_owned() };
match pay.refund(&handle, Money { minor: this, currency }) {
Ok(_) => {}
Err(PayError::Unavailable(e)) => return Err(RefundRefusal::Unavailable(format!("{vendor} did not answer the refund (it is REQUESTED; the same call again finishes it): {e}"))),
Err(e) => return Err(RefundRefusal::NotRefundable(format!("{vendor} refused the refund of {reference}: {e}"))),
}
let id = crate::signup::refund_key(session, this);
let at = state.now();
state
.ledger
.append(at, Step::Reversed { reference: reference.clone(), kind: ReversalKind::Refunded, id: id.clone(), amount: Money { minor: cumulative, currency }, reason: why.clone(), by: by.into() })
.map_err(ledger)?;
eprintln!(
"monetize-server: REFUND {reference} ({vendor}:{session}): {} given back by {by} ({why}); {} refunded of {} paid",
Money { minor: this, currency }.in_words(),
Money { minor: cumulative, currency }.in_words(),
Money { minor: gross, currency }.in_words()
);
Ok(done(id, this, why, requested_at, at))
}
/// **One refund of an order at a time**: the references being refunded right
/// now. Taken before the rows are read and dropped after the last append.
static REFUNDING: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
static REFUND_FREED: std::sync::Condvar = std::sync::Condvar::new();
pub(crate) struct OrderLock(String);
impl OrderLock {
pub(crate) fn take(reference: &str) -> OrderLock {
let mut held = REFUNDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while held.contains(reference) {
held = REFUND_FREED.wait(held).unwrap_or_else(std::sync::PoisonError::into_inner);
}
held.insert(reference.to_owned());
OrderLock(reference.to_owned())
}
}
impl Drop for OrderLock {
fn drop(&mut self) {
REFUNDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&self.0);
REFUND_FREED.notify_all();
}
}