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/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
}