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/accounts.rs

//! **ACCOUNTS** (ONE monetize, 5.4) — every decision the Accounts screens take,
//! compiled for the host and tested there; `pages::accounts` only lays them out.
//!
//! An account is `(product, tenant)`; members are never accounts and never
//! appear here. A Private Gunnar is a ROW on its gunnar account. Stripe is the
//! PRODUCT's (gunnar's Stripe, holger's Stripe), shown on every account.
//!
//! ```text
//! Accounts:      product | tenant | rows | Stripe | status
//! One account:   kind | plan / stop | host | last heard | billing | since   (+ its payments)
//! ```
//!
//! [`accounts_text`] and [`account_text`] draw the same columns from the same
//! cells as plain text — what the tests (and `ui-screen.md`) read.

use std::collections::BTreeMap;

use serde::Deserialize;

use crate::fmt::{date, percent_encode, Tone};

/// The Accounts list's columns, in order (the agreed mockup).
pub const ACCOUNT_COLUMNS: [&str; 5] = ["product", "tenant", "rows", "Stripe", "status"];
/// One account's row columns, in order.
pub const ROW_COLUMNS: [&str; 6] = [
    "kind",
    "plan / stop",
    "host",
    "last heard",
    "billing",
    "since",
];
/// One account's payment columns, in order.
pub const PAYMENT_COLUMNS: [&str; 4] = ["when", "reference", "outcome", "what"];

/// The row kind of the SaaS plan.
pub const KIND_SAAS: &str = "saas";
/// The row kind of a private instance (a Private Gunnar).
pub const KIND_PRIVATE: &str = "private-instance";

/// The words for a cell that has nothing to say.
pub const NONE: &str = "—";
/// A private instance whose estate has not called home yet.
pub const NEVER: &str = "never";
/// ★ ALICA-6: a private instance with no paid period yet (being set up): its term counts
/// from the day it runs.
pub const TERM_NOT_YET: &str = "its term starts when it runs";

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct AccountJson {
    pub product: String,
    pub tenant: String,
    #[serde(default)]
    pub vendor: String,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub paid_until_unix_ms: Option<u64>,
    #[serde(default)]
    pub rows: u32,
    #[serde(default)]
    pub instances: u32,
    #[serde(default)]
    pub last_unix_ms: u64,
    #[serde(default)]
    pub needs_you: bool,
    #[serde(default)]
    pub livemode: Option<bool>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct AccountRowJson {
    pub kind: String,
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub vendor: String,
    #[serde(default)]
    pub plan: String,
    #[serde(default)]
    pub host: Option<String>,
    #[serde(default)]
    pub last_heard_unix_ms: Option<u64>,
    #[serde(default)]
    pub billing: String,
    #[serde(default)]
    pub paid_until_unix_ms: Option<u64>,
    #[serde(default)]
    pub paid_until: String,
    #[serde(default)]
    pub since_unix_ms: u64,
    #[serde(default)]
    pub words: String,
    #[serde(default)]
    pub why: String,
    #[serde(default)]
    pub needs_you: bool,
    /// A private order's phase (`owed`, `held`, `laid`, …); empty for the SaaS plan.
    #[serde(default)]
    pub phase: String,
    /// ★ A32-29: the settles the SERVER says this row offers, with their words (UW-3).
    #[serde(default)]
    pub actions: Vec<ActionJson>,
}

/// **One settle a row offers**, as the server words it: the verb posted back, its button, its
/// confirm (monetize-ui-server `accounts::ActionJson`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct ActionJson {
    #[serde(default)]
    pub verb: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub confirm: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct PaymentJson {
    pub reference: String,
    #[serde(default)]
    pub vendor: String,
    #[serde(default)]
    pub words: String,
    #[serde(default)]
    pub at_unix_ms: u64,
    #[serde(default)]
    pub outcome: String,
    #[serde(default)]
    pub needs_you: bool,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct AccountsJson {
    #[serde(default)]
    pub accounts: Vec<AccountJson>,
    #[serde(default)]
    pub vendors: BTreeMap<String, String>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
pub struct AccountDetailJson {
    pub account: AccountJson,
    #[serde(default)]
    pub rows: Vec<AccountRowJson>,
    #[serde(default)]
    pub payments: Vec<PaymentJson>,
}

/// The console's address of one account (a tenant may carry slashes, so it
/// rides in the query).
pub fn account_href(product: &str, tenant: &str) -> String {
    format!(
        "/account?product={}&tenant={}",
        percent_encode(product),
        percent_encode(tenant)
    )
}

/// The server's address of one account's rows (each tenant segment encoded,
/// its slashes kept: the route takes the rest of the path as the tenant).
pub fn account_api(product: &str, tenant: &str) -> String {
    let tenant: Vec<String> = tenant.split('/').map(percent_encode).collect();
    format!(
        "/api/accounts/{}/{}",
        percent_encode(product),
        tenant.join("/")
    )
}

/// `Private Gunnar` — what a private instance of `product` is called.
pub fn instance_word(product: &str) -> String {
    let mut c = product.chars();
    match c.next() {
        Some(first) => format!("Private {}{}", first.to_uppercase(), c.as_str()),
        None => "Private instance".to_owned(),
    }
}

fn or_none(s: &str) -> String {
    if s.is_empty() {
        NONE.to_owned()
    } else {
        s.to_owned()
    }
}

/// The list's `rows` cell: `SaaS plan · 1 Private Gunnar instance`.
pub fn rows_cell(a: &AccountJson) -> String {
    let mut out = Vec::new();
    if a.state.is_some() {
        out.push("SaaS plan".to_owned());
    }
    if a.instances > 0 {
        let s = if a.instances == 1 { "" } else { "s" };
        out.push(format!(
            "{} {} instance{s}",
            a.instances,
            instance_word(&a.product)
        ));
    }
    if out.is_empty() {
        NONE.to_owned()
    } else {
        out.join(" · ")
    }
}

/// The list's `Stripe` cell: the product's vendor instance (the account's own
/// copy, else the product map) and the money mode its payments said.
pub fn stripe_cell(a: &AccountJson, vendors: &BTreeMap<String, String>) -> String {
    let vendor = if a.vendor.is_empty() {
        vendors.get(&a.product).cloned().unwrap_or_default()
    } else {
        a.vendor.clone()
    };
    if vendor.is_empty() {
        return "no Stripe (free)".to_owned();
    }
    let mode = match a.livemode {
        Some(true) => "live",
        Some(false) => "test",
        None => "mode not said",
    };
    format!("{vendor} · {mode}")
}

/// The list's `status` cell: `needs you` first, else the SaaS plan's state
/// and its end — said as the SaaS plan's (ALICA-6, live 2026-10-26: "paid until
/// 2026-11-25" read as a 1-day instance's term) — else that there is no SaaS plan.
pub fn status_cell(a: &AccountJson) -> (Tone, String) {
    if a.needs_you {
        return (Tone::Warn, "needs you".to_owned());
    }
    let Some(state) = &a.state else {
        return (Tone::Plain, "no SaaS plan".to_owned());
    };
    let tone = match state.as_str() {
        "paid" => Tone::Pass,
        "grace" => Tone::Warn,
        "suspended" | "retention" => Tone::Bad,
        _ => Tone::Plain,
    };
    match a.paid_until_unix_ms {
        Some(at) => (tone, format!("SaaS plan {state} until {}", date(at))),
        None => (tone, format!("SaaS plan {state}")),
    }
}

/// The "last heard" cell — pull model: monetize never reaches into an estate,
/// the estate calls home. A private instance says how long ago it last did,
/// [`NEVER`] before its first call; the SaaS plan has no estate of its own.
pub fn last_heard_cell(r: &AccountRowJson, now_ms: u64) -> String {
    match (r.kind.as_str(), r.last_heard_unix_ms) {
        (KIND_SAAS, _) => NONE.to_owned(),
        (_, Some(at)) => crate::fmt::since(at, now_ms),
        (_, None) => NEVER.to_owned(),
    }
}

/// One account row's six cells, in [`ROW_COLUMNS`] order.
pub fn row_cells(r: &AccountRowJson, now_ms: u64) -> [String; 6] {
    let mut billing = or_none(&r.billing);
    match (r.paid_until_unix_ms, r.paid_until.as_str()) {
        (Some(at), _) => billing = format!("{billing} until {}", date(at)),
        (None, until) if !until.is_empty() => billing = format!("{billing} until {until}"),
        // ★ ALICA-6: a private instance with no paid period yet says when it starts.
        _ if r.kind == KIND_PRIVATE
            && r.why.is_empty()
            && !matches!(
                r.billing.as_str(),
                "" | "stopped" | "failed" | "cancelled" | "terminated" | "deleted"
            ) =>
        {
            billing = format!("{billing} · {TERM_NOT_YET}")
        }
        _ => {}
    }
    if !r.why.is_empty() {
        billing = format!("{billing} — {}", r.why);
    }
    let since = if r.since_unix_ms == 0 {
        NONE.to_owned()
    } else {
        date(r.since_unix_ms)
    };
    [
        r.kind.clone(),
        or_none(&r.plan),
        or_none(r.host.as_deref().unwrap_or_default()),
        last_heard_cell(r, now_ms),
        billing,
        since,
    ]
}

/// One payment's four cells, in [`PAYMENT_COLUMNS`] order.
pub fn payment_cells(p: &PaymentJson) -> [String; 4] {
    let when = if p.at_unix_ms == 0 {
        NONE.to_owned()
    } else {
        date(p.at_unix_ms)
    };
    [
        when,
        p.reference.clone(),
        or_none(&p.outcome),
        or_none(&p.words),
    ]
}

/// One account line's five cells, in [`ACCOUNT_COLUMNS`] order.
pub fn account_cells(a: &AccountJson, vendors: &BTreeMap<String, String>) -> [String; 5] {
    [
        a.product.clone(),
        a.tenant.clone(),
        rows_cell(a),
        stripe_cell(a, vendors),
        status_cell(a).1,
    ]
}

/// A plain-text table: the headings, a rule, one line per row, columns padded
/// (by characters, not bytes), trailing blanks trimmed.
pub fn text_table(headings: &[&str], rows: &[Vec<String>]) -> String {
    let mut widths: Vec<usize> = headings.iter().map(|h| h.chars().count()).collect();
    for row in rows {
        for (w, cell) in widths.iter_mut().zip(row) {
            *w = (*w).max(cell.chars().count());
        }
    }
    let line = |cells: Vec<&str>| -> String {
        let padded: Vec<String> = cells
            .iter()
            .zip(&widths)
            .map(|(c, w)| format!("{c}{}", " ".repeat(w - c.chars().count())))
            .collect();
        padded.join(" | ").trim_end().to_owned()
    };
    let mut out = line(headings.to_vec());
    out.push('\n');
    out.push_str(
        &widths
            .iter()
            .map(|w| "-".repeat(*w))
            .collect::<Vec<_>>()
            .join("-+-"),
    );
    out.push('\n');
    for row in rows {
        out.push_str(&line(row.iter().map(String::as_str).collect()));
        out.push('\n');
    }
    out
}

/// **The Accounts screen as text** — the page's columns and cells.
pub fn accounts_text(j: &AccountsJson) -> String {
    let rows: Vec<Vec<String>> = j
        .accounts
        .iter()
        .map(|a| account_cells(a, &j.vendors).to_vec())
        .collect();
    format!("Accounts\n{}", text_table(&ACCOUNT_COLUMNS, &rows))
}

/// One account's heading line under its name: its Stripe and its status.
pub fn account_summary_line(a: &AccountJson) -> String {
    format!(
        "Stripe: {} · status: {}",
        stripe_cell(a, &BTreeMap::new()),
        status_cell(a).1
    )
}

/// **One account's screen as text** — its rows, then its payments.
pub fn account_text(j: &AccountDetailJson, now_ms: u64) -> String {
    let a = &j.account;
    let rows: Vec<Vec<String>> = j
        .rows
        .iter()
        .map(|r| row_cells(r, now_ms).to_vec())
        .collect();
    let payments = if j.payments.is_empty() {
        format!("{NONE}\n")
    } else {
        text_table(
            &PAYMENT_COLUMNS,
            &j.payments
                .iter()
                .map(|p| payment_cells(p).to_vec())
                .collect::<Vec<_>>(),
        )
    };
    format!(
        "Account {} / {}\n{}\nRows\n{}Payments\n{payments}",
        a.product,
        a.tenant,
        account_summary_line(a),
        text_table(&ROW_COLUMNS, &rows)
    )
}

/// ★ UW-13: where a private instance's setup log is read.
pub fn instance_log_api(company: &str) -> String {
    format!("/api/instances/{}/log", percent_encode(company))
}

/// ★ UW-13: one line of a private instance's setup log: when, who said it, what.
pub fn log_line(l: &serde_json::Value) -> String {
    let at = l["at_unix_ms"]
        .as_u64()
        .map(crate::fmt::date)
        .unwrap_or_default();
    format!(
        "{at} · {} · {}",
        l["source"].as_str().unwrap_or(""),
        l["line"].as_str().unwrap_or("")
    )
}

/// Where an order's settle is posted.
pub fn settle_api(company: &str) -> String {
    format!("/api/estate-orders/{}/settle", percent_encode(company))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ UW-13: a setup-log line says when, who and what; the log is read by company.
    #[test]
    fn a_setup_log_line_says_when_who_and_what() {
        let l = serde_json::json!({"at_unix_ms": 1_760_000_000_000u64, "source": "installer", "line": "front up"});
        assert_eq!(
            log_line(&l),
            format!(
                "{} · installer · front up",
                crate::fmt::date(1_760_000_000_000)
            )
        );
        assert_eq!(instance_log_api("acme co"), "/api/instances/acme%20co/log");
    }

    const LIST: &str = r#"{"accounts":[
        {"product":"gunnar","tenant":"acme","vendor":"stripe/gunnar","state":"paid","paid_until_unix_ms":1793836800000,"rows":3,"instances":1,"last_unix_ms":1759881600000,"needs_you":false,"livemode":true},
        {"product":"holger","tenant":"acme","vendor":"stripe/holger","state":"paid","paid_until_unix_ms":1793836800000,"rows":2,"instances":0,"last_unix_ms":1759881600000,"needs_you":false,"livemode":true},
        {"product":"gunnar","tenant":"bolt","vendor":"stripe/gunnar","state":null,"paid_until_unix_ms":null,"rows":1,"instances":1,"last_unix_ms":1759881600000,"needs_you":true,"livemode":false}],
        "vendors":{"gunnar":"stripe/gunnar","holger":"stripe/holger"}}"#;

    const ACME: &str = r#"{"account":{"product":"gunnar","tenant":"acme","vendor":"stripe/gunnar","state":"paid","paid_until_unix_ms":1793836800000,"rows":3,"instances":1,"last_unix_ms":1759881600000,"needs_you":false,"livemode":true},
        "rows":[
          {"kind":"saas","id":"","vendor":"stripe/gunnar","plan":"10gb","host":null,"last_heard_unix_ms":null,"billing":"paid","paid_until_unix_ms":1793836800000,"paid_until":"","since_unix_ms":1759276800000,"words":"Paid until 2026-11-05 · 10 GiB","why":"","needs_you":false},
          {"kind":"private-instance","id":"acme","vendor":"stripe/gunnar","plan":"132.00 EUR a month","host":"acme.gunnar.rs","last_heard_unix_ms":null,"billing":"ready","paid_until_unix_ms":null,"paid_until":"2027-04-07","since_unix_ms":1759881600000,"words":"Private Gunnar acme · laid · 132.00 EUR a month","why":"","needs_you":false}],
        "payments":[{"reference":"gunnar/acme/2025-10-01+ab12","vendor":"stripe/gunnar","words":"Sold acme 10 GiB · 110.00 SEK","at_unix_ms":1759276800000,"outcome":"granted","needs_you":false}]}"#;

    /// The agreed accounts screen, drawn from its cells.
    pub const ACCOUNTS_SCREEN: &str = "\
Accounts
product | tenant | rows                                  | Stripe               | status
--------+--------+---------------------------------------+----------------------+--------------------------------
gunnar  | acme   | SaaS plan · 1 Private Gunnar instance | stripe/gunnar · live | SaaS plan paid until 2026-11-05
holger  | acme   | SaaS plan                             | stripe/holger · live | SaaS plan paid until 2026-11-05
gunnar  | bolt   | 1 Private Gunnar instance             | stripe/gunnar · test | needs you
";

    pub const ACME_SCREEN: &str = "\
Account gunnar / acme
Stripe: stripe/gunnar · live · status: SaaS plan paid until 2026-11-05
Rows
kind             | plan / stop        | host           | last heard | billing                | since
-----------------+--------------------+----------------+------------+------------------------+-----------
saas             | 10gb               | —              | —          | paid until 2026-11-05  | 2025-10-01
private-instance | 132.00 EUR a month | acme.gunnar.rs | never      | ready until 2027-04-07 | 2025-10-08
Payments
when       | reference                   | outcome | what
-----------+-----------------------------+---------+------------------------------
2025-10-01 | gunnar/acme/2025-10-01+ab12 | granted | Sold acme 10 GiB · 110.00 SEK
";

    /// ★ **The Accounts list, as its cells say it** (the agreed mockup):
    /// product | tenant | rows | Stripe | status — the same company on two
    /// products is two accounts, each with its product's Stripe; a Private
    /// Gunnar is counted on the gunnar account; an account waiting on the
    /// operator says so first.
    #[test]
    fn the_accounts_list_reads_product_tenant_rows_stripe_status() {
        let j: AccountsJson = serde_json::from_str(LIST).unwrap();
        let [acme_g, acme_h, bolt] = [&j.accounts[0], &j.accounts[1], &j.accounts[2]];
        assert_eq!(rows_cell(acme_g), "SaaS plan · 1 Private Gunnar instance");
        assert_eq!(rows_cell(acme_h), "SaaS plan");
        assert_eq!(rows_cell(bolt), "1 Private Gunnar instance");
        assert_eq!(
            rows_cell(&AccountJson {
                instances: 2,
                product: "holger".into(),
                ..Default::default()
            }),
            "2 Private Holger instances"
        );
        assert_eq!(rows_cell(&AccountJson::default()), NONE);
        assert_eq!(stripe_cell(acme_g, &j.vendors), "stripe/gunnar · live");
        assert_eq!(
            stripe_cell(acme_h, &j.vendors),
            "stripe/holger · live",
            "holger's Stripe, never gunnar's"
        );
        assert_eq!(stripe_cell(bolt, &j.vendors), "stripe/gunnar · test");
        let unsaid = AccountJson {
            product: "holger".into(),
            ..Default::default()
        };
        assert_eq!(
            stripe_cell(&unsaid, &j.vendors),
            "stripe/holger · mode not said",
            "the product's vendor, from the map"
        );
        assert_eq!(
            stripe_cell(
                &AccountJson {
                    product: "idun".into(),
                    ..Default::default()
                },
                &j.vendors
            ),
            "no Stripe (free)"
        );
        assert_eq!(
            status_cell(acme_g),
            (Tone::Pass, "SaaS plan paid until 2026-11-05".to_owned()),
            "ALICA-6: the term named is the SaaS plan's"
        );
        assert_eq!(status_cell(bolt), (Tone::Warn, "needs you".to_owned()));
        assert_eq!(
            status_cell(&AccountJson {
                state: Some("grace".into()),
                ..Default::default()
            })
            .0,
            Tone::Warn
        );
        assert_eq!(
            status_cell(&AccountJson {
                state: Some("suspended".into()),
                ..Default::default()
            }),
            (Tone::Bad, "SaaS plan suspended".to_owned())
        );
        assert_eq!(
            status_cell(&AccountJson::default()),
            (Tone::Plain, "no SaaS plan".to_owned())
        );
        assert_eq!(
            account_cells(acme_g, &j.vendors),
            [
                "gunnar",
                "acme",
                "SaaS plan · 1 Private Gunnar instance",
                "stripe/gunnar · live",
                "SaaS plan paid until 2026-11-05"
            ]
            .map(String::from)
        );
        assert_eq!(accounts_text(&j), ACCOUNTS_SCREEN);
    }

    /// The console's clock in these tests: 2026-11-05.
    const NOW: u64 = 1_793_836_800_000;

    /// ★ **One account's rows** — kind | plan / stop | host | last heard |
    /// billing | since: the SaaS plan has no host and no estate of its own;
    /// the Private Gunnar names its host, when it last called home (`never`
    /// until the pull channel lands), its order's state and paid period; the
    /// payments follow.
    #[test]
    fn one_account_reads_as_its_rows_then_its_payments() {
        let j: AccountDetailJson = serde_json::from_str(ACME).unwrap();
        assert_eq!(ROW_COLUMNS[3], "last heard");
        assert_eq!(
            row_cells(&j.rows[0], NOW),
            [
                "saas",
                "10gb",
                NONE,
                NONE,
                "paid until 2026-11-05",
                "2025-10-01"
            ]
            .map(String::from)
        );
        assert_eq!(
            row_cells(&j.rows[1], NOW),
            [
                "private-instance",
                "132.00 EUR a month",
                "acme.gunnar.rs",
                NEVER,
                "ready until 2027-04-07",
                "2025-10-08"
            ]
            .map(String::from)
        );
        let stopped = AccountRowJson {
            kind: KIND_PRIVATE.into(),
            billing: "stopped".into(),
            why: "lay failed: quota".into(),
            ..Default::default()
        };
        assert_eq!(
            row_cells(&stopped, NOW),
            [
                "private-instance",
                NONE,
                NONE,
                NEVER,
                "stopped — lay failed: quota",
                NONE
            ]
            .map(String::from)
        );
        // ★ ALICA-6 (live 2026-10-26): a paid instance being set up has no term yet; its row
        // says when its term starts, never a blank that reads as "unpaid".
        let laying = AccountRowJson {
            kind: KIND_PRIVATE.into(),
            billing: "laying".into(),
            ..Default::default()
        };
        assert_eq!(
            row_cells(&laying, NOW)[4],
            format!("laying · {TERM_NOT_YET}")
        );
        assert_eq!(TERM_NOT_YET, "its term starts when it runs");
        assert_eq!(
            payment_cells(&j.payments[0]),
            [
                "2025-10-01",
                "gunnar/acme/2025-10-01+ab12",
                "granted",
                "Sold acme 10 GiB · 110.00 SEK"
            ]
            .map(String::from)
        );
        assert_eq!(account_text(&j, NOW), ACME_SCREEN);
        let no_payments = AccountDetailJson {
            payments: vec![],
            ..j
        };
        assert!(
            account_text(&no_payments, NOW).ends_with("Payments\n—\n"),
            "{}",
            account_text(&no_payments, NOW)
        );
    }

    /// ★ **Pull model**: monetize never reaches into an estate; the estate
    /// calls home. A private instance's row says how long ago it last did,
    /// `never` before its first call; the SaaS plan has no estate to hear from.
    #[test]
    fn a_private_instance_says_when_its_estate_last_called_home() {
        let heard = |ago_ms: u64| AccountRowJson {
            kind: KIND_PRIVATE.into(),
            last_heard_unix_ms: Some(NOW - ago_ms),
            ..Default::default()
        };
        assert_eq!(last_heard_cell(&heard(2 * 60_000), NOW), "2 min ago");
        assert_eq!(last_heard_cell(&heard(10_000), NOW), "just now");
        assert_eq!(last_heard_cell(&heard(3 * 3_600_000), NOW), "3 h ago");
        assert_eq!(last_heard_cell(&heard(2 * 86_400_000), NOW), "2 d ago");
        let never = AccountRowJson {
            kind: KIND_PRIVATE.into(),
            ..Default::default()
        };
        assert_eq!(last_heard_cell(&never, NOW), NEVER);
        let saas = AccountRowJson {
            kind: KIND_SAAS.into(),
            last_heard_unix_ms: Some(NOW),
            ..Default::default()
        };
        assert_eq!(last_heard_cell(&saas, NOW), NONE);
        assert_eq!(row_cells(&heard(2 * 60_000), NOW)[3], "2 min ago");
    }

    /// An account is addressed by product and tenant; a tenant may carry a slash.
    #[test]
    fn an_account_is_addressed_by_product_and_tenant() {
        assert_eq!(
            account_href("gunnar", "team/sub"),
            "/account?product=gunnar&tenant=team%2Fsub"
        );
        assert_eq!(
            account_api("gunnar", "team/sub"),
            "/api/accounts/gunnar/team/sub"
        );
        assert_eq!(account_api("gunnar", "a b"), "/api/accounts/gunnar/a%20b");
    }

    /// Members are not accounts: no screen here says "member".
    #[test]
    fn no_accounts_screen_speaks_of_members() {
        let all = format!(
            "{}{}{:?}{:?}",
            accounts_text(&serde_json::from_str(LIST).unwrap()),
            account_text(&serde_json::from_str(ACME).unwrap(), NOW),
            ACCOUNT_COLUMNS,
            ROW_COLUMNS
        );
        assert!(!all.to_lowercase().contains("member"), "{all}");
    }

    /// ★ UW-3, A32-29: **a row's settles are read off the server's answer** (monetize-ui-server
    /// `accounts::settles_of`), words and all; an older server sends none and none are drawn.
    #[test]
    fn a_rows_settles_are_read_off_the_servers_answer() {
        let r: AccountRowJson = serde_json::from_value(serde_json::json!({"kind": KIND_PRIVATE, "id": "acme", "phase": "owed", "actions": [{"verb": "refund", "label": "Refund", "confirm": "Confirm: refund the order"}]})).expect("a row");
        assert_eq!(
            r.actions,
            vec![ActionJson {
                verb: "refund".into(),
                label: "Refund".into(),
                confirm: "Confirm: refund the order".into()
            }]
        );
        let old: AccountRowJson =
            serde_json::from_value(serde_json::json!({"kind": KIND_PRIVATE, "phase": "owed"}))
                .expect("an older server");
        assert!(old.actions.is_empty());
    }
}