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/estate_order/reversed.rs

//! ★ **Money that goes back on a private estate's sale** (F-S-01, A17 2026-10-23). The vendor's
//! refund or dispute (Stripe's `charge.refunded`, `charge.dispute.*`) on an estate's ORDER
//! (`estate/<company>`), one of its RENEWALS (`…/renewal-<n>`) or one of its PLAN CHANGES
//! (`…/change-<n>`) is found by the charge it names — never by the reference alone — and:
//!
//! * **written down, once**: a `Step::Reversed` row on the sale's reference (the ledger drops a
//!   repeat), and an [`EstateReversal`] on the run plus a line in its history;
//! * **said out loud**: an `ALERT estate-orders.reversal` line and a red
//!   `monetize-server/reversal` status row (green only for a dispute the merchant WON);
//! * **the paid time taken back** when nothing of an order or a renewal is kept — all of it
//!   refunded, or a dispute lost: the run is paid only up to now (never before the time that sale
//!   bought began), and the term's own nonpay path moves it — grace, then stopped with every disk
//!   kept ([`super::term`]). It is never DELETED by it ([`hold_deletion`]): the operator renews it
//!   for the buyer or terminates it by hand. A plan change bought room, not time: it is said, and
//!   the room is the operator's to take back.
//!
//! A partial refund is goodwill and an opened dispute can still be won: both are said, and the
//! paid period is left as it is.

use monetize::ledger::Step;
use monetize_pay::{ChargeHandle, Money, Reversal, ReversalKind};

use super::{iso_date, phase, EstateRun, Next, Transition};
use crate::reversal::Recorded;
use crate::state::AppState;

/// One reversal on the run, as the vendor said it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EstateReversal {
    /// The sale it is about: the order's, a renewal's or a plan change's reference.
    pub reference: String,
    pub kind: ReversalKind,
    /// The vendor's id for it (`re_…`, `dp_…`).
    pub id: String,
    /// A refund: the charge's refunded TOTAL so far; a dispute: its amount.
    pub amount: Money,
    pub at_ms: u64,
    /// It took the paid time back.
    pub taken_back: bool,
}

/// Which of the run's sales a charge is.
enum Sale {
    Order,
    Renewal { amount: Money, from_ms: u64 },
    Change { amount: Money },
}

fn sale_of(run: &EstateRun, handle: &ChargeHandle) -> Option<(String, Sale)> {
    if run.handle.as_ref() == Some(handle) {
        return Some((run.reference.clone(), Sale::Order));
    }
    if let Some(r) = run
        .renewals
        .iter()
        .find(|r| r.handle.as_ref() == Some(handle))
    {
        return Some((
            r.reference.clone(),
            Sale::Renewal {
                amount: r.amount,
                from_ms: if r.from_ms > 0 {
                    r.from_ms
                } else {
                    r.captured_at_ms
                },
            },
        ));
    }
    run.change
        .iter()
        .chain(run.changes.iter())
        .find(|c| c.handle.as_ref() == Some(handle))
        .map(|c| (c.reference.clone(), Sale::Change { amount: c.amount }))
}

/// **Record one verified reversal on the estate whose sale it is.** `Ok(None)`: no estate here
/// holds that charge (the caller looks further). `Err` starting `ledger:` or `estate store:` is
/// ours — answered 500, so the vendor delivers it again.
pub fn record(state: &AppState, r: &Reversal) -> Result<Option<Recorded>, String> {
    let eo = &state.estates;
    let Some(cfg) = eo.cfg.as_ref() else {
        return Ok(None);
    };
    // The reference narrows it to one company; the charge decides.
    let named = r
        .reference
        .strip_prefix("estate/")
        .and_then(|rest| rest.split('/').next())
        .filter(|c| !c.is_empty())
        .map(str::to_owned);
    let found = named
        .and_then(|c| eo.get(&c))
        .filter(|run| sale_of(run, &r.handle).is_some())
        .or_else(|| {
            eo.all()
                .into_iter()
                .find(|run| sale_of(run, &r.handle).is_some())
        });
    let Some(found) = found else { return Ok(None) };
    let company = found.company.clone();
    // The run's pen, from the read to the write (the driver writes nothing between them).
    let _pen = eo
        .pen_for_a_call(cfg, &company)
        .map_err(|e| format!("estate store: {e}"))?;
    let mut run = eo
        .get(&company)
        .filter(|run| run.ordered_at_ms == found.ordered_at_ms)
        .ok_or_else(|| {
            format!(
                "estate store: the run of {company} changed under the reversal; delivered again"
            )
        })?;
    let Some((reference, sale)) = sale_of(&run, &r.handle) else {
        return Err(format!(
            "estate store: the run of {company} changed under the reversal; delivered again"
        ));
    };
    let now = state.now();
    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}"))?;
    // Once on the run: the same id, or a refund's same total under another id (the echo).
    let repeat = run.reversals.iter().any(|h| {
        h.reference == reference
            && h.kind == r.kind
            && (h.id == r.id
                || (r.kind == ReversalKind::Refunded && h.amount.minor == r.amount.minor))
    });
    if repeat {
        return Ok(Some(Recorded { reference, seq }));
    }
    let all_back = r.kind == ReversalKind::DisputeLost
        || (r.kind == ReversalKind::Refunded
            && r.amount.minor
                >= match &sale {
                    Sale::Order => run.amount.minor,
                    Sale::Renewal { amount, .. } | Sale::Change { amount } => amount.minor,
                });
    let from = match &sale {
        Sale::Order => Some(run.captured_at_ms),
        Sale::Renewal { from_ms, .. } => Some(*from_ms),
        Sale::Change { .. } => None,
    };
    let mut taken_back = false;
    let grant = match (all_back, from) {
        (true, Some(from)) if phase::is_term(&run.phase) => {
            let until = run.paid_until_ms.min(now.max(from));
            if until < run.paid_until_ms {
                let was = run.paid_until_ms;
                run.paid_until_ms = until;
                run.reversed_at_ms = now;
                taken_back = true;
                format!(
                    "nothing of the sale is kept, so its paid time is TAKEN BACK: paid until {} (was {}); it goes the nonpay way — grace, then stopped with every disk kept — and is never deleted by it: renew it for the buyer or terminate it by hand",
                    iso_date(until),
                    iso_date(was)
                )
            } else {
                "nothing of the sale is kept; the time it bought is already over".to_owned()
            }
        }
        (true, Some(_)) => format!("nothing of the sale is kept; the estate is {}, so no paid time was taken back — look at it by hand", run.phase),
        (true, None) => "nothing of the plan change is kept; the estate keeps the room it bought — resize it back by hand if it should not".to_owned(),
        (false, _) if r.kind == ReversalKind::DisputeOpened => "a dispute is OPEN: the paid period is unchanged until it is decided — answer it at the vendor".to_owned(),
        (false, _) if r.kind == ReversalKind::DisputeWon => "the dispute is WON: nothing changes".to_owned(),
        (false, _) => "a PARTIAL refund: the paid period is unchanged".to_owned(),
    };
    let why = format!(
        "REVERSAL {} {} of {} on {reference}: {}. {grant}",
        r.kind.name(),
        r.id,
        r.amount.in_words(),
        r.reason
    );
    run.reversals.push(EstateReversal {
        reference: reference.clone(),
        kind: r.kind,
        id: r.id.clone(),
        amount: r.amount,
        at_ms: now,
        taken_back,
    });
    run.history.push(Transition {
        phase: String::new(),
        at_ms: now,
        note: format!("ALERT: {why}"),
    });
    super::save(state, &run).map_err(|e| format!("estate store: {e}"))?;
    // Green only for a dispute the merchant WON; everything else is the operator's to look at.
    skidbladnir::functional_status(
        "monetize-server/reversal",
        &reference,
        r.kind == ReversalKind::DisputeWon,
        &why,
    );
    eprintln!(
        "monetize-server: ALERT estate-orders.reversal {}: {why}",
        run.reference
    );
    Ok(Some(Recorded { reference, seq }))
}

/// **A reversed estate past its notice is NOT deleted** (F-S-01): it stays stopped with every
/// disk kept, and the operator is told — once a day — to renew it for the buyer or terminate it.
pub(super) fn hold_deletion(run: &mut EstateRun, now: u64) -> Next {
    const DAY_MS: u64 = 86_400_000;
    let last = run
        .history
        .iter()
        .rev()
        .find(|t| t.note.starts_with(HELD))
        .map_or(0, |t| t.at_ms);
    if last == 0 || now >= last.saturating_add(DAY_MS) {
        eprintln!(
            "monetize-server: ALERT estate-orders.reversal {}: {HELD}",
            run.reference
        );
        run.history.push(Transition {
            phase: String::new(),
            at_ms: now,
            note: HELD.to_owned(),
        });
    }
    Next::Park(format!("stopped, data kept: {HELD}"))
}

const HELD: &str = "NOT deleted: its money went back at the vendor (a refund or a lost dispute); the operator decides — renew it for the buyer, or terminate it by hand";