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