crates/monetize-server/src/estate_order/term.rs
//! **After the capture: the six-month term, its renewal, and a customer who
//! stops paying** (owner, 2026-10-04).
//!
//! The rule, as every mail states it:
//!
//! ```text
//! paid_until − 30/14/3 days ─▶ a reminder, each once ("renew at <url>"; a
//! skipped one is not sent late)
//! the customer renews ─▶ a new hold for six months at TODAY's provider
//! price + 20 %, captured as soon as it is
//! authorised; the six months run from the END
//! of the paid period (from the payment, if the
//! estate was already stopped)
//! paid_until ─▶ grace: the estate runs as before for 14 days;
//! the deletion date is FIXED and said in this
//! first mail (paid_until + 60 days, never less
//! than 60 days from the mail)
//! paid_until + 14 days ─▶ suspended: every server stopped, every disk
//! and all data kept
//! the deletion date ─▶ deleted: servers, disks, names and secrets
//! given back; the name is free
//! paid at any time before ─▶ running again (started, the deletion off)
//! ```
//!
//! **Nothing is ever deleted inside the paid period**: the policy is refused
//! if its deletion could fall inside the grace, and the deleting step itself
//! refuses while `paid_until` is in the future, whatever the run file says.
//!
//! Mails go through the deployment's one mail path (`SignupSink::notify`),
//! to the address the order was placed with, and each is written down once
//! (`EstateRun::reminded`) so a restart never sends one twice.
/// The history's one line of a bench box that keeps an estate's secrets after its hand-off.
pub const BENCH_KEPT: &str = "BENCH: the estate's secrets are kept after its hand-off";
use monetize_cloud::{CloudError, OwnedKind, ResourceId};
use monetize_pay::{ChargeHandle, ChargeRequest, ChargeStatus, Money};
use super::{iso_date, phase, price, EstateRun, Next, StartRefusal, Transition};
use crate::state::AppState;
/// A day, in milliseconds.
pub const DAY_MS: u64 = 86_400_000;
/// **`[estate_orders.term]`** — the renewal and the rule for a customer who
/// stops paying.
///
/// ```toml
/// [estate_orders.term]
/// remind_days = [30, 14, 3] # before paid_until
/// grace_days = 14 # still running after it
/// delete_after_days = 60 # deleted no earlier than this after it
/// warn_days = [0, 7, 14, 45, 59] # after it
/// renew_url = "https://{site}/private?renew={company}" # {site}: the run's own site
/// ```
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TermPolicy {
/// Days before `paid_until` a reminder goes out; the largest is when a
/// renewal opens.
pub remind_days: Vec<u16>,
/// Days after `paid_until` the estate keeps running unpaid.
pub grace_days: u16,
/// **A grace of HOURS instead** (test2's one-day term, the owner 2026-10-08: one hour);
/// 0: `grace_days`.
pub grace_hours: u16,
/// Days after `paid_until` it is deleted, at the earliest. 0: never.
pub delete_after_days: u16,
/// Days after `paid_until` a warning goes out.
pub warn_days: Vec<u16>,
/// Where a customer renews; `{company}` is filled in, and `{site}` with the apex of the
/// site the estate was bought on (ST-5).
pub renew_url: String,
}
impl Default for TermPolicy {
fn default() -> Self {
TermPolicy {
remind_days: vec![30, 14, 3],
grace_days: 14,
grace_hours: 0,
delete_after_days: 60,
warn_days: vec![0, 7, 14, 45, 59],
renew_url: "https://{site}/private?renew={company}".into(),
}
}
}
impl TermPolicy {
/// **Refused by name**: a deletion that could fall inside the grace (the
/// estate still running), or a notice shorter than a month; a warning on
/// or after the deletion.
pub fn check(&self) -> Result<(), String> {
if self.delete_after_days > 0 {
if u64::from(self.delete_after_days) * DAY_MS <= self.grace_ms() {
return Err(format!(
"[estate_orders.term] delete_after_days = {} is inside grace_days = {}: an estate could be deleted while it still runs; the rule is stopped FIRST, deleted after",
self.delete_after_days, self.grace_days
));
}
if self.delete_after_days < 30 {
return Err(format!("[estate_orders.term] delete_after_days = {}: a customer gets at least 30 days' notice before their data is deleted", self.delete_after_days));
}
if let Some(d) = self.warn_days.iter().find(|d| **d >= self.delete_after_days) {
return Err(format!("[estate_orders.term] warn_days has {d}, not before delete_after_days = {}: it warns of nothing", self.delete_after_days));
}
}
Ok(())
}
/// **The grace, in milliseconds**: `grace_hours` when set, else `grace_days`.
pub fn grace_ms(&self) -> u64 {
if self.grace_hours > 0 {
u64::from(self.grace_hours) * 3_600_000
} else {
u64::from(self.grace_days) * DAY_MS
}
}
/// The grace, as words: `1 hour`, `14 days`.
pub fn grace_words(&self) -> String {
match (self.grace_hours, self.grace_days) {
(1, _) => "1 hour".into(),
(h, _) if h > 1 => format!("{h} hours"),
(_, 1) => "1 day".into(),
(_, d) => format!("{d} days"),
}
}
/// The renewal page for `company`.
pub fn url_for(&self, company: &str) -> String {
self.renew_url.replace("{company}", company)
}
/// When a renewal opens: the largest reminder before `paid_until` — or, for a term too
/// short for any (a day), half the term before it.
fn opens_at(&self, paid_until_ms: u64, term_months: u32, term_days: u32) -> u64 {
match self.remind_days_for(term_months, term_days).into_iter().max() {
Some(d) => paid_until_ms.saturating_sub(u64::from(d) * DAY_MS),
None if term_days > 0 => paid_until_ms.saturating_sub(u64::from(term_days) * DAY_MS / 2),
None => paid_until_ms.saturating_sub(3 * DAY_MS),
}
}
/// **The reminder days that fit a term** (wiring trace A11): a reminder
/// is never earlier than half the paid period — test2's ONE month gets
/// its 14- and 3-day reminders, not one right after it was paid; a term of one day
/// gets none.
pub fn remind_days_for(&self, term_months: u32, term_days: u32) -> Vec<u16> {
let half = if term_days > 0 { (term_days / 2) as u16 } else { (term_months.max(1) as u64 * 30 / 2) as u16 };
let mut days: Vec<u16> = self.remind_days.iter().copied().filter(|d| *d <= half).collect();
days.sort_unstable();
days.dedup();
days
}
}
/// **A renewal the customer opened** and has not paid yet.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Renewal {
/// `estate/<company>/renewal-<n>`: the vendor's reference.
pub reference: String,
pub handle: ChargeHandle,
pub checkout_url: String,
/// The six months' price, as quoted when it was opened.
pub amount: Money,
pub monthly: Money,
pub opened_at_ms: u64,
}
/// **A renewal paid.**
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RenewalDone {
pub reference: String,
pub amount: Money,
pub captured_at_ms: u64,
/// The `paid_until` it bought.
pub paid_until_ms: u64,
/// The vendor's charge it was paid under (F-S-01: a refund or a dispute names it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<ChargeHandle>,
/// Where the time it bought starts (the end of the period before it, or the payment for a
/// stopped estate); 0: a renewal recorded before this was kept.
#[serde(default)]
pub from_ms: u64,
}
/// **`EstateOrder.Renew`**: open (or answer again) the renewal of a delivered
/// estate — six months at TODAY's provider price + the margin, held on the
/// vendor's page and captured when the customer authorises it. Opens
/// `max(remind_days)` days before the period ends; a lapsed or stopped
/// estate may renew until it is deleted.
/// What [`cancel`] answers: the dates, and one sentence for the page.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cancelled {
pub paid_until_ms: u64,
pub delete_on_ms: u64,
pub words: String,
}
/// **The buyer stops renewing** (wiring trace A51; the owner via the
/// coordinator, 2026-10-08: cancel = no renewal): the estate runs to the end
/// of its paid period, is then STOPPED with every disk kept for the term's
/// grace (`grace_days`), and deleted after it. Renewing before the deletion
/// keeps it (and undoes the cancel). A renewal page in flight is released.
pub fn cancel(state: &AppState, company: &str) -> Result<Cancelled, StartRefusal> {
let eo = &state.estates;
let cfg = eo.cfg.as_ref().ok_or_else(|| StartRefusal::NotSold("this monetize sells no private estates ([estate_orders] is absent)".into()))?;
let _pen = eo.pen_for_a_call(cfg, company)?;
let mut run = eo.get(company).filter(|r| !phase::is_gone(&r.phase)).ok_or_else(|| StartRefusal::NotFound(format!("no order for {company:?}")))?;
// ★ ST-5: the run's own site's account and name.
let site_terms = super::terms_of(state, cfg, &run).map_err(StartRefusal::NotSold)?;
let cfg = &site_terms;
if !matches!(run.phase.as_str(), phase::CAPTURED | phase::GRACE) {
return Err(StartRefusal::NotNow(format!("{} is not running a paid period it could stop renewing ({})", host_of(cfg, &run), run.phase)));
}
let now = state.now();
let delete_on_ms = run.paid_until_ms.max(now).saturating_add(cfg.term.grace_ms());
let host = host_of(cfg, &run);
let words = format!(
"{host} runs until {}, the end of its paid period. Then it is stopped, and every disk and all your data are kept until {}, when it is deleted. Renew before then to keep it.",
iso_date(run.paid_until_ms),
iso_date(delete_on_ms)
);
if run.cancelled_at_ms == 0 {
run.cancelled_at_ms = now;
if let (Some(r), Some(vendor)) = (run.renewal.take(), state.registry.find_vendor(&cfg.vendor)) {
match super::pages::close(vendor, &r.handle) {
super::pages::Closed::Closed => note(&mut run, now, format!("renewal {} released: the buyer cancelled", r.reference)),
// Paid as the buyer cancelled: kept, captured on the tick (a renewal undoes a cancel).
super::pages::Closed::Held | super::pages::Closed::Paid(_) => {
note(&mut run, now, format!("renewal {} was paid as the buyer cancelled: it is kept and captured", r.reference));
run.renewal = Some(r);
}
super::pages::Closed::Failed(e) => super::pages::settle(&mut run, &r.reference, &r.handle, super::pages::Closed::Failed(e), "the buyer cancelled", now),
}
}
let until = iso_date(run.paid_until_ms);
note(&mut run, now, format!("cancelled by its buyer: no renewal; stopped after {until}, deleted on {}", iso_date(delete_on_ms)));
mail(state, &mut run, "estate.cancelled", format!("{host} is cancelled: it runs until {until}"), format!("{words}\n"));
super::save(state, &run).map_err(StartRefusal::NotSold)?;
}
Ok(Cancelled { paid_until_ms: run.paid_until_ms, delete_on_ms, words })
}
pub fn renew(state: &AppState, company: &str) -> Result<Renewal, StartRefusal> {
let eo = &state.estates;
let base = eo.cfg.as_ref().ok_or_else(|| StartRefusal::NotSold("this monetize sells no private estates ([estate_orders] is absent)".into()))?;
// ★ ST-5: renewed through the account of the site it was bought on, never a fixed one —
// and named on that site.
let site_terms = match eo.get(company) {
Some(run) => super::terms_of(state, base, &run).map_err(StartRefusal::NotSold)?,
None => base.clone(),
};
let cfg = &site_terms;
let now = state.now();
// Refused at once when there is nothing to renew: a run being laid or
// installed holds its pen for as long as that takes.
renewable(cfg, company, eo.get(company), now)?;
// **The run's pen, from the read to the write** (`super::Pens`): the
// driver's step writes nothing between them, and this writes over nothing.
let _pen = eo.pen_for_a_call(cfg, company)?;
let mut run = renewable(cfg, company, eo.get(company), now)?;
let vendor = state.registry.find_vendor(&cfg.vendor).ok_or_else(|| StartRefusal::Vendor(format!("the vendor {:?} is not linked here", cfg.vendor)))?;
// The page already open is answered again while it can still be paid.
if let Some(r) = run.renewal.clone() {
let fresh = now <= r.opened_at_ms.saturating_add(cfg.order_ttl_secs.saturating_mul(1_000));
match vendor.charge(&r.handle) {
Ok(ChargeStatus::AwaitingCustomer) | Ok(ChargeStatus::Reserved) if fresh => return Ok(r),
Ok(ChargeStatus::Reserved) | Ok(ChargeStatus::Captured { .. }) => return Ok(r),
// A stale page is closed before a new one opens — and one paid in its last second
// is the renewal (the tick captures it); one the vendor did not close stays open.
_ => match super::pages::close(vendor, &r.handle) {
super::pages::Closed::Closed => {}
super::pages::Closed::Held | super::pages::Closed::Paid(_) => return Ok(r),
super::pages::Closed::Failed(e) => {
note(&mut run, now, format!("renewal {}: its stale page was not closed ({e}); no new page is opened while it may be paid", r.reference));
super::save(state, &run).map_err(StartRefusal::NotSold)?;
return Err(StartRefusal::Vendor(format!("the renewal's old payment page could not be closed yet: {e}; ask again in a minute")));
}
},
}
}
// Today's price for the estate as it stands — what is LAID, read back
// from the provider first (its data as its own monetize grew it; (d)),
// its twin as it grew; the estate's own spend cap, not the offer's. An
// unreadable provider leaves what was last read.
if let Ok(Some(line)) = super::refresh_laid(state, cfg, &mut run) {
run.sizes_read_at_ms = now;
note(&mut run, now, line);
}
let q = super::quote_for_run(state, &run)?;
// **A renewal is never refused for a cap** (A03, the owner 2026-10-08):
// the buyer keeps paying for what runs; an estate now over its own cap
// is the operator's to see, not the buyer's.
let cap = super::effective_cap(cfg, &run);
if cap.minor > 0 && q.cost_in_sale.minor > cap.minor {
eprintln!("monetize-server: ALERT estate-orders.queue {}: renewed over its spend cap: {}", run.reference, over_cap(q.cost_in_sale, cap));
}
let reference = format!("{}/renewal-{}", run.reference, run.renewals.len() + 1);
let host = if run.domain.is_empty() { cfg.host_of(company) } else { run.domain.clone() };
let req = ChargeRequest {
reference: reference.clone(),
amount: q.total,
market: q.total.currency.market(),
description: format!("{} — your own estate {host}, {} more (charged when you authorise it)", super::lifecycle::name_of(cfg), price::term_words(q.term_months, q.term_days)),
payer: Some(run.email.clone()),
};
// A16: back to the site it was bought on, whichever instance takes the money.
let handle = vendor.reserve(&req, &crate::returns::back_for(state.registry.site_of_estate(&run.estate).as_deref(), &req.reference)).map_err(|e| StartRefusal::Vendor(format!("the vendor refused the renewal: {e}")))?;
let renewal = Renewal { reference, checkout_url: vendor.page_url(&handle).unwrap_or_default(), handle, amount: q.total, monthly: q.monthly, opened_at_ms: now };
run.renewal = Some(renewal.clone());
run.history.push(Transition { phase: String::new(), at_ms: now, note: format!("renewal {} opened: {} for {} at {}", renewal.reference, q.total.in_words(), price::term_words(q.term_months, q.term_days), q.price_words()) });
super::save(state, &run).map_err(StartRefusal::NotSold)?;
Ok(renewal)
}
/// The run, if it may be renewed now: delivered, lapsed or stopped — and,
/// while it is paid, inside its window.
fn renewable(cfg: &super::EstateOrderConfig, company: &str, run: Option<EstateRun>, now: u64) -> Result<EstateRun, StartRefusal> {
let run = run.ok_or_else(|| StartRefusal::NotNow(format!("there is no estate {}", cfg.host_of(company))))?;
if !matches!(run.phase.as_str(), phase::CAPTURED | phase::GRACE | phase::SUSPENDED) {
return Err(StartRefusal::NotNow(format!("{} is {}: there is nothing to renew", cfg.host_of(company), run.phase)));
}
let opens = cfg.term.opens_at(run.paid_until_ms, run.quote.term_months, run.quote.term_days);
if run.phase == phase::CAPTURED && now < opens {
return Err(StartRefusal::NotNow(format!("{} is paid until {}; its renewal opens on {}", cfg.host_of(company), iso_date(run.paid_until_ms), iso_date(opens))));
}
Ok(run)
}
/// **Is a monthly provider cost within the estate's spend cap?** Words when not.
pub fn within_cap(run: &EstateRun, cost: Money) -> Result<(), String> {
if run.spend_cap.minor > 0 && cost.minor > run.spend_cap.minor {
return Err(over_cap(cost, run.spend_cap));
}
Ok(())
}
/// The refusal's words, one place.
pub fn over_cap(cost: Money, cap: Money) -> String {
format!("{} a month at the provider is over this estate's spend cap of {} a month; nothing was bought", cost.in_words(), cap.in_words())
}
/// **One step of a delivered estate** (`captured`, `grace`, `suspended`,
/// `deleting`).
pub(super) fn step(state: &AppState, run: &mut EstateRun) -> Next {
let Some(base) = state.estates.cfg.clone() else { return Next::Park("[estate_orders] is absent".into()) };
// ★ ST-5: the run's own site's account, name and renewal page.
let cfg = match super::terms_of(state, &base, run) {
Ok(c) => c,
Err(why) => return Next::Park(why),
};
let t = &cfg.term;
let now = state.now();
// ── A47: the ready mail that did not go, asked again within the hour ──
if !run.told && run.phase == phase::CAPTURED && now >= run.told_tried_at_ms.saturating_add(60 * 60 * 1_000) {
super::tell(state, run);
}
// ── the hand-off is done: the shop FORGETS the estate's secrets ──
// (the owner via the coordinator, 2026-10-08) once the estate runs and its
// buyer has the first-login link: its signing seed, backup key, pairing
// material, break-glass bearer, first-login invite and client seed. The
// shop keeps its OWN facts key and the estate's PUBLIC client key (which
// verifies the estate's signed calls) — nothing else: the twin is read from
// the estate's own reports (ONE monetize, G12), so no health reader is kept.
// F4: an estate whose pairing handed back its installer material is given
// [`super::sold::MATERIAL_TAKE_WITHIN_MS`] to TAKE it (`SoldEstate.TakeMaterial`,
// its pull) before the shop forgets; past that the shop forgets anyway, said loud.
// ★ OX-WGDS3: a BENCH box keeps them (the gate's console calls after ready), said once.
if run.phase == phase::CAPTURED && run.told && run.secrets_forgotten_at_ms == 0 && cfg.bench_keep_secrets && !run.history.iter().any(|t| t.note == BENCH_KEPT) {
note(run, now, BENCH_KEPT.to_string());
}
if run.phase == phase::CAPTURED && run.told && run.secrets_forgotten_at_ms == 0 && !cfg.bench_keep_secrets && !state.estates.keep_secrets_for_health_reads.load(std::sync::atomic::Ordering::SeqCst) {
if let Some(c) = state.estates.custody.as_ref() {
let waits = super::sold::material_waits(c, run, now);
if !waits {
let untaken = run.material_taken_at_ms == 0 && c.installer_material(&run.company).ok().flatten().is_some();
// ★ Lane N2: a narrated site's own estate keeps its installer link file (its
// DEV-NARRATE receiver reads it), in a seal of its own; everything else goes.
// ★ Lane O1: the estate's off-site pair stays with the operator (its own seal).
let narrated = run.site_order && state.registry.narrates(&run.estate);
match c.forget_handed_off(&run.company, narrated) {
Ok(()) => {
run.secrets_forgotten_at_ms = now;
note(run, now, "the hand-off is done: the shop forgot the estate's secrets (signing seed, backup key, pairing material, break-glass bearer, first-login invite, client seed); it keeps its own facts key and the estate's public client key".to_string());
if untaken {
eprintln!("monetize-server: ALERT estate-orders.material {}: the estate never took its installer material; forgotten with the rest — it cannot be upgraded, recharged or renewed by itself", run.reference);
note(run, now, "ALERT: the estate never took its installer material (its front's `material` ask); it was forgotten with the rest".to_string());
}
}
Err(e) => eprintln!("monetize-server: ALERT estate-orders.custody {}: the estate's secrets were not forgotten after the hand-off: {e}", run.reference),
}
}
}
}
// ── the release clones go once the estate is handed over (no-dwarf step 4) ──
if run.phase == phase::CAPTURED && run.told && run.media_forgotten_at_ms == 0 {
if let Some(cloud) = state.registry.find_cloud(&cfg.cloud) {
match super::forget_media(state, cloud, run) {
Ok(Some(line)) => note(run, now, line),
Ok(None) => {}
Err(e) => eprintln!("monetize-server: estate order {}: {e}", run.reference),
}
}
}
// ── a plan change in flight, or the twin growing by itself ──
if run.phase != phase::DELETING {
super::change::advance(state, &cfg, run, now);
}
// ── what the estate's own monetize grew, read back once a day (d) ──
if run.phase != phase::DELETING && now >= run.sizes_read_at_ms.saturating_add(DAY_MS) {
match super::refresh_laid(state, &cfg, run) {
Ok(said) => {
run.sizes_read_at_ms = now;
if let Some(line) = said {
note(run, now, line);
}
}
// Read again next tick; what was last read stands meanwhile.
Err(e) => eprintln!("monetize-server: estate order {}: the laid sizes were not read back: {e}", run.reference),
}
}
// ── a renewal the customer opened ──
if let Some(r) = run.renewal.clone() {
if let Some(vendor) = state.registry.find_vendor(&cfg.vendor) {
if run.phase == phase::DELETING {
// Never charged for an estate on its way out.
let closed = super::pages::close(vendor, &r.handle);
run.renewal = None;
note(run, now, format!("renewal {} released: the estate is being deleted", r.reference));
super::pages::settle(run, &r.reference, &r.handle, closed, "the estate is being deleted", now);
} else {
let captured = match vendor.charge(&r.handle) {
Ok(ChargeStatus::Reserved) => match vendor.capture(&r.handle) {
Ok(ChargeStatus::Captured { vendor_ref, .. }) => Some(vendor_ref),
Ok(other) => return Next::Park(format!("the renewal's capture answered {other:?}; asking again")),
Err(e) => return Next::Park(format!("the renewal's capture did not go through yet: {e}")),
},
Ok(ChargeStatus::Captured { vendor_ref, .. }) => Some(vendor_ref),
Ok(ChargeStatus::AwaitingCustomer) => {
if now > r.opened_at_ms.saturating_add(cfg.order_ttl_secs.saturating_mul(1_000)) {
// ★ F-S-02: the ORDERED phase's rule — paid in the last second is
// captured, a page not closed is asked again; never dropped.
match super::pages::close_or_capture(vendor, &r.handle) {
super::pages::Closed::Closed => {
run.renewal = None;
note(run, now, format!("renewal {} expired unpaid", r.reference));
None
}
super::pages::Closed::Paid(vendor_ref) => Some(vendor_ref),
super::pages::Closed::Held => return Next::Park(format!("renewal {} was paid as its page expired; capturing it", r.reference)),
super::pages::Closed::Failed(e) => return Next::Park(format!("renewal {}'s page expired, and closing it did not go through yet: {e}; asking again", r.reference)),
}
} else {
None
}
}
Ok(ChargeStatus::Released) | Ok(ChargeStatus::Declined { .. }) => {
run.renewal = None;
note(run, now, format!("renewal {} was not authorised", r.reference));
None
}
Err(_) => None,
};
if let Some(vendor_ref) = captured {
// From the end of the paid period — or from the payment,
// for an estate already stopped (those weeks were not service).
let from = if run.phase == phase::SUSPENDED { now } else { run.paid_until_ms.max(1) };
run.paid_until_ms = price::add_term(from, run.quote.term_months, run.quote.term_days);
run.renewals.push(RenewalDone { reference: r.reference.clone(), amount: r.amount, captured_at_ms: now, paid_until_ms: run.paid_until_ms, handle: Some(r.handle.clone()), from_ms: from });
run.renewal = None;
run.delete_on_ms = 0;
// Paid again: a reversal's lapse is over (F-S-01).
run.reversed_at_ms = 0;
// A renewal undoes the buyer's cancel (A51).
run.cancelled_at_ms = 0;
note(run, now, format!("renewal {} captured ({vendor_ref}): {}, paid until {}", r.reference, r.amount.in_words(), iso_date(run.paid_until_ms)));
super::sold::renewed(state, run, &r.reference, now);
let host = host_of(&cfg, run);
mail(
state,
run,
"estate.renewed",
format!("Your {} {host} is paid until {}", super::lifecycle::name_of(&cfg), iso_date(run.paid_until_ms)),
format!("Thank you. {} was charged for {} more of {host}; it is paid until {}.\n", r.amount.in_words(), price::term_words(run.quote.term_months, run.quote.term_days), iso_date(run.paid_until_ms)),
);
}
}
}
}
// ── the operator's own site (an operator-site order) does not lapse ──
// Its "term" is the sold estate's arithmetic, not a customer's paid period: it is never
// put in grace, stopped or deleted for it, and nobody is mailed a reminder. It ends by the
// operator's terminate alone.
if run.site_order {
return Next::Park("the operator's own site: its term does not lapse".into());
}
// ── paid: the estate runs ──
if now < run.paid_until_ms {
match run.phase.as_str() {
phase::SUSPENDED | phase::DELETING if !run.stopped.is_empty() || run.phase == phase::SUSPENDED => {
// A refused start is CODE RED and asked again on the next tick (audit 3).
let red = format!("start:{}", run.company);
if let Err(e) = power(state, &cfg, run, true) {
let host = host_of(&cfg, run);
super::code_red::raise(state, &red, &host, "every box", "paid, and its machines would not start again", &e, "start the boxes left stopped at the provider; the shop asks again every tick");
return Next::Park(format!("paid until {}; starting the machines again: {e}", iso_date(run.paid_until_ms)));
}
super::code_red::clear(state, &red);
run.suspended_at_ms = 0;
return Next::To(phase::CAPTURED, format!("paid until {}; the machines are started again, every disk as it was", iso_date(run.paid_until_ms)));
}
phase::GRACE | phase::DELETING => return Next::To(phase::CAPTURED, format!("paid until {}", iso_date(run.paid_until_ms))),
_ => {}
}
if run.cancelled_at_ms == 0 {
remind(state, &cfg, run, now);
}
return Next::Park(format!("paid until {}{}", iso_date(run.paid_until_ms), if run.cancelled_at_ms > 0 { "; cancelled by its buyer: not renewed" } else { "" }));
}
// ── not paid ──
// **Cancelled by its buyer** (A51): stopped at the end of the paid
// period, every disk kept for the grace, deleted after it.
if run.cancelled_at_ms > 0 && matches!(run.phase.as_str(), phase::CAPTURED | phase::GRACE) {
let delete_on = run.paid_until_ms.saturating_add(t.grace_ms());
if run.delete_on_ms == 0 || run.delete_on_ms > delete_on {
run.delete_on_ms = delete_on;
}
if let Err(e) = power(state, &cfg, run, false) {
return Next::Park(format!("cancelled, and the paid period ended; stopping the machines: {e}"));
}
run.suspended_at_ms = now;
let host = host_of(&cfg, run);
let url = t.url_for(&run.company);
mail(
state,
run,
"estate.stopped",
format!("{host} is stopped — your data is kept until {}", deletion_words(run)),
format!(
"You cancelled {host}, and its paid period ended on {}. It is now stopped: every disk and all your data are kept, untouched, until {}, when it is deleted.\n\nTo keep it, renew at {url} before then: it starts again as it was.\n",
iso_date(run.paid_until_ms),
deletion_words(run)
),
);
return Next::To(phase::SUSPENDED, format!("cancelled by its buyer: stopped at the end of the paid period, every disk kept; deleted on {}", deletion_words(run)));
}
if run.delete_on_ms == 0 && t.delete_after_days > 0 {
// Fixed ONCE, by the first mail: never earlier than the full notice
// from that mail, whenever this server first noticed.
let d = t.delete_after_days as u64 * DAY_MS;
run.delete_on_ms = run.paid_until_ms.saturating_add(d).max(now.saturating_add(d.saturating_sub(t.warn_days.iter().copied().min().unwrap_or(0) as u64 * DAY_MS)));
}
let stop_on = run.paid_until_ms.saturating_add(t.grace_ms());
match run.phase.as_str() {
phase::CAPTURED => {
warn(state, &cfg, run, now);
Next::To(phase::GRACE, format!("the paid period ended on {}; it runs until {}, then it is stopped with its data kept", iso_date(run.paid_until_ms), iso_date(stop_on)))
}
phase::GRACE => {
warn(state, &cfg, run, now);
if now < stop_on {
return Next::Park(format!("unpaid since {}; runs until {}", iso_date(run.paid_until_ms), iso_date(stop_on)));
}
if let Err(e) = power(state, &cfg, run, false) {
return Next::Park(format!("unpaid past the grace; stopping the machines: {e}"));
}
run.suspended_at_ms = now;
let host = host_of(&cfg, run);
let url = t.url_for(&run.company);
mail(
state,
run,
"estate.suspended",
format!("Your {} {host} is stopped — your data is kept until {}", super::lifecycle::name_of(&cfg), deletion_words(run)),
format!(
"{host} was paid until {}. It is now stopped: every disk and all your data are kept, untouched, until {}{}.\n\nRenew at {url} and it starts again.\n",
iso_date(run.paid_until_ms),
deletion_words(run),
if run.delete_on_ms > 0 { ", when it is deleted" } else { "" }
),
);
Next::To(phase::SUSPENDED, format!("unpaid past the grace: every server stopped, every disk kept; deleted on {}", deletion_words(run)))
}
phase::SUSPENDED => {
// A buyer who cancelled was told once, when it stopped (A51).
if run.cancelled_at_ms == 0 {
warn(state, &cfg, run, now);
}
if run.delete_on_ms > 0 && now >= run.delete_on_ms && run.reversed_at_ms > 0 {
// ★ F-S-01: lapsed because its money went back at the vendor — never deleted by
// itself; the operator decides (renewed, or terminated by hand).
super::reversed::hold_deletion(run, now)
} else if run.delete_on_ms > 0 && now >= run.delete_on_ms {
Next::To(phase::DELETING, format!("unpaid since {} and past the notice of {}: deleting", iso_date(run.paid_until_ms), iso_date(run.delete_on_ms)))
} else {
Next::Park(format!("stopped, data kept; deleted on {}", deletion_words(run)))
}
}
phase::DELETING => {
// ★ The guard that does not trust the run file: inside the paid
// period nothing is deleted, whatever led here.
if run.paid_until_ms > now || run.delete_on_ms == 0 || now < run.delete_on_ms {
return Next::Park(format!("NOT deleting: paid until {}, notice until {}", iso_date(run.paid_until_ms), deletion_words(run)));
}
match super::return_everything(state, &cfg, run) {
Ok(said) => {
let host = host_of(&cfg, run);
mail(
state,
run,
"estate.deleted",
format!("Your {} {host} is deleted", super::lifecycle::name_of(&cfg)),
format!(
"{host} was paid until {}, and was not renewed. As the notice said, it was deleted on {}: its machines, disks and data are gone, and nothing more will be charged.\n\nYou are welcome to order a new one.\n",
iso_date(run.paid_until_ms),
iso_date(now)
),
);
Next::To(phase::DELETED, said.join("; "))
}
Err(next) => next,
}
}
other => Next::Park(format!("the term does not drive {other:?}")),
}
}
fn host_of(cfg: &super::EstateOrderConfig, run: &EstateRun) -> String {
if run.domain.is_empty() {
cfg.host_of(&run.company)
} else {
run.domain.clone()
}
}
fn deletion_words(run: &EstateRun) -> String {
if run.delete_on_ms == 0 {
"an operator decides".into()
} else {
iso_date(run.delete_on_ms)
}
}
fn note(run: &mut EstateRun, at_ms: u64, note: String) {
run.history.push(Transition { phase: String::new(), at_ms, note });
}
/// One mail, written down (sent or why not) in the history.
fn mail(state: &AppState, run: &mut EstateRun, kind: &str, subject: String, body: String) {
let notice = monetize_pay::CustomerNotice { product: super::change::notice_product(state.registry.product_of_estate(&run.estate), state.sold_product()), tenant: run.company.clone(), to: run.email.clone(), kind: kind.into(), subject, body };
let line = match state.signup_sink.notify(¬ice) {
Ok(l) => format!("{kind} mail sent: {l}"),
Err(e) => format!("{kind} mail NOT sent: {e:?}"),
};
let at = state.now();
note(run, at, line);
}
/// **The reminders before the period ends**: the latest one due and not yet
/// sent is sent; earlier ones due at the same time are written down as
/// skipped (never sent late).
fn remind(state: &AppState, cfg: &super::EstateOrderConfig, run: &mut EstateRun, now: u64) {
let days = cfg.term.remind_days_for(run.quote.term_months, run.quote.term_days);
let due: Vec<u16> = days.iter().copied().filter(|d| now >= run.paid_until_ms.saturating_sub(*d as u64 * DAY_MS)).collect();
let Some(latest) = due.first().copied() else { return };
let key = |d: u16| format!("{}:remind:{d}", run.paid_until_ms);
if run.reminded.contains(&key(latest)) {
return;
}
for d in &due {
let k = key(*d);
if !run.reminded.contains(&k) {
run.reminded.push(k);
}
}
let host = host_of(cfg, run);
let url = cfg.term.url_for(&run.company);
let price_now = match super::quote_for_run(state, run) {
Ok(q) => format!("{} more cost {} ({} a month: the provider's price today + {} %).", price::term_words(q.term_months, q.term_days), q.total.in_words(), q.monthly.in_words(), q.margin_bp / 100),
Err(e) => format!("The price is shown on the renewal page ({e})."),
};
let paid = iso_date(run.paid_until_ms);
mail(
state,
run,
"estate.renewal_reminder",
format!("Your {} {host} is paid until {paid}", super::lifecycle::name_of(cfg)),
format!(
"{host} is paid until {paid}. Renew at {url} — the new month(s) start on {paid}, so renewing early loses nothing.\n\n{price_now}\n\n\
If it is not renewed, it keeps running for {} after {paid}, is then stopped with all your data kept, and is deleted no earlier than {} days after {paid}.\n",
cfg.term.grace_words(), cfg.term.delete_after_days
),
);
}
/// **The warnings after the period ended**, the same way: the latest due.
fn warn(state: &AppState, cfg: &super::EstateOrderConfig, run: &mut EstateRun, now: u64) {
let mut days = cfg.term.warn_days.clone();
days.sort_unstable();
days.dedup();
let due: Vec<u16> = days.iter().copied().filter(|d| now >= run.paid_until_ms.saturating_add(*d as u64 * DAY_MS)).collect();
let Some(latest) = due.last().copied() else { return };
let key = |d: u16| format!("{}:warn:{d}", run.paid_until_ms);
if run.reminded.contains(&key(latest)) {
return;
}
let first = !run.reminded.iter().any(|k| k.starts_with(&format!("{}:warn:", run.paid_until_ms)));
for d in &due {
let k = key(*d);
if !run.reminded.contains(&k) {
run.reminded.push(k);
}
}
let host = host_of(cfg, run);
let url = cfg.term.url_for(&run.company);
let paid = iso_date(run.paid_until_ms);
let stop_on = iso_date(run.paid_until_ms.saturating_add(cfg.term.grace_ms()));
let rule = format!(
"{host} was paid until {paid}. It keeps running until {stop_on}; then it is stopped, with every disk and all your data kept, until {}{}. Nothing is ever deleted inside a paid period.\n\nRenew at {url} and it runs on (or starts again).\n",
deletion_words(run),
if run.delete_on_ms > 0 { ", when it is deleted" } else { "" }
);
let (kind, subject) = if first {
("estate.lapsed", format!("Your {} {host}'s paid period ended on {paid}", super::lifecycle::name_of(cfg)))
} else {
("estate.unpaid", format!("Your {} {host} is unpaid — deleted on {}", super::lifecycle::name_of(cfg), deletion_words(run)))
};
mail(state, run, kind, subject, rule);
}
/// **Stop or start every server of the estate**, its disks untouched. Off:
/// the ids stopped are written down; on: exactly those are started.
fn power(state: &AppState, cfg: &super::EstateOrderConfig, run: &mut EstateRun, on: bool) -> Result<(), String> {
let cloud = state.registry.find_cloud(&cfg.cloud).ok_or_else(|| format!("the cloud {:?} is not linked", cfg.cloud))?;
let ids: Vec<ResourceId> = if on {
run.stopped.iter().map(|id| ResourceId { provider: cloud.name().into(), id: id.clone() }).collect()
} else {
let inv = monetize_cloud::ask::account(&cloud).map_err(|e| format!("the cloud could not be read: {e}"))?;
inv.owned.iter().filter(|r| r.kind == OwnedKind::Server && r.reference.as_deref() == Some(run.reference.as_str())).map(|r| r.id.clone()).collect()
};
// Every box is asked, a refusal of one never holds the others back; on: the ones that
// started leave the list, the refused stay on it for the next tick.
let mut refused = Vec::new();
for id in &ids {
match monetize_cloud::ask::set_power(&cloud, id, on) {
Ok(()) => {}
Err(CloudError::UnknownResource(_)) if on => {}
Err(e) => {
refused.push(format!("{}: {e}", id.id));
continue;
}
}
if on {
run.stopped.retain(|s| *s != id.id);
} else if !run.stopped.contains(&id.id) {
run.stopped.push(id.id.clone());
}
}
if refused.is_empty() { Ok(()) } else { Err(refused.join("; ")) }
}