crates/monetize-server/src/estate_order/terminate.rs
//! **TERMINATE a sold Gunnar** (owner 2026-10-12): the ONE box ends a private estate it laid
//! — its machines, its disks, its DNS records and the box's own records of it — at its
//! account admin's word, from the console's button (admin only, a fresh login, the estate's
//! name typed back).
//!
//! * **The name typed back** is the estate's company or its host, exactly; anything else ends
//! nothing ([`StartRefusal::BadName`]).
//! * **No backup, ever** (owner 2026-10-22, "ta ingen backup alls"): it is ended without one,
//! for the buyer and the operator alike, and the wire carries no backup word (A32-27: the
//! request's `backup` and the answer's `backup_offered` / `backups` are deleted, reserved).
//! * **The operator's own sites are protected**: a declared `[estates.<label>]` — gunnar.rs
//! above all — is never ended here, by its label or its host, unless it says
//! `terminable = true`.
//! * **Every step is a ledger row** (an instance row in phase `terminating` whose note says
//! what the step did, then `terminated`) **and an ALERT line** the ONE box's PID 1 mails to
//! its operator (ALERT-1, [`alert_line`]).
//!
//! The give-back is [`super::return_everything`], the path a lapsed estate's deletion takes.
//! **It is never done inside the call** (F-T-02, A17): `Terminate` marks the run `terminating`
//! and answers at once; the tick's drive of the estate (a queued jera job) gives everything
//! back, and asks again while the provider deletes in its own time.
use super::{phase, save, EstateRun, StartRefusal, Transition};
use crate::state::AppState;
/// **Who asked the terminate** (owner 2026-10-22): the estate's own account (its ticket
/// proved), or the ONE box's operator (the operator bearer). The path is the same; the run's
/// history and its ledger rows say who.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AskedBy {
/// The account that owns the estate.
Account,
/// The operator of the ONE box.
Operator,
}
impl AskedBy {
/// Who, in the run's history: "its account" or "the operator".
pub fn who(self) -> &'static str {
match self {
AskedBy::Account => "its account",
AskedBy::Operator => "the operator",
}
}
/// Pure: **who asked the terminate of `run`**, read back from its history (the tick
/// finishes a terminate the provider was slow to give back).
fn of(run: &EstateRun) -> AskedBy {
let asked = run
.history
.iter()
.rev()
.find(|t| t.phase == phase::TERMINATING);
if asked.is_some_and(|t| {
t.note
.starts_with(&format!("terminate asked by {}", AskedBy::Operator.who()))
}) {
AskedBy::Operator
} else {
AskedBy::Account
}
}
}
/// What a terminate answered.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Termination {
/// Everything is gone (`false` while the provider still deletes).
pub done: bool,
/// What the admin reads.
pub words: String,
}
/// The ALERT kind of a terminate step, for the operator's mail (ALERT-1).
pub const ALERT_KIND: &str = "estate-orders.terminate";
/// Pure: **one terminate step as the ONE box's ALERT line** — `monetize-server: ALERT
/// estate-orders.terminate <reference>: <words>`, the form PID 1 hears and mails.
pub fn alert_line(reference: &str, words: &str) -> String {
format!("monetize-server: ALERT {ALERT_KIND} {reference}: {words}")
}
/// Pure: **the operator's ALERT line for a terminate step that failed** (audit 6: a stuck
/// terminate keeps billing), once per change — `None` while the run already parks on `why`.
pub fn failed_step_alert(parked_on: &str, reference: &str, why: &str) -> Option<String> {
(parked_on != format!("being terminated: {why}")).then(|| {
alert_line(
reference,
&format!("a terminate step failed (asked again each tick): {why}"),
)
})
}
/// **Why `name` is protected from a terminate**, or `None`: a declared site (its label or its
/// host) that does not say `terminable = true`, or a name the shop keeps for the operator's
/// own estates (`[estate_orders] reserved_names`).
pub fn protected(state: &AppState, name: &str) -> Option<String> {
let name = name.trim().trim_end_matches('.').to_ascii_lowercase();
for (label, e) in state.registry.declared_estates() {
let site = e.site.trim().to_ascii_lowercase();
if !e.terminable && (name == label || (!site.is_empty() && name == site)) {
let shown = if site.is_empty() { label.clone() } else { site };
return Some(format!("{shown} is one of the operator's own sites and is protected: it is never terminated here (its [estates.{label}] does not say terminable = true)"));
}
}
let reserved = state
.estates
.cfg
.as_ref()
.is_some_and(|c| c.reserved_names.contains(&name));
reserved.then(|| format!("{name} is one of the operator's own estates and is protected: it is never terminated here"))
}
/// Pure: **the phases a terminate may end** — a running estate (its term), a setup that failed
/// (`failed`, `owed`: whatever it left is given back), a `paired` one that never reached
/// delivered (F10: every box PAIRED by its installer's PASS or adopted on a re-check, and its
/// install no longer runs, so its boxes are as safe to give back as a failed setup's) and one
/// already being terminated. `laying` and `installing` are refused: the install still runs.
/// The money follows the rules it already had: a terminate captures nothing and refunds
/// nothing. The Sites listing asks the same rule ([`super::site::sites`]), so it offers what
/// is allowed.
pub fn phase_allows(phase: &str) -> bool {
phase::is_term(phase)
|| matches!(
phase,
phase::PAIRED | phase::FAILED | phase::OWED | phase::TERMINATING
)
}
/// The run's host: its own domain, else `<company>.<its site's apex>`.
fn host_of(state: &AppState, run: &EstateRun) -> String {
if !run.domain.is_empty() {
return run.domain.clone();
}
super::run_terms(state, run)
.map(|c| c.host_of(&run.company))
.unwrap_or_else(|| run.company.clone())
}
/// **Note a step**: its history line, its ledger row (phase `terminating`, the step's words)
/// and its ALERT line for the operator's mail.
fn step_logged(state: &AppState, run: &mut EstateRun, words: &str) {
let now = state.now();
run.history.push(Transition {
phase: String::new(),
at_ms: now,
note: format!("terminate: {words}"),
});
let terms = super::run_terms(state, run);
let booked = EstateRun {
account: super::ledger_account(state, run),
..run.clone()
};
if let Some(monetize::Step::Instance {
reference,
product,
tenant,
company,
host,
monthly,
paid_until_unix_ms,
vendor,
operator_test,
..
}) = super::instance_step(terms.as_ref(), &booked)
{
let row = monetize::Step::Instance {
reference,
product,
tenant,
company,
host,
phase: run.phase.clone(),
monthly,
paid_until_unix_ms,
vendor,
operator_test,
note: words.to_owned(),
};
if let Err(e) = state.ledger.append(now, row) {
eprintln!("monetize-server: ALERT {ALERT_KIND} {}: the step's ledger row is not written ({e}): {words}", run.reference);
}
}
eprintln!("{}", alert_line(&run.reference, words));
}
/// **`EstateOrder.Terminate`**: end the sold estate `name` (its company), the admin having
/// typed `typed` back. The caller proved the account owns it.
pub fn terminate(
state: &AppState,
site: Option<&str>,
name: &str,
typed: &str,
) -> Result<Termination, StartRefusal> {
terminate_as(state, site, name, typed, AskedBy::Account)
}
/// **[`terminate`], asked by `by`** — the operator's terminate of a sold estate (owner
/// 2026-10-22) is the buyer's own path: the name typed back, a protected site refused, the
/// same give-back. No backup is offered to anyone. No money moves.
pub fn terminate_as(
state: &AppState,
site: Option<&str>,
name: &str,
typed: &str,
by: AskedBy,
) -> Result<Termination, StartRefusal> {
let _ = site;
let name = name.trim().to_ascii_lowercase();
if let Some(why) = protected(state, &name) {
eprintln!("monetize-server: estate order: a terminate of {name} was refused: protected");
return Err(StartRefusal::NotNow(why));
}
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(),
)
})?;
let Some(mut run) = eo.get(&name).filter(|r| !phase::is_gone(&r.phase)) else {
return Err(StartRefusal::NotFound(format!(
"{name} is not an estate this shop runs: nothing to terminate"
)));
};
// An operator site this shop laid itself is the operator's own, whatever name asked.
if run.site_order {
if let Some(why) = protected(state, &run.estate) {
return Err(StartRefusal::NotNow(why));
}
}
// An adopted site's machines carry no label of this shop: its give-back could find none of
// them and would say "terminated" over servers still billed. Its kit ends it.
if super::adopt::is_adopted(&run) {
return Err(StartRefusal::NotNow(super::adopt::not_laid_here(
&run,
"terminating it",
)));
}
let host = host_of(state, &run);
let typed = typed.trim_end_matches('.');
if typed.is_empty()
|| (typed != run.company && typed != host && !(run.site_order && typed == run.estate))
{
return Err(StartRefusal::BadName(format!(
"the name typed back is not {}: type {} exactly to terminate it. Nothing was ended.",
run.company, run.company
)));
}
if !phase_allows(&run.phase) {
return Err(StartRefusal::NotNow(format!(
"{host} is still being set up; it can be terminated once it runs"
)));
}
// The run's own pen: a site is asked by its label, its run is written under its company.
let _pen = eo.pen_for_a_call(base, &run.company)?;
let cfg = super::terms_of(state, base, &run).map_err(StartRefusal::NotSold)?;
if state.registry.find_cloud(&cfg.cloud).is_none() {
return Err(StartRefusal::NotSold(format!(
"the cloud {:?} is not linked here: nothing it laid can be ended from here",
cfg.cloud
)));
}
if run.phase != phase::TERMINATING {
run.phase = phase::TERMINATING.into();
run.history.push(Transition {
phase: phase::TERMINATING.into(),
at_ms: state.now(),
note: format!("terminate asked by {} for {host}", by.who()),
});
step_logged(
state,
&mut run,
"no backup taken: none is offered at terminate",
);
// ★ A site on its way out makes no more accounts: its pending paid sign-ups are refunded.
if run.site_order {
for line in settle_site_signups(state, &run.estate) {
step_logged(state, &mut run, &line);
}
}
// A renewal page still open is never charged for an estate on its way out.
if let Some(r) = run.renewal.take() {
if let Some(vendor) = state.registry.find_vendor(&cfg.vendor) {
let closed = super::pages::close(vendor, &r.handle);
let now = state.now();
super::pages::settle(
&mut run,
&r.reference,
&r.handle,
closed,
"the estate is terminated",
now,
);
}
step_logged(
state,
&mut run,
&format!("the open renewal {} released", r.reference),
);
}
save(state, &run).map_err(StartRefusal::NotSold)?;
}
// ★ F-T-02 (A17): the give-back is the tick's (its drive, a queued jera job on the estate's
// own chain, [`step`]): the site relay gives this call 10 s, a provider's destroy can take
// minutes. The run is `terminating`, recorded and its money stopped, when this answers.
Ok(Termination {
done: false,
words: format!(
"{host} is being ended now: its machines, disks and DNS records are being given back."
),
})
}
/// **The site's pending sign-ups, settled at its terminate** (live 2026-10-14: test2 was
/// terminated and a paid sign-up on it stayed pending — neither an account nor a refund). Each
/// paid sign-up still pending on the site `label` (begun there, or paid through the site's own
/// vendor) is refused for the customer in plain words and refunded through its vendor, the way
/// an owed order is settled; one without money is closed with a note. One line per sign-up.
pub fn settle_site_signups(state: &AppState, label: &str) -> Vec<String> {
use crate::signup::{Owed, Refusal, SignupState};
let pending = match state.signups.pending() {
Ok(p) => p,
Err(e) => {
return vec![format!(
"the site's pending sign-ups could not be read ({e}); settle them by hand"
)]
}
};
let on_site = |r: &crate::signup::SignupRecord| match r.estate.as_deref() {
Some(e) => e == label,
None => {
state
.registry
.estate_of_vendor(&r.signup.product, &r.signup.vendor)
.as_deref()
== Some(label)
}
};
let mut lines = Vec::new();
for rec in pending.into_iter().filter(on_site) {
let (vendor, session, who) = (
rec.signup.vendor.clone(),
rec.signup.session.clone(),
rec.signup.username.clone(),
);
let reason = format!("the site {label} was terminated before the account {who} was made");
let refused = SignupState::Refused {
reason: reason.clone(),
at_unix_ms: state.now() as i64,
words: Some(
"This site was closed before your account was made. Your payment is refunded."
.into(),
),
refusal: Refusal::Final,
owed: Owed::Refund,
};
if let Err(e) = state.signups.set(&vendor, &session, refused) {
lines.push(format!("the pending sign-up {who} ({vendor}) could not be settled ({e}); settle it by hand"));
continue;
}
let line = if rec.signup.amount.minor == 0 {
match crate::signup::settle_by_hand(
state,
&vendor,
&session,
&format!("{reason}; nothing was paid"),
) {
Ok(_) => format!("the pending sign-up {who} closed: nothing was paid"),
Err(e) => format!("the pending sign-up {who} refused, not closed ({e:?})"),
}
} else {
match crate::signup::refund(state, &vendor, &session, 0, &reason, false) {
Ok(_) => format!("the pending sign-up {who} refunded: {}", rec.signup.amount.in_words()),
Err(e) => format!("the pending sign-up {who} ({}) is owed back and the refund did not go through ({e:?}); it waits in the owed-back queue", rec.signup.amount.in_words()),
}
};
lines.push(line);
}
lines
}
/// The give-back and the box's own records, each a logged step; `Err` is why it waits (the
/// tick asks again, [`step`]).
fn finish(
state: &AppState,
cfg: &super::EstateOrderConfig,
run: &mut EstateRun,
) -> Result<(), String> {
let said = match super::return_everything(state, cfg, run) {
Ok(said) => said,
Err(super::Next::Park(why))
| Err(super::Next::Fail(why))
| Err(super::Next::To(_, why)) => return Err(give_back_failed(state, run, why)),
};
run.give_back_failures = 0;
run.give_back_retry_at_ms = 0;
step_logged(
state,
run,
&format!(
"every machine, disk and DNS record given back: {}",
said.join("; ")
),
);
// The ONE box's own records of it: no box of the estate can call in again.
if let Some(g) = state.fleet.guardian() {
let mut roles = g.store.roles_of(run.fleet_name());
let pinned = roles.len();
// F6: a laid box that never pinned its key has an open pin window, and no key.
for m in run.laid.iter().flat_map(|l| l.machines.iter()) {
if !roles.contains(&m.role) {
roles.push(m.role.clone());
}
}
for role in &roles {
if let Err(e) = g.store.forget_box(run.fleet_name(), role) {
return Err(format!(
"the fleet record of its {role} could not be forgotten: {e}"
));
}
}
// OVW-1: the estate itself too (its fleet row, unacked orders, auto-fix opt-in); the run
// stays, the buyer's "terminated" page and the Overview's quiet line read it.
if let Err(e) = g.store.forget_estate(run.fleet_name()) {
return Err(format!(
"the fleet record of the estate could not be forgotten: {e}"
));
}
step_logged(state, run, &format!("the ONE box's records of it forgotten: the estate, {pinned} box key(s), {} box(es) ({})", roles.len(), roles.join(", ")));
}
run.phase = phase::TERMINATED.into();
run.why = String::new();
let by = AskedBy::of(run);
run.history.push(Transition {
phase: phase::TERMINATED.into(),
at_ms: state.now(),
note: format!("terminated by {}", by.who()),
});
step_logged(
state,
run,
"terminated: nothing of it is left, and nothing more is charged",
);
Ok(())
}
/// **The tick's step for a run left `terminating`** (a provider that deletes in its own
/// time): finish it, or wait.
pub(super) fn step(state: &AppState, run: &mut EstateRun) -> super::Next {
let Some(base) = state.estates.cfg.clone() else {
return super::Next::Park("[estate_orders] is absent".into());
};
let cfg = match super::terms_of(state, &base, run) {
Ok(c) => c,
Err(why) => return super::Next::Park(why),
};
// Inside the pause after a failed give-back the provider is not asked again (F4).
if run.give_back_retry_at_ms > state.now() {
return super::Next::Park(run.why.clone());
}
match finish(state, &cfg, run) {
// `finish` moved the phase and logged it; the driver writes the run.
Ok(()) => super::Next::Park(String::new()),
Err(why) => {
let said = format!("being terminated: {why}");
if let Some(line) = failed_step_alert(&run.why, &run.reference, &why) {
eprintln!("{line}");
}
super::Next::Park(said)
}
}
}
/// The first pause after a failed give-back ([`give_back_pause_ms`]).
pub const GIVE_BACK_FIRST_PAUSE_MS: u64 = 10_000;
/// The longest pause between two asks of a give-back that keeps failing.
pub const GIVE_BACK_MAX_PAUSE_MS: u64 = 300_000;
/// Pure: **the pause after the `failures`-th failed give-back in a row** — 10 s, doubled each
/// time, at most 5 minutes: a provider that is down is not asked every tick, and one that is
/// back is asked again within minutes.
pub fn give_back_pause_ms(failures: u32) -> u64 {
GIVE_BACK_FIRST_PAUSE_MS
.saturating_mul(1u64 << failures.saturating_sub(1).min(16))
.min(GIVE_BACK_MAX_PAUSE_MS)
}
/// **A failed give-back, counted, booked and said** (F4, test3-holger-rs 2026-10-20: a destroy
/// cut off at the guest's deadline left a STOPPED main and a STARTED twin billed, and nothing
/// said why after the first answer). Every failure is a line with its full reason (the word
/// FAILED makes it a WARN on the box), the next ask is booked after a growing pause, and the
/// tick asks again then — until everything is gone. Answers `why`.
fn give_back_failed(state: &AppState, run: &mut EstateRun, why: String) -> String {
run.give_back_failures = run.give_back_failures.saturating_add(1);
let pause = give_back_pause_ms(run.give_back_failures);
run.give_back_retry_at_ms = state.now().saturating_add(pause);
eprintln!(
"{}",
alert_line(
&run.reference,
&format!(
"the give-back FAILED (try {}; asked again in {} s): {why}",
run.give_back_failures,
pause / 1_000
)
)
);
why
}