Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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