Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-web/src/sites.rs

//! **THE SITE ORDER** (owner 2026-10-13: "the shop must deploy gunnar.rs itself") — every
//! decision the Sites page takes, compiled for the host and tested there; `pages::sites`
//! only lays them out.
//!
//! The operator's own sites, one form each. The form asks what a declared site does not
//! already have — its size and its first admin's address — and shows the rest as already
//! fed: its domain, its own Stripe account, the box's one DNS token and one cloud account.
//! The console decides nothing: which sites, whether one can be ordered and why not, where
//! an order stands and where the operator pays are monetize's words.
//!
//! ```text
//! Sites:  per site — domain · Stripe · DNS · cloud · first admin   (each "already fed")
//!                    size [select]  first admin's address [input]  seed key, public [input, optional]  [Lay <domain>]
//!                    its order: state — words — [Pay <monetize's price words>] while it waits for payment
//! ```

use serde::{Deserialize, Serialize};

use crate::fmt::Tone;

pub const LIST_API: &str = "/api/sites";
pub const ORDER_API: &str = "/api/sites/order";
pub const TERMINATE_API: &str = "/api/sites/terminate";
pub const UPDATE_API: &str = "/api/sites/update";
pub const UPDATE_STATUS_API: &str = "/api/sites/update-status";
pub const LIVE_UPDATE_API: &str = "/api/sites/live-update";
/// MONETIZE-FIX: the order kinds a site's product declares, and one queued (and its answer).
pub const KINDS_API: &str = "/api/sites/kinds";
pub const DO_API: &str = "/api/sites/do";
/// How often the page reads the list again while an order is on its way.
pub const REREAD_MS: u32 = 3_000;

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteJson {
    pub estate: String,
    pub domain: String,
    pub vendor: String,
    pub first_admin: String,
    pub dns_words: String,
    pub dns_writes: bool,
    pub cloud_words: String,
    pub state: String,
    pub words: String,
    pub checkout_url: String,
    pub orderable: bool,
    pub why_not: String,
    /// monetize's word that it may be terminated; a protected site is false.
    pub terminable: bool,
    /// monetize's word that "Check again" may ask its boxes (F5).
    pub recheckable: bool,
    /// ★ A33-6: monetize's words for what it costs (the page computes no price).
    pub price_words: String,
    /// ★ A32-29: the settles the SERVER says this site offers (F2, F5), with their words; the
    /// page draws them for an admin.
    #[serde(default)]
    pub actions: Vec<crate::accounts::ActionJson>,
    /// ★ FIRST-LINK-1: the first-admin door as the server says it (`open` | `spent` |
    /// `expired` | `handed_off` | `unknown`), and its sentence; empty when not asked.
    pub first_admin_door: String,
    pub first_admin_door_words: String,
}

/// Pure: **the first-admin door, beside "Send the first sign-in link again"** (FIRST-LINK-1):
/// its word and the server's sentence; `None` when the server said none.
pub fn door_line(s: &SiteJson) -> Option<String> {
    let word = match s.first_admin_door.as_str() {
        "" => return None,
        "open" => "open",
        "spent" => "spent (the first admin signed in)",
        "expired" => "expired",
        "handed_off" => "handed off to the site",
        other => other,
    };
    Some(if s.first_admin_door_words.is_empty() {
        format!("First-admin door: {word}")
    } else {
        format!("First-admin door: {word} — {}", s.first_admin_door_words)
    })
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SitesJson {
    pub sites: Vec<SiteJson>,
    pub sizes: Vec<String>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct OrderedJson {
    pub reference: String,
    pub checkout_url: String,
    pub price_words: String,
    pub words: String,
}

/// What the form posts: the three fields, and the operator's "lay it".
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OrderBody {
    pub estate: String,
    pub size: String,
    pub email: String,
    /// The seed's PUBLIC key (optional): the kit seeds the site's repositories with its private half.
    pub seed_key: String,
    pub confirm: bool,
}

/// **What a site already has**, as the form shows it: `(field, value, fed)` — the fields an
/// order needs and never asks for.
pub fn fed_rows(s: &SiteJson) -> Vec<(&'static str, String, bool)> {
    vec![
        (
            "Domain",
            format!("{0}, www.{0}, git.{0}", s.domain),
            !s.domain.is_empty(),
        ),
        ("Stripe", s.vendor.clone(), !s.vendor.is_empty()),
        ("DNS", s.dns_words.clone(), s.dns_writes),
        ("Cloud", s.cloud_words.clone(), !s.cloud_words.is_empty()),
        (
            "First admin",
            s.first_admin.clone(),
            !s.first_admin.is_empty(),
        ),
    ]
}

/// The mark beside a field the site already has, or one it lacks.
pub fn fed_mark(fed: bool) -> &'static str {
    if fed {
        "already fed"
    } else {
        "missing"
    }
}

/// **May the button be pressed, and why not**: monetize's own answer first; then the form's
/// own two fields; an admin's alone.
pub fn press(s: &SiteJson, email: &str, admin: bool) -> (bool, String) {
    if !s.orderable {
        return (false, s.why_not.clone());
    }
    if !admin {
        return (false, "an admin orders a site".into());
    }
    if !email.trim().contains('@') {
        return (
            false,
            "the first admin's address: the sign-in link is mailed there".into(),
        );
    }
    (true, String::new())
}

/// The site's name as a person reads it: its domain, else its label.
pub fn site_name(s: &SiteJson) -> &str {
    if s.domain.is_empty() {
        &s.estate
    } else {
        &s.domain
    }
}

/// The button's words.
pub fn button_words(s: &SiteJson) -> String {
    format!("Order {}", site_name(s))
}

/// **The state as a person reads it** — monetize's word, spelled out; a word this page
/// does not know is shown as monetize sent it.
pub fn state_words(state: &str) -> String {
    match state {
        "none" => "not ordered",
        "waiting_for_payment" => "waiting for payment",
        "queued" => "ordered",
        "waiting_for_dns" => "waiting for DNS",
        "stands" => "running",
        "owed" => "setup failed after payment",
        "install_failed" => "install failed",
        "unjoined" => "not joined: its shop cannot answer",
        other => return other.replace('_', " "),
    }
    .to_string()
}

/// Is an order on its way? Then the page reads the list again every [`REREAD_MS`], so the
/// state is seen to move without a reload. Nothing is decided by it.
pub fn in_flight(state: &str) -> bool {
    matches!(
        state,
        "waiting_for_payment"
            | "queued"
            | "waiting_for_dns"
            | "laying"
            | "installing"
            | "terminating"
    )
}

/// **A refusal, in plain words**: the server's own sentence; when it sent none, one of
/// this page's. Never a status code or a transport word.
pub fn fail_words(status: u16, message: &str) -> String {
    let m = message.trim();
    if !m.is_empty() {
        return m.to_string();
    }
    match status {
        0 | 502 | 503 | 504 => "The box did not answer. Try again in a moment.",
        403 => "You are not allowed to do that.",
        _ => "That did not work. Try again in a moment.",
    }
    .to_string()
}

/// Where a site's settle is posted, and what: its order is kept under the site's label.
pub fn settle_post(s: &SiteJson, action: &str) -> (String, serde_json::Value) {
    (
        crate::accounts::settle_api(&s.estate),
        serde_json::json!({ "action": action, "confirm": true }),
    )
}

/// What the terminate posts: the site and its name typed back. No backup is offered at
/// terminate (owner 2026-10-22).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TerminateBody {
    pub estate: String,
    pub confirm: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TerminatedJson {
    pub words: String,
    pub done: bool,
}

/// May Terminate be pressed? Only on a site monetize says may be terminated, by an admin,
/// with the site's name typed back (monetize checks the name again).
pub fn may_terminate(s: &SiteJson, typed: &str, admin: bool) -> bool {
    offers_terminate(s) && admin && !typed.trim().is_empty() && typed.trim() == site_name(s)
}

/// **Is Terminate shown at all**: monetize's word alone (thin UI, F3 2026-10-20). monetize says
/// yes only over something that stands, in a phase its terminate takes — a running site, a
/// stopped install, an owed site whose boxes stand — and never for a protected site.
pub fn offers_terminate(s: &SiteJson) -> bool {
    s.terminable
}

pub fn terminate_body(s: &SiteJson, typed: &str) -> TerminateBody {
    TerminateBody {
        estate: s.estate.clone(),
        confirm: typed.trim().to_string(),
    }
}

/// **May a sold estate's Terminate be pressed** (the operator, on the estate's Guardian page,
/// owner 2026-10-22): by an admin, with the estate's name typed back exactly. monetize checks
/// the name again and refuses a protected site.
pub fn may_terminate_estate(estate: &str, typed: &str, admin: bool) -> bool {
    admin && !typed.trim().is_empty() && typed.trim() == estate.trim()
}

/// The operator's terminate of a sold estate, as `POST /api/sites/terminate` takes it.
pub fn estate_terminate_body(estate: &str, typed: &str) -> TerminateBody {
    TerminateBody {
        estate: estate.trim().to_string(),
        confirm: typed.trim().to_string(),
    }
}

/// The order the form sends.
pub fn order_body(s: &SiteJson, size: &str, email: &str, seed_key: &str) -> OrderBody {
    OrderBody {
        estate: s.estate.clone(),
        size: size.trim().to_string(),
        email: email.trim().to_string(),
        seed_key: seed_key.trim().to_string(),
        confirm: true,
    }
}

/// **Where the operator pays**, while the order waits for it: the words and the vendor's
/// own https page. Nothing else is ever a link.
pub fn pay_link(s: &SiteJson) -> Option<(String, String)> {
    (s.state == "waiting_for_payment" && s.checkout_url.starts_with("https://"))
        .then(|| (format!("Pay {}", s.price_words), s.checkout_url.clone()))
}

/// The state's badge.
pub fn state_tone(state: &str) -> Tone {
    match state {
        "ready" | "stands" => Tone::Pass,
        "failed" | "install_failed" | "owed" | "cancelled" | "deleted" | "terminated"
        | "unjoined" => Tone::Bad,
        "none" => Tone::Plain,
        _ => Tone::Live,
    }
}

// ── THE SITE BOOK (2026-10-14: config in the application) ──────────────────
//
// ```text
// [New site]                          → the form, blank (the box's choices)
// per site: [Edit]                    → the form, filled from the box (never a secret)
// form: label · domain · product · first admin · operator site · terminable
//       Stripe account [held | a new one] · test/live · secret key [password] · webhook secret [password]
//       term days · term months · grace hours · margin bp · media from · (currency, sizes: the shop's)
//       [Add site | Save]  → the box judges it (refused whole, by name) and applies it live
// ```

pub const BOOK_API: &str = "/api/sites/book";
pub const PUT_API: &str = "/api/sites/put";

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TermsJson {
    pub term_days: u32,
    pub term_months: u32,
    pub grace_hours: u32,
    pub margin_bp: u32,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteRecordJson {
    pub estate: String,
    pub domain: String,
    pub product: String,
    pub first_admin: String,
    pub operator_site: bool,
    pub terminable: bool,
    pub vendor: String,
    pub live: bool,
    pub terms: TermsJson,
    pub media_from: String,
    pub source: String,
    pub secret_key_set: bool,
    pub secret_key_fingerprint: String,
    pub webhook_secret_set: bool,
    pub webhook_secret_fingerprint: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteShownJson {
    pub site: SiteRecordJson,
    pub products: Vec<String>,
    pub vendors: Vec<String>,
    pub media_sites: Vec<String>,
    pub currency: String,
    pub sizes: Vec<String>,
    pub history: Vec<String>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SitePutJson {
    pub words: String,
    pub changed: Vec<String>,
}

/// What the form holds while it is filled in — the secrets included, until it is sent.
#[derive(Clone, Default, PartialEq, Eq, Serialize)]
pub struct SitePutBody {
    pub estate: String,
    pub create: bool,
    pub domain: String,
    pub product: String,
    pub first_admin: String,
    pub operator_site: bool,
    pub terminable: bool,
    pub vendor: String,
    pub vendor_secret_key: String,
    pub vendor_webhook_secret: String,
    pub live: bool,
    pub terms: TermsJson,
    pub media_from: String,
}

impl std::fmt::Debug for SitePutBody {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SitePutBody")
            .field("estate", &self.estate)
            .field("create", &self.create)
            .field("domain", &self.domain)
            .field("vendor", &self.vendor)
            .field("secrets", &"(never shown)")
            .finish()
    }
}

/// The book's path for one site (empty: a blank new one).
pub fn book_path(estate: &str) -> String {
    if estate.is_empty() {
        BOOK_API.to_string()
    } else {
        format!("{BOOK_API}?estate={}", crate::fmt::percent_encode(estate))
    }
}

/// **Where this site's buyers order a private estate**: the site's OWN sign-up page,
/// `https://<domain>/signup?plan=private` (owner decision OD6, 2026-10-26: the ONE box sells on
/// no order page of its own; buying happens on each product site's own sign-up). `None` until
/// the site names its domain.
pub fn buyer_order_page(domain: &str) -> Option<String> {
    let domain = domain.trim().trim_end_matches('/');
    (!domain.is_empty()).then(|| format!("https://{domain}/signup?plan=private"))
}

/// The Stripe-account choice that means "a new account of the site's own".
pub const NEW_ACCOUNT: &str = "";

/// Pure: **the form, filled from what the box showed** — an edit starts from the site as it
/// is; a new site from the box's first product, and a new Stripe account of its own. The
/// secrets always start empty: the box never answers one.
pub fn form_of(shown: &SiteShownJson, create: bool) -> SitePutBody {
    let s = &shown.site;
    SitePutBody {
        estate: s.estate.clone(),
        create,
        domain: s.domain.clone(),
        product: if s.product.is_empty() {
            shown.products.first().cloned().unwrap_or_default()
        } else {
            s.product.clone()
        },
        first_admin: s.first_admin.clone(),
        operator_site: s.operator_site,
        terminable: s.terminable,
        vendor: if create {
            NEW_ACCOUNT.to_string()
        } else {
            s.vendor.clone()
        },
        vendor_secret_key: String::new(),
        vendor_webhook_secret: String::new(),
        live: s.live,
        terms: s.terms.clone(),
        media_from: s.media_from.clone(),
    }
}

/// Pure: **the Stripe-account choices** — a new account of the site's own (a new site only),
/// then every account the box holds.
pub fn vendor_choices(shown: &SiteShownJson, create: bool, estate: &str) -> Vec<(String, String)> {
    let mut v = Vec::new();
    if create {
        let own = if estate.trim().is_empty() {
            "a new account of its own (stripe/<name>)".to_string()
        } else {
            format!("a new account: stripe/{}", estate.trim())
        };
        v.push((NEW_ACCOUNT.to_string(), own));
    }
    v.extend(shown.vendors.iter().map(|x| (x.clone(), x.clone())));
    v
}

/// Pure: **a secret as the form shows it** — set or not, and a fingerprint at most.
pub fn secret_words(set: bool, fingerprint: &str) -> String {
    match (set, fingerprint.is_empty()) {
        (true, false) => format!("set ({fingerprint})"),
        (true, true) => "set".to_string(),
        (false, _) => "not set".to_string(),
    }
}

/// Pure: **may the form be sent, and why not** — what the form itself can see; the box judges
/// the rest (refused whole, by name).
pub fn may_put(b: &SitePutBody, admin: bool) -> (bool, String) {
    if !admin {
        return (false, "an admin adds and edits a site".into());
    }
    if b.create && !label_ok(b.estate.trim()) {
        return (
            false,
            "the site's name: lowercase letters, digits and hyphens".into(),
        );
    }
    if !b.domain.trim().contains('.') {
        return (false, "the site's domain".into());
    }
    if b.first_admin.trim().is_empty() {
        return (false, "the site's first admin".into());
    }
    if b.create
        && b.vendor == NEW_ACCOUNT
        && (b.vendor_secret_key.trim().is_empty() || b.vendor_webhook_secret.trim().is_empty())
    {
        return (
            false,
            "a new Stripe account needs its secret key and its webhook secret".into(),
        );
    }
    let key = b.vendor_secret_key.trim();
    if !key.is_empty() && key.contains("_live_") != b.live {
        return (
            false,
            if b.live {
                "a live site takes a live key".into()
            } else {
                "a test site takes a test key".into()
            },
        );
    }
    (true, String::new())
}

/// Pure: **a site's label**, the box's own rule (`monetize-admin` `app_config::label_ok`):
/// 1–63 lowercase letters, digits and hyphens, not starting or ending with a hyphen.
pub fn label_ok(l: &str) -> bool {
    !l.is_empty()
        && l.len() <= 63
        && !l.starts_with('-')
        && !l.ends_with('-')
        && l.bytes()
            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}

/// Pure: **the label a typed name could be** (owner 2026-11-03: `holger.rs` → `holger-rs`):
/// lowercased, every other character a hyphen, hyphens never doubled nor at an end.
pub fn label_from(typed: &str) -> String {
    let mut out = String::new();
    for c in typed.trim().chars().flat_map(char::to_lowercase) {
        let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
            c
        } else {
            '-'
        };
        if c == '-' && (out.is_empty() || out.ends_with('-')) {
            continue;
        }
        out.push(c);
    }
    let mut out = out.trim_end_matches('-').to_string();
    out.truncate(63);
    out.trim_end_matches('-').to_string()
}

/// Pure: **the label's warning as it is typed** — none for an empty or a good label; else
/// the rule and the label it could be.
pub fn label_hint(typed: &str) -> Option<String> {
    let t = typed.trim();
    if t.is_empty() || label_ok(t) {
        return None;
    }
    let could = label_from(t);
    Some(if could.is_empty() {
        "lowercase letters, digits and hyphens".to_string()
    } else {
        format!("lowercase letters, digits and hyphens — e.g. {could}")
    })
}

/// Pure: **the label after the domain changed** (a new site only): it follows the domain while
/// it is empty or still the one the old domain gave; a label typed by hand is kept.
pub fn label_follows(label: &str, old_domain: &str, new_domain: &str) -> Option<String> {
    let l = label.trim();
    (l.is_empty() || l == label_from(old_domain)).then(|| label_from(new_domain))
}

/// The button's words.
pub fn put_words(b: &SitePutBody) -> &'static str {
    if b.create {
        "Add site"
    } else {
        "Save"
    }
}

/// Pure: a term field as typed (empty or not a number: 0, the shop's).
pub fn term_of(typed: &str) -> u32 {
    typed.trim().parse().unwrap_or(0)
}

// ── the update of a site this box laid (owner 2026-10-14) ───────────────────

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateStepJson {
    pub role: String,
    pub state: String,
    pub how: String,
    pub words: String,
}

/// monetize's word on a site's update: the release sold now, what each box runs, live on or
/// off, the open (else last) update.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateJson {
    pub host: String,
    pub release: String,
    pub release_of: std::collections::BTreeMap<String, String>,
    pub live_on: bool,
    pub state: String,
    pub steps: Vec<UpdateStepJson>,
    pub update_release: String,
    pub words: String,
    /// Every box runs the release sold now.
    pub up_to_date: bool,
    /// The button: a newer release that carries a way to update every box.
    pub offered: bool,
    /// Why there is no button, in monetize's plain words.
    pub why_not: String,
    /// The release sold now, named for a person; empty: no note.
    pub release_name: String,
    pub boxes: Vec<UpdateBoxJson>,
}

/// One box's line, as monetize said it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateBoxJson {
    pub role: String,
    /// `current` | `behind` | `not-reported`
    pub state: String,
    pub words: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct UpdateBody {
    pub estate: String,
    pub release: String,
    pub confirm: bool,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct LiveBody {
    pub estate: String,
    pub on: bool,
}

/// Is the update shown for this site? Only one this box laid and that is up.
pub fn offers_update(s: &SiteJson) -> bool {
    s.state == "ready"
}

/// A release id as a person reads it: its first twelve characters.
pub fn short_release(r: &str) -> &str {
    &r[..r.len().min(12)]
}

/// **The release, for a person**: its name (the kit's note: label · date · commits), else
/// "release <short id>"; and the short id, shown small.
pub fn release_words(u: &UpdateJson) -> (String, String) {
    let short = short_release(&u.release).to_string();
    let name = if u.release_name.trim().is_empty() {
        format!("release {short}")
    } else {
        u.release_name.trim().to_string()
    };
    (name, short)
}

/// **The update's head line** — up to date (and on which release), or why there is nothing to
/// update; `None` while the button is the answer (a newer release that can be taken) or an
/// update runs.
pub fn update_head(u: &UpdateJson) -> Option<(Tone, String)> {
    if update_in_flight(u) {
        return None;
    }
    if u.up_to_date {
        return Some((Tone::Pass, format!("Up to date · {}", release_words(u).0)));
    }
    if u.offered {
        return None;
    }
    let why = if u.why_not.trim().is_empty() {
        "No newer release with update media is published yet.".to_string()
    } else {
        u.why_not.trim().to_string()
    };
    Some((Tone::Plain, why))
}

/// Each box's line: `(role, monetize's words, tone)` — every role has one.
pub fn box_lines(u: &UpdateJson) -> Vec<(String, String, Tone)> {
    u.boxes
        .iter()
        .map(|b| {
            let tone = match b.state.as_str() {
                "current" => Tone::Pass,
                "behind" => Tone::Warn,
                _ => Tone::Plain,
            };
            (b.role.clone(), b.words.clone(), tone)
        })
        .collect()
}

/// **A site this box did not lay** (kit-installed, its boxes report in): it is updated here only
/// once adopted — said, with no button.
pub fn not_managed(s: &SiteJson) -> Option<&'static str> {
    (s.state == "stands").then_some("Not managed by this box yet — adopt it to update it here")
}

/// **What the site has, in one line**: "Configured" when every field an order needs is fed
/// (the details are on Edit), else what is missing.
pub fn configured(s: &SiteJson) -> (bool, String) {
    let missing: Vec<&str> = fed_rows(s)
        .into_iter()
        .filter(|r| !r.2)
        .map(|r| r.0)
        .collect();
    if missing.is_empty() {
        (true, "Configured".to_string())
    } else {
        (false, format!("Missing: {}", missing.join(", ")))
    }
}

/// **The ordering UI** (the form, or why it cannot be ordered) is for a site not ordered yet; a
/// site on its way, running or settled shows its state instead.
pub fn shows_order(s: &SiteJson) -> bool {
    s.state == "none"
}

/// **The site's line**: a running site says where it runs; else monetize's words.
pub fn site_words(s: &SiteJson) -> String {
    if matches!(s.state.as_str(), "ready" | "stands") && !s.domain.is_empty() {
        format!("Running at https://{}", s.domain)
    } else {
        s.words.clone()
    }
}

/// **"Update to <release>"** — offered only when monetize says a newer release can be taken
/// (and while one runs, disarmed): `(may press, words, why not)`; `None`: no button.
pub fn update_button(u: &UpdateJson, admin: bool) -> Option<(bool, String, String)> {
    if !u.offered && !update_in_flight(u) {
        return None;
    }
    let words = format!("Update to {}", release_words(u).0);
    if update_in_flight(u) {
        return Some((false, words, "an update is running".into()));
    }
    if !admin {
        return Some((false, words, "an admin updates a site".into()));
    }
    Some((true, words, String::new()))
}

/// The update the button sends: the release the page SHOWED (monetize refuses a stale one).
pub fn update_body(estate: &str, u: &UpdateJson) -> UpdateBody {
    UpdateBody {
        estate: estate.to_string(),
        release: u.release.clone(),
        confirm: true,
    }
}

/// The live-update switch: what pressing it sends.
pub fn live_body(estate: &str, u: &UpdateJson) -> LiveBody {
    LiveBody {
        estate: estate.to_string(),
        on: !u.live_on,
    }
}

/// The switch: its label, whether it is on, and one line of what it means.
pub fn live_words(u: &UpdateJson) -> (&'static str, bool, &'static str) {
    ("Live updates", u.live_on, "On: an update stages the new app live where it can. Off = every update reinstalls the box from its keep medium, /data kept.")
}

/// **The update's steps, as the page lists them** (owner 2026-10-17: each box's step shown):
/// `(role, the line for it, failed)` — the open update's, else the last one's; none before any.
pub fn step_lines(u: &UpdateJson) -> Vec<(String, String, bool)> {
    u.steps
        .iter()
        .map(|s| {
            let mut line = s.state.clone();
            if !s.how.is_empty() {
                line.push_str(&format!(" ({})", s.how));
            }
            if !s.words.is_empty() {
                line.push_str(&format!(" — {}", s.words));
            }
            (s.role.clone(), line, s.state == "failed")
        })
        .collect()
}

/// Whether the update (open or last) stopped on a failure — the page says it as an alert.
pub fn update_failed(u: &UpdateJson) -> bool {
    u.state == "failed" || u.steps.iter().any(|s| s.state == "failed")
}

/// While an update runs the page reads it again.
pub fn update_in_flight(u: &UpdateJson) -> bool {
    matches!(u.state.as_str(), "running" | "front-keep")
}

// ── A PRODUCT'S OWN ORDERS (MONETIZE-FIX 2026-11-01) ───────────────────────────
//
// Each kind the site's product declares in its facts is a row: its words, a field per param,
// one button. The box PULLS the order; its answer is the box's ack, read again until it came.
// A kind that carries a file is the CLI's (`monetize site do <site> <kind> --file …`).

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindParamJson {
    pub name: String,
    pub words: String,
    pub required: bool,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindJson {
    pub kind: String,
    pub role: String,
    pub words: String,
    pub params: Vec<KindParamJson>,
    pub file: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindsJson {
    pub kinds: Vec<KindJson>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DoBody {
    pub estate: String,
    pub kind: String,
    pub params: std::collections::BTreeMap<String, String>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct DoneJson {
    pub words: String,
    pub seq: u64,
    pub role: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct DoAnswerJson {
    pub kind: String,
    pub answered: bool,
    pub ok: bool,
    pub detail: String,
}

/// Pure: **the kind's button**: `(may press, why not)` — an admin's, every required param typed,
/// and never a kind that carries a file (the CLI's).
pub fn may_do(
    k: &KindJson,
    typed: &std::collections::BTreeMap<String, String>,
    admin: bool,
) -> (bool, String) {
    if !k.file.is_empty() {
        return (
            false,
            format!(
                "this order carries a file: `monetize site do <site> {} --file {}=<path>`",
                k.kind, k.file
            ),
        );
    }
    if !admin {
        return (false, "an admin's".into());
    }
    match k
        .params
        .iter()
        .find(|p| p.required && typed.get(&p.name).is_none_or(|v| v.trim().is_empty()))
    {
        Some(p) => (false, format!("{} is needed", p.name)),
        None => (true, String::new()),
    }
}

/// Pure: **what is sent**: the typed params the kind declares, trimmed, empty ones left out.
pub fn do_body(
    estate: &str,
    k: &KindJson,
    typed: &std::collections::BTreeMap<String, String>,
) -> DoBody {
    let params = k
        .params
        .iter()
        .filter_map(|p| {
            typed
                .get(&p.name)
                .map(|v| (p.name.clone(), v.trim().to_string()))
        })
        .filter(|(_, v)| !v.is_empty())
        .collect();
    DoBody {
        estate: estate.to_string(),
        kind: k.kind.clone(),
        params,
    }
}

/// Pure: the answer path of order `seq` on `estate`.
pub fn answer_path(estate: &str, seq: u64) -> String {
    format!("{DO_API}?estate={estate}&seq={seq}")
}

/// Pure: **the box's answer, as the page says it**: waiting, its answer, or its refusal.
pub fn answer_words(a: &DoAnswerJson) -> (Tone, String) {
    match (a.answered, a.ok) {
        (false, _) => (
            Tone::Warn,
            format!("{}: queued; the box has not pulled it yet", a.kind),
        ),
        (true, true) => (Tone::Pass, a.detail.clone()),
        (true, false) => (Tone::Bad, format!("refused by the box: {}", a.detail)),
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn a_label_is_the_boxs_rule_and_a_domain_gives_one() {
        use super::*;
        assert!(label_ok("holger-rs") && label_ok("test5-acme-example") && label_ok("a1"));
        for bad in [
            "",
            "holger.rs",
            "Holger-rs",
            "-a",
            "a-",
            "a b",
            &"a".repeat(64),
        ] {
            assert!(!label_ok(bad), "{bad:?}");
        }
        assert_eq!(label_from("holger.rs"), "holger-rs");
        assert_eq!(label_from(" Test5.Acme..Example. "), "test5-acme-example");
        assert_eq!(label_from("..."), "");
        assert!(label_ok(&label_from(&"x.".repeat(60))));
        assert_eq!(label_hint(""), None);
        assert_eq!(label_hint("holger-rs"), None);
        assert_eq!(
            label_hint("holger.rs").as_deref(),
            Some("lowercase letters, digits and hyphens — e.g. holger-rs")
        );
        // The label follows the domain while it is empty or the old domain's; typed by hand, kept.
        assert_eq!(label_follows("", "", "h").as_deref(), Some("h"));
        assert_eq!(
            label_follows("holger-r", "holger.r", "holger.rs").as_deref(),
            Some("holger-rs")
        );
        assert_eq!(label_follows("mine", "holger.r", "holger.rs"), None);
    }

    #[test]
    fn a_new_site_with_a_label_that_is_not_one_is_not_sent() {
        use super::*;
        let mut b = SitePutBody {
            create: true,
            estate: "holger.rs".into(),
            domain: "holger.rs".into(),
            first_admin: "ops".into(),
            vendor: "stripe/x".into(),
            ..Default::default()
        };
        let (may, why) = may_put(&b, true);
        assert!(
            !may && why.contains("lowercase letters, digits and hyphens"),
            "{why}"
        );
        b.estate = "holger-rs".into();
        assert_eq!(may_put(&b, true), (true, String::new()));
    }

    /// ★ MONETIZE-FIX: a declared kind's button is an admin's with every required param typed;
    /// a kind with a file is the CLI's; what is sent is the declared params, trimmed.
    #[test]
    fn a_declared_order_kind_is_a_button_with_its_params() {
        let k = KindJson {
            kind: "lab_join".into(),
            params: vec![
                KindParamJson {
                    name: "name".into(),
                    required: true,
                    ..Default::default()
                },
                KindParamJson {
                    name: "key".into(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        };
        let typed = |kv: &[(&str, &str)]| {
            kv.iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect::<std::collections::BTreeMap<_, _>>()
        };
        assert_eq!(
            may_do(&k, &typed(&[("name", "lab-1")]), true),
            (true, String::new())
        );
        assert!(!may_do(&k, &typed(&[("name", " ")]), true).0);
        assert!(!may_do(&k, &typed(&[("name", "lab-1")]), false).0);
        let file = KindJson {
            file: "bundle".into(),
            ..k.clone()
        };
        assert!(may_do(&file, &typed(&[("name", "x")]), true)
            .1
            .contains("--file bundle="));
        let b = do_body(
            "holger-rs",
            &k,
            &typed(&[("name", " lab-1 "), ("key", ""), ("other", "x")]),
        );
        assert_eq!(b.params, typed(&[("name", "lab-1")]));
        assert_eq!(
            answer_path("holger-rs", 7),
            "/api/sites/do?estate=holger-rs&seq=7"
        );
        assert_eq!(
            answer_words(&DoAnswerJson {
                kind: "k".into(),
                answered: true,
                ok: false,
                detail: "no".into()
            })
            .0,
            Tone::Bad
        );
    }

    /// ★ **The operator's Terminate on a sold estate** (owner 2026-10-22): an admin, the
    /// estate's name typed back exactly; the body carries the name, and no backup word.
    #[test]
    fn a_sold_estates_terminate_needs_an_admin_and_its_name_typed_back() {
        use super::*;
        assert!(may_terminate_estate("polka", " polka ", true));
        assert!(
            !may_terminate_estate("polka", "polk", true)
                && !may_terminate_estate("polka", "", true)
        );
        assert!(
            !may_terminate_estate("polka", "polka", false),
            "an admin's alone"
        );
        assert_eq!(
            estate_terminate_body("polka", " polka "),
            TerminateBody {
                estate: "polka".into(),
                confirm: "polka".into()
            }
        );
        assert_eq!(
            serde_json::to_value(estate_terminate_body("polka", "polka")).unwrap(),
            serde_json::json!({ "estate": "polka", "confirm": "polka" }),
            "no backup is asked at terminate"
        );
    }

    use super::buyer_order_page;

    /// ★ OD6: a site's buyers order a private estate on the site's own sign-up page, never on a
    /// page of the ONE shop's.
    #[test]
    fn a_sites_buyers_order_on_the_sites_own_sign_up_page() {
        assert_eq!(
            buyer_order_page("test3.holger.rs").as_deref(),
            Some("https://test3.holger.rs/signup?plan=private")
        );
        assert_eq!(buyer_order_page(" "), None);
        assert!(!buyer_order_page("holger.rs").unwrap().contains("/shop/"));
    }

    use super::*;

    /// ★ Each box's step is listed (front first when the box chose it), and a failure is said.
    #[test]
    fn the_update_lists_each_box_and_says_a_failure() {
        let step = |role: &str, state: &str, how: &str, words: &str| UpdateStepJson {
            role: role.into(),
            state: state.into(),
            how: how.into(),
            words: words.into(),
        };
        let mut u = UpdateJson {
            state: "running".into(),
            steps: vec![
                step("front", "done", "keep", "booted from the front keep medium"),
                step("twin", "waiting", "", ""),
                step("main", "waiting", "", ""),
            ],
            ..UpdateJson::default()
        };
        let l = step_lines(&u);
        assert_eq!(
            l[0],
            (
                "front".to_string(),
                "done (keep) — booted from the front keep medium".to_string(),
                false
            )
        );
        assert_eq!((l[1].0.as_str(), l[1].1.as_str()), ("twin", "waiting"));
        assert!(!update_failed(&u));
        u.state = "failed".into();
        u.steps[1] = step("twin", "failed", "", "the stage was not confirmed");
        assert!(update_failed(&u) && step_lines(&u)[1].2);
        assert!(
            step_lines(&UpdateJson::default()).is_empty(),
            "no update yet: no steps"
        );
    }

    fn shown() -> SiteShownJson {
        SiteShownJson {
            site: SiteRecordJson {
                estate: "test3-gunnar-rs".into(),
                domain: "test3.gunnar.rs".into(),
                product: "gunnar".into(),
                first_admin: "ignalina".into(),
                operator_site: true,
                terminable: true,
                vendor: "stripe/gunnar-test2".into(),
                terms: TermsJson {
                    term_days: 1,
                    grace_hours: 1,
                    margin_bp: 2500,
                    term_months: 0,
                },
                source: "fed".into(),
                secret_key_set: true,
                secret_key_fingerprint: "sha256:0123abcd".into(),
                ..Default::default()
            },
            products: vec!["gunnar".into()],
            vendors: vec!["stripe/gunnar".into(), "stripe/gunnar-test2".into()],
            media_sites: vec!["test2-gunnar-rs".into()],
            currency: "EUR".into(),
            sizes: vec!["Mini".into()],
            history: vec![],
        }
    }

    /// ★ **An edit starts from the site as the box has it, never with a secret**; a new site
    /// starts on a new Stripe account of its own; the choices are the box's.
    #[test]
    fn the_form_starts_from_the_box_and_never_holds_a_secret_it_was_not_typed() {
        let e = form_of(&shown(), false);
        assert_eq!(
            (
                e.estate.as_str(),
                e.domain.as_str(),
                e.vendor.as_str(),
                e.terms.term_days,
                e.create
            ),
            (
                "test3-gunnar-rs",
                "test3.gunnar.rs",
                "stripe/gunnar-test2",
                1,
                false
            )
        );
        assert!(e.vendor_secret_key.is_empty() && e.vendor_webhook_secret.is_empty());
        let n = form_of(
            &SiteShownJson {
                site: SiteRecordJson::default(),
                ..shown()
            },
            true,
        );
        assert_eq!(
            (n.product.as_str(), n.vendor.as_str(), n.create),
            ("gunnar", NEW_ACCOUNT, true)
        );
        let c = vendor_choices(&shown(), true, "test5-gunnar-rs");
        assert_eq!(
            c[0],
            (
                NEW_ACCOUNT.to_string(),
                "a new account: stripe/test5-gunnar-rs".to_string()
            )
        );
        assert_eq!(c.len(), 3);
        assert_eq!(
            vendor_choices(&shown(), false, "x").len(),
            2,
            "an edit picks a held account"
        );
        assert_eq!(
            secret_words(true, "sha256:0123abcd"),
            "set (sha256:0123abcd)"
        );
        assert_eq!(secret_words(false, ""), "not set");
        assert_eq!(
            book_path("test3-gunnar-rs"),
            "/api/sites/book?estate=test3-gunnar-rs"
        );
        assert_eq!(book_path(""), "/api/sites/book");
        let d = format!(
            "{:?}",
            SitePutBody {
                vendor_secret_key: "sk_test_SECRET".into(),
                ..e
            }
        );
        assert!(!d.contains("SECRET"), "{d}");
    }

    /// ★ The form says what it lacks before the box is asked: an admin, a name, a domain, a
    /// first admin, a new account's two secrets, a key of the site's mode.
    #[test]
    fn the_form_says_what_it_lacks() {
        let mut b = form_of(
            &SiteShownJson {
                site: SiteRecordJson::default(),
                ..shown()
            },
            true,
        );
        assert!(!may_put(&b, false).0);
        assert!(may_put(&b, true).1.contains("name"));
        b.estate = "test5-gunnar-rs".into();
        assert!(may_put(&b, true).1.contains("domain"));
        b.domain = "test5.gunnar.rs".into();
        assert!(may_put(&b, true).1.contains("first admin"));
        b.first_admin = "ignalina".into();
        assert!(may_put(&b, true).1.contains("secret key"));
        b.vendor_secret_key = "sk_live_x".into();
        b.vendor_webhook_secret = "whsec_x".into();
        assert_eq!(may_put(&b, true).1, "a test site takes a test key");
        b.vendor_secret_key = "sk_test_x".into();
        assert_eq!(may_put(&b, true), (true, String::new()));
        assert_eq!(put_words(&b), "Add site");
        let e = form_of(&shown(), false);
        assert_eq!(
            may_put(&e, true),
            (true, String::new()),
            "an edit keeps the held secrets"
        );
        assert_eq!(put_words(&e), "Save");
        assert_eq!((term_of(" 7 "), term_of(""), term_of("x")), (7, 0, 0));
    }

    fn site() -> SiteJson {
        SiteJson {
            estate: "gunnar-rs".into(),
            domain: "gunnar.rs".into(),
            vendor: "stripe/gunnar".into(),
            first_admin: "ignalina".into(),
            dns_words: "this box's one DNS token writes its zone".into(),
            dns_writes: true,
            cloud_words: "upcloud · se-sto1".into(),
            state: "none".into(),
            words: "Not laid by this box.".into(),
            orderable: true,
            ..Default::default()
        }
    }

    /// ★ A declared site's form asks its size and its first admin's address — the rest reads
    /// "already fed": the domain, its own Stripe, the box's one DNS token and one cloud.
    #[test]
    fn a_declared_sites_form_shows_what_is_already_fed_and_asks_only_size_and_address() {
        let rows = fed_rows(&site());
        assert_eq!(
            rows.iter().map(|r| r.0).collect::<Vec<_>>(),
            vec!["Domain", "Stripe", "DNS", "Cloud", "First admin"]
        );
        assert!(rows.iter().all(|r| r.2), "{rows:?}");
        assert_eq!(rows[0].1, "gunnar.rs, www.gunnar.rs, git.gunnar.rs");
        assert_eq!(
            (fed_mark(true), fed_mark(false)),
            ("already fed", "missing")
        );
        let b = order_body(&site(), " Mini ", " ops@example.invalid ", "");
        assert_eq!(
            (
                b.estate.as_str(),
                b.size.as_str(),
                b.email.as_str(),
                b.confirm
            ),
            ("gunnar-rs", "Mini", "ops@example.invalid", true)
        );
        let no_dns = SiteJson {
            dns_writes: false,
            dns_words: "this box's DNS token does not write its zone".into(),
            ..site()
        };
        assert!(
            !fed_rows(&no_dns)[2].2,
            "a zone the box cannot write is said, not hidden"
        );
    }

    /// ★ The button is monetize's to allow: its own "why not" first, then an admin, then the
    /// address; and the only link is the vendor's page while the order waits for payment.
    #[test]
    fn the_button_is_monetizes_to_allow_and_the_only_link_is_the_vendors_pay_page() {
        assert_eq!(
            press(&site(), "ops@example.invalid", true),
            (true, String::new())
        );
        assert!(!press(&site(), "ops@example.invalid", false).0);
        assert!(!press(&site(), "nobody", true).0);
        let stands = SiteJson {
            orderable: false,
            why_not: "it stands: a site that runs is not laid over".into(),
            state: "stands".into(),
            ..site()
        };
        assert_eq!(
            press(&stands, "ops@example.invalid", true),
            (
                false,
                "it stands: a site that runs is not laid over".to_string()
            )
        );
        assert_eq!(button_words(&site()), "Order gunnar.rs");
        assert_eq!(pay_link(&site()), None);
        // ★ A33-6: the price is the server's word, never the page's.
        let waiting = SiteJson {
            state: "waiting_for_payment".into(),
            checkout_url: "https://checkout.example/x".into(),
            price_words: "1.25 EUR".into(),
            ..site()
        };
        assert_eq!(
            pay_link(&waiting),
            Some((
                "Pay 1.25 EUR".to_string(),
                "https://checkout.example/x".to_string()
            ))
        );
        assert_eq!(
            pay_link(&SiteJson {
                checkout_url: "javascript:alert(1)".into(),
                ..waiting
            }),
            None
        );
        assert_eq!(state_tone("ready"), Tone::Pass);
        assert_eq!(state_tone("laying"), Tone::Live);
    }

    /// ★ The state reads as words and moves on its own; a refusal is the server's sentence,
    /// never a code; Terminate needs monetize's yes and the site's name typed.
    #[test]
    fn the_state_reads_plainly_a_refusal_is_a_sentence_and_terminate_needs_the_name() {
        assert_eq!(
            [
                "none",
                "waiting_for_payment",
                "queued",
                "laying",
                "installing",
                "ready",
                "stands"
            ]
            .map(state_words),
            [
                "not ordered",
                "waiting for payment",
                "ordered",
                "laying",
                "installing",
                "ready",
                "running"
            ]
        );
        assert!(
            in_flight("queued")
                && in_flight("laying")
                && in_flight("installing")
                && in_flight("waiting_for_payment")
        );
        assert!(
            !in_flight("ready")
                && !in_flight("none")
                && !in_flight("stands")
                && !in_flight("failed")
        );
        assert_eq!(
            fail_words(400, "gunnar.rs already runs: nothing was ordered"),
            "gunnar.rs already runs: nothing was ordered"
        );
        assert_eq!(
            fail_words(503, ""),
            "The box did not answer. Try again in a moment."
        );
        let old = SiteJson {
            terminable: true,
            state: "ready".into(),
            ..site()
        };
        assert!(may_terminate(&old, " gunnar.rs ", true));
        assert!(!may_terminate(&old, "gunnar", true) && !may_terminate(&old, "gunnar.rs", false));
        assert!(
            !may_terminate(&site(), "gunnar.rs", true),
            "a protected site offers no terminate"
        );
        assert!(
            !offers_terminate(&SiteJson {
                terminable: false,
                state: "owed".into(),
                ..site()
            }),
            "an owed site of which nothing stands: monetize says no"
        );
        assert!(offers_terminate(&SiteJson {
            terminable: true,
            state: "stands".into(),
            ..site()
        }));
        assert_eq!(state_words("owed"), "setup failed after payment");
        assert_eq!(state_tone("owed"), Tone::Bad);
        assert!(!in_flight("owed"));
        // M-FOLLOW 1: a site whose shop cannot answer is never shown as a ready one.
        assert_eq!(
            (state_words("unjoined").as_str(), state_tone("unjoined")),
            ("not joined: its shop cannot answer", Tone::Bad)
        );
        assert!(!in_flight("unjoined"));
        assert_eq!(
            settle_post(&site(), "abandon").1,
            serde_json::json!({ "action": "abandon", "confirm": true })
        );
        assert_eq!(
            (
                state_words("install_failed").as_str(),
                state_tone("install_failed")
            ),
            ("install failed", Tone::Bad)
        );
        assert!(
            offers_terminate(&SiteJson {
                terminable: true,
                state: "install_failed".into(),
                ..site()
            }),
            "a stopped install's boxes are terminated"
        );
        assert!(
            !offers_terminate(&SiteJson {
                terminable: false,
                state: "install_failed".into(),
                ..site()
            }),
            "unless protected"
        );
        assert_eq!(
            settle_post(&site(), "refund"),
            (
                "/api/estate-orders/gunnar-rs/settle".to_string(),
                serde_json::json!({ "action": "refund", "confirm": true })
            )
        );
        assert_eq!(
            terminate_body(&old, " gunnar.rs "),
            TerminateBody {
                estate: "gunnar-rs".into(),
                confirm: "gunnar.rs".into()
            }
        );
    }

    /// ★ **F2/F5, A32-29: a site's settles are the server's** (monetize-ui-server
    /// `site_order::site_settles`): the page reads them, words and all, off the site's JSON and
    /// keeps no rule of its own on the state.
    #[test]
    fn a_sites_settles_are_read_off_the_servers_answer() {
        let j = serde_json::json!({"estate": "gunnar-rs", "state": "install_failed", "recheckable": true, "actions": [
            {"verb": "recheck", "label": "Check again", "confirm": "Confirm: check every box again"},
            {"verb": "retry", "label": "Set up again", "confirm": "Confirm: set it up again"}]});
        let s: SiteJson = serde_json::from_value(j).expect("a site");
        assert_eq!(
            s.actions
                .iter()
                .map(|a| (a.verb.as_str(), a.label.as_str(), a.confirm.as_str()))
                .collect::<Vec<_>>(),
            [
                ("recheck", "Check again", "Confirm: check every box again"),
                ("retry", "Set up again", "Confirm: set it up again")
            ]
        );
        let none: SiteJson =
            serde_json::from_value(serde_json::json!({"estate": "x", "state": "owed"}))
                .expect("an older server");
        assert!(
            none.actions.is_empty(),
            "an older server sends none: the page draws none"
        );
        assert_eq!(
            settle_post(&s, "recheck").1,
            serde_json::json!({ "action": "recheck", "confirm": true })
        );
    }

    /// ★ **F3: Terminate is monetize's word alone** (live 2026-10-20: an owed site whose boxes
    /// stood showed no Terminate): an owed site monetize calls terminable offers it, behind the
    /// same guard (an admin, the name typed back); the page keeps no rule of its own on the state.
    #[test]
    fn an_owed_site_with_machines_offers_terminate_because_monetize_says_so() {
        let owed = SiteJson { terminable: true, state: "owed".into(), words: "The setup failed after payment; the operator can retry or refund. Last failure: x Terminate removes its boxes.".into(), ..site() };
        assert!(
            offers_terminate(&owed),
            "monetize says its boxes can be removed"
        );
        assert!(may_terminate(&owed, " gunnar.rs ", true));
        assert!(
            !may_terminate(&owed, "gunnar", true) && !may_terminate(&owed, "gunnar.rs", false),
            "the guard stands"
        );
        for st in [
            "none",
            "waiting_for_payment",
            "owed",
            "failed",
            "install_failed",
            "installing",
            "ready",
            "stands",
            "terminating",
        ] {
            assert!(
                offers_terminate(&SiteJson {
                    terminable: true,
                    state: st.into(),
                    ..site()
                }),
                "{st}: monetize's yes is the answer"
            );
            assert!(
                !offers_terminate(&SiteJson {
                    terminable: false,
                    state: st.into(),
                    ..site()
                }),
                "{st}: monetize's no is the answer"
            );
        }
    }

    fn boxes(states: &[(&str, &str, &str)]) -> Vec<UpdateBoxJson> {
        states
            .iter()
            .map(|(r, st, w)| UpdateBoxJson {
                role: r.to_string(),
                state: st.to_string(),
                words: w.to_string(),
            })
            .collect()
    }

    /// ★ **Never an update to the release a site runs** (owner 2026-10-17, test2 live): up to
    /// date says so and names the release; the button only for a newer release monetize says
    /// can be taken, named for a person; when not, monetize's plain reason; each box its line.
    #[test]
    fn an_up_to_date_site_is_said_so_and_the_button_is_only_for_a_newer_release_that_can_be_taken()
    {
        let current = UpdateJson {
            release: "27478f3a499ff8c5".into(),
            up_to_date: true,
            release_name: "r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".into(),
            live_on: true,
            boxes: boxes(&[
                ("twin", "current", "current · release 27478f3a499f"),
                ("main", "current", "current · release 27478f3a499f"),
                ("front", "current", "current · release 38a4a08277d7"),
            ]),
            ..Default::default()
        };
        assert_eq!(
            update_head(&current),
            Some((
                Tone::Pass,
                "Up to date · r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".to_string()
            ))
        );
        assert_eq!(
            update_button(&current, true),
            None,
            "no update to the release it runs"
        );
        assert_eq!(
            release_words(&current).1,
            "27478f3a499f",
            "the hash only as the small id"
        );
        assert_eq!(
            release_words(&UpdateJson {
                release_name: String::new(),
                ..current.clone()
            })
            .0,
            "release 27478f3a499f",
            "no note: the short id"
        );
        assert_eq!(
            box_lines(&current).iter().map(|b| b.2).collect::<Vec<_>>(),
            vec![Tone::Pass, Tone::Pass, Tone::Pass],
            "every role has its state"
        );
        let stuck = UpdateJson { up_to_date: false, offered: false, why_not: "No newer release with update media is published yet: release 27478f3a499f carries neither an in-place upgrade nor a live update for test2.gunnar.rs.".into(), ..current.clone() };
        assert_eq!(update_button(&stuck, true), None);
        let (_, why) = update_head(&stuck).unwrap();
        assert!(
            why.starts_with("No newer release with update media is published yet")
                && !why.contains('`'),
            "{why}"
        );
        let newer = UpdateJson {
            up_to_date: false,
            offered: true,
            why_not: String::new(),
            ..current.clone()
        };
        assert_eq!(update_head(&newer), None, "the button is the answer");
        assert_eq!(
            update_button(&newer, true),
            Some((
                true,
                "Update to r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".to_string(),
                String::new()
            ))
        );
        assert!(!update_button(&newer, false).unwrap().0, "an admin's");
        let running = UpdateJson {
            state: "running".into(),
            ..newer.clone()
        };
        assert!(!update_button(&running, true).unwrap().0 && update_head(&running).is_none());
        let s = SiteJson {
            estate: "test2-gunnar-rs".into(),
            state: "ready".into(),
            ..Default::default()
        };
        assert_eq!(
            update_body(&s.estate, &newer),
            UpdateBody {
                estate: "test2-gunnar-rs".into(),
                release: "27478f3a499ff8c5".into(),
                confirm: true
            }
        );
        let unreported = UpdateJson { boxes: boxes(&[("front", "not-reported", "not reported yet — the front has no update taker yet; the update starts with the front")]), ..current };
        assert_eq!(box_lines(&unreported)[0], ("front".to_string(), "not reported yet — the front has no update taker yet; the update starts with the front".to_string(), Tone::Plain));
    }

    /// ★ **A site reads in one glance** (owner 2026-10-17): one "Configured" line, not "already
    /// fed" on every field; the ordering UI only for a site not ordered yet; a running site says
    /// where it runs; a kit-installed site says it is not managed here until adopted; the live
    /// switch says what off means.
    #[test]
    fn a_site_reads_configured_running_or_not_managed_and_the_switch_says_what_off_means() {
        assert_eq!(configured(&site()), (true, "Configured".to_string()));
        assert_eq!(
            configured(&SiteJson {
                dns_writes: false,
                ..site()
            }),
            (false, "Missing: DNS".to_string())
        );
        assert!(shows_order(&site()));
        let running = SiteJson {
            state: "ready".into(),
            domain: "test2.gunnar.rs".into(),
            words:
                "The site is ready at https://test2.gunnar.rs. Its first sign-in link was mailed."
                    .into(),
            orderable: false,
            why_not: "it is already ordered".into(),
            ..site()
        };
        assert!(
            !shows_order(&running),
            "no 'Cannot be ordered' on a running site"
        );
        assert_eq!(site_words(&running), "Running at https://test2.gunnar.rs");
        assert!(offers_update(&running) && not_managed(&running).is_none());
        let kit = SiteJson {
            state: "stands".into(),
            ..site()
        };
        assert!(!shows_order(&kit) && !offers_update(&kit));
        assert_eq!(
            not_managed(&kit),
            Some("Not managed by this box yet — adopt it to update it here")
        );
        assert_eq!(site_words(&kit), "Running at https://gunnar.rs");
        let u = UpdateJson {
            live_on: false,
            ..Default::default()
        };
        let (label, on, meaning) = live_words(&u);
        assert_eq!((label, on), ("Live updates", false));
        assert!(
            meaning
                .contains("Off = every update reinstalls the box from its keep medium, /data kept"),
            "{meaning}"
        );
        assert_eq!(
            live_body("test2-gunnar-rs", &u),
            LiveBody {
                estate: "test2-gunnar-rs".into(),
                on: true
            }
        );
    }

    /// ★ **FIRST-LINK-1: the door's state stands beside the button**, in words, from the
    /// server's field; nothing when the server said nothing.
    #[test]
    fn the_first_admin_door_is_said_beside_the_send_again_button() {
        let mut s = site();
        assert_eq!(door_line(&s), None);
        s.first_admin_door = "open".into();
        assert_eq!(door_line(&s).as_deref(), Some("First-admin door: open"));
        s.first_admin_door = "handed_off".into();
        s.first_admin_door_words = "its main says the link was sent to r***@ignalina.dk".into();
        assert_eq!(door_line(&s).as_deref(), Some("First-admin door: handed off to the site — its main says the link was sent to r***@ignalina.dk"));
        s.first_admin_door = "spent".into();
        assert!(door_line(&s)
            .unwrap()
            .starts_with("First-admin door: spent"));
        s.first_admin_door = "expired".into();
        assert!(door_line(&s)
            .unwrap()
            .starts_with("First-admin door: expired"));
    }
}