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

//! **THE TEST BUTTON** (owner 2026-10-11) — every decision the Test buy page
//! takes, compiled for the host and tested there; `pages::test_buy` only lays
//! them out.
//!
//! Each PRODUCT declares its test actions (monetize's `TestPurchase.Actions`):
//! one button each, nothing else — the console never invents an action, a plan
//! or a size. A press makes a real account (and, for a private instance, real
//! machines) through the sign-up page's own path, paid with the vendor's TEST
//! payment method; the run's lines are the setup's own, kept in monetize.
//!
//! ```text
//! Test buy:  per product, one button per declared action and site (disabled, with why, when
//!            it cannot run; "LIVE — you will be charged" on a live key: a real purchase,
//!            paid by the operator at the vendor's page the run hands back)
//!            runs: started | product | action | account | instance | state | where
//!            one run: its lines (when | from | what)
//! ```

use serde::Deserialize;

use crate::fmt::{since, Tone};

/// The runs table's columns.
pub const RUN_COLUMNS: [&str; 7] = [
    "started", "product", "action", "account", "instance", "state", "where",
];
/// One run's line columns.
pub const LINE_COLUMNS: [&str; 3] = ["when", "from", "what"];
/// A cell with nothing to say.
pub const NONE: &str = "—";
/// What the browser posts to.
pub const START_API: &str = "/api/test-purchase/start";
pub const LIST_API: &str = "/api/test-purchase";

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ActionJson {
    pub product: String,
    pub id: String,
    pub label: String,
    pub kind: String,
    pub cap_gib: u64,
    pub front_plan: String,
    pub main_plan: String,
    pub main_data_gib: u64,
    pub runnable: bool,
    pub why_not: String,
    pub site: String,
    pub site_host: String,
    pub live: bool,
    /// ★ A33-11: monetize's one sentence for the button (real money on a LIVE key, none on TEST).
    pub words: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct LineJson {
    pub at_unix_ms: u64,
    pub source: String,
    pub line: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct RunJson {
    pub id: String,
    pub product: String,
    pub action: String,
    pub kind: String,
    pub label: String,
    pub username: String,
    pub company: String,
    pub vendor: String,
    pub session: String,
    pub started_unix_ms: u64,
    pub state: String,
    pub words: String,
    pub signup_state: String,
    pub phase: String,
    pub mailbox: String,
    pub lines: Vec<LineJson>,
    pub site: String,
    pub pay_url: String,
    pub live: bool,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TestPurchaseJson {
    pub actions: Vec<ActionJson>,
    pub runs: Vec<RunJson>,
}

/// The actions grouped under their product, in the order monetize answered.
pub fn by_product(actions: &[ActionJson]) -> Vec<(String, Vec<ActionJson>)> {
    let mut out: Vec<(String, Vec<ActionJson>)> = Vec::new();
    for a in actions {
        match out.iter_mut().find(|(p, _)| *p == a.product) {
            Some((_, v)) => v.push(a.clone()),
            None => out.push((a.product.clone(), vec![a.clone()])),
        }
    }
    out
}

/// What the action buys, in words — from its declaration, never a price.
pub fn buys(a: &ActionJson) -> String {
    match a.kind.as_str() {
        "hosted_plan" => format!(
            "a hosted account with {} GiB, at the plan the sign-up page offers for it now",
            a.cap_gib
        ),
        "private_instance" => {
            let sizes: Vec<String> = [
                ("front", a.front_plan.as_str()),
                ("main", a.main_plan.as_str()),
            ]
            .iter()
            .filter(|(_, v)| !v.is_empty())
            .map(|(k, v)| format!("{k} {v}"))
            .collect();
            let data = if a.main_data_gib > 0 {
                format!(", {} GiB data", a.main_data_gib)
            } else {
                String::new()
            };
            let sizes = if sizes.is_empty() && data.is_empty() {
                "the ladder's default sizes".to_owned()
            } else {
                format!("{}{data}", sizes.join(", "))
            };
            format!("an account and a private instance ({sizes}): real machines")
        }
        other => format!("{other} (a kind this console does not know)"),
    }
}

/// The words LIVE buttons and runs carry (monetize's `test_purchase::LIVE_WORDS`).
pub const LIVE_WORDS: &str = "LIVE — you will be charged";

/// The button's words: its label, the site it sells as, and LIVE when the key is live (ST-8).
pub fn button_words(a: &ActionJson) -> String {
    // ★ A33-11: a LIVE key's press is a real purchase: its label says no "Test:".
    let mut w = if a.live {
        a.label.trim_start_matches("Test:").trim_start().to_owned()
    } else {
        a.label.clone()
    };
    if !a.site_host.is_empty() {
        w.push_str(&format!(" · {}", a.site_host));
    }
    if a.live {
        w.push_str(&format!(" · {LIVE_WORDS}"));
    }
    w
}

/// What a press posts: the action, its site, and on a LIVE key the operator's confirmation
/// (the button itself says "LIVE — you will be charged").
pub fn start_body(a: &ActionJson) -> serde_json::Value {
    serde_json::json!({ "product": a.product, "action": a.id, "site": a.site, "live": a.live })
}

/// A LIVE run's way to pay: the words and the vendor's own https checkout page.
pub fn pay_link(r: &RunJson) -> Option<(String, String)> {
    (r.live && r.pay_url.starts_with("https://")).then(|| {
        (
            format!("{LIVE_WORDS}: pay at the vendor's page"),
            r.pay_url.clone(),
        )
    })
}

/// Is the button pressable, and if not, why (shown as its title and beside it).
pub fn press(a: &ActionJson, is_admin: bool) -> (bool, String) {
    if !a.runnable {
        return (false, a.why_not.clone());
    }
    if !is_admin {
        return (false, "a test buy needs an admin passkey".into());
    }
    (true, String::new())
}

/// The badge tone of a run's state.
pub fn state_tone(state: &str) -> Tone {
    match state {
        "done" => Tone::Pass,
        "failed" => Tone::Bad,
        "running" => Tone::Live,
        _ => Tone::Plain,
    }
}

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

/// One run's row: started | product | action | account | instance | state | where.
pub fn run_cells(r: &RunJson, now_ms: u64) -> [String; 7] {
    let account = if r.signup_state.is_empty() {
        or_none(&r.username)
    } else {
        format!("{} ({})", r.username, r.signup_state.to_lowercase())
    };
    let instance = if r.company.is_empty() {
        NONE.to_owned()
    } else if r.phase.is_empty() {
        r.company.clone()
    } else {
        format!("{} ({})", r.company, r.phase)
    };
    [
        since(r.started_unix_ms, now_ms),
        r.product.clone(),
        or_none(&r.label),
        account,
        instance,
        or_none(&r.state),
        or_none(&r.words),
    ]
}

/// One line's row: when | from | what.
pub fn line_cells(l: &LineJson, now_ms: u64) -> [String; 3] {
    [
        since(l.at_unix_ms, now_ms),
        l.source.clone(),
        l.line.clone(),
    ]
}

/// **A refusal of the test button, in plain words** — a headline and what to
/// do; never a raw status or a wire code (live 2026-10-14: an admin saw
/// `HTTP 403 · monetize: permissiondenied`).
pub fn refusal_words(f: &crate::fmt::Fail) -> (String, String) {
    match (f.status, f.code.as_str()) {
        (403, "stepup") => (f.summary().to_owned(), String::new()),
        (403, _) | (_, "permissiondenied") => (
            "Only the operator can make a test buy.".to_owned(),
            "This console could not show monetize that it acts for the operator, so nothing was bought. Sign in with an admin passkey on the operator's own console.".to_owned(),
        ),
        (_, "unavailable") if f.message.contains("operator credential") => ("This console cannot make a test buy.".to_owned(), "It holds no operator credential; nothing was bought.".to_owned()),
        _ => {
            let detail = f.message.trim().trim_start_matches("REFUSED").trim().to_owned();
            (f.summary().to_owned(), detail)
        }
    }
}

/// The screen as plain text — what the tests read.
pub fn page_text(j: &TestPurchaseJson, is_admin: bool, now_ms: u64) -> String {
    let mut out = String::new();
    for (product, actions) in by_product(&j.actions) {
        out.push_str(&format!("{product}\n"));
        for a in actions {
            let (ok, why) = press(&a, is_admin);
            out.push_str(&format!(
                "  [{}]{} — {}{}\n",
                button_words(&a),
                if ok { "" } else { " (disabled)" },
                if a.words.is_empty() {
                    buys(&a)
                } else {
                    a.words.clone()
                },
                if why.is_empty() {
                    String::new()
                } else {
                    format!(" — {why}")
                }
            ));
        }
    }
    if j.actions.is_empty() {
        out.push_str("No product declares a test action.\n");
    }
    for r in &j.runs {
        out.push_str(&run_cells(r, now_ms).join(" | "));
        out.push('\n');
    }
    out
}

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

    /// ★ live 2026-10-14: a refusal is said in words — no status number, no wire code.
    #[test]
    fn a_refusal_is_said_in_plain_words() {
        let f = crate::fmt::Fail { status: 403, code: "permissiondenied".into(), message: "the test button is the operator's: the operator bearer, or the console's signed call".into() };
        let (head, detail) = refusal_words(&f);
        assert_eq!(head, "Only the operator can make a test buy.");
        for raw in ["403", "permissiondenied", "bearer", "HTTP"] {
            assert!(
                !head.contains(raw) && !detail.contains(raw),
                "{raw}: {head} {detail}"
            );
        }
        let (head, _) = refusal_words(&crate::fmt::Fail { status: 503, code: "unavailable".into(), message: "this console holds no operator credential (monetize's operator-token), so it cannot refund or settle; nothing was sent".into() });
        assert_eq!(head, "This console cannot make a test buy.");
    }

    fn action(product: &str, id: &str, kind: &str, runnable: bool) -> ActionJson {
        ActionJson {
            product: product.into(),
            id: id.into(),
            label: format!("Test: {id}"),
            kind: kind.into(),
            cap_gib: 10,
            runnable,
            why_not: if runnable {
                String::new()
            } else {
                "stripe/gunnar: the vendor's key is not a TEST key".into()
            },
            ..Default::default()
        }
    }

    /// ★ **One button per declared action, under its product; the console
    /// invents none** — and a button that cannot run says why instead of
    /// pressing into a refusal; a viewer cannot press at all.
    #[test]
    fn one_button_per_declared_action_and_a_disabled_one_says_why() {
        let j = TestPurchaseJson {
            actions: vec![
                action("gunnar", "hosted-10gb", "hosted_plan", true),
                action("gunnar", "private-small", "private_instance", false),
                action("holger", "hosted-10gb", "hosted_plan", true),
            ],
            runs: vec![],
        };
        let text = page_text(&j, true, 0);
        assert_eq!(
            by_product(&j.actions)
                .iter()
                .map(|(p, a)| (p.as_str(), a.len()))
                .collect::<Vec<_>>(),
            [("gunnar", 2), ("holger", 1)]
        );
        assert!(
            text.contains("[Test: hosted-10gb] — a hosted account with 10 GiB"),
            "{text}"
        );
        assert!(text.contains("[Test: private-small] (disabled) — an account and a private instance (the ladder's default sizes): real machines — stripe/gunnar: the vendor's key is not a TEST key"), "{text}");
        // A33-11: what money moves is the server's sentence (from the key verdict), shown as sent.
        let mut said = j.clone();
        said.actions[0].words = "Test purchase on test2.gunnar.rs: real machines, paid with the vendor's test card — no money moves.".into();
        assert!(page_text(&said, true, 0).contains("[Test: hosted-10gb] — Test purchase on test2.gunnar.rs: real machines, paid with the vendor's test card — no money moves."), "{}", page_text(&said, true, 0));
        assert_eq!(
            press(&j.actions[0], false),
            (false, "a test buy needs an admin passkey".into())
        );
        assert!(page_text(&TestPurchaseJson::default(), true, 0)
            .contains("No product declares a test action."));
    }

    /// ★ ST-8: **a LIVE button says so, per site** — "LIVE — you will be charged", the site
    /// it sells as; and a live run hands the operator the vendor's checkout page to pay at.
    #[test]
    fn a_live_button_says_you_will_be_charged_and_names_its_site() {
        let live = ActionJson {
            site: "gunnar-rs".into(),
            site_host: "gunnar.rs".into(),
            live: true,
            ..action("gunnar", "hosted-10gb", "hosted_plan", true)
        };
        let test = ActionJson {
            site: "test2-gunnar-rs".into(),
            site_host: "test2.gunnar.rs".into(),
            ..action("gunnar", "hosted-10gb", "hosted_plan", true)
        };
        assert_eq!(
            button_words(&live),
            "hosted-10gb · gunnar.rs · LIVE — you will be charged",
            "A33-11: no Test: on a real purchase"
        );
        assert_eq!(button_words(&test), "Test: hosted-10gb · test2.gunnar.rs");
        assert!(page_text(
            &TestPurchaseJson {
                actions: vec![live.clone(), test],
                runs: vec![]
            },
            true,
            0
        )
        .contains("[hosted-10gb · gunnar.rs · LIVE — you will be charged]"));
        assert_eq!(
            start_body(&live),
            serde_json::json!({ "product": "gunnar", "action": "hosted-10gb", "site": "gunnar-rs", "live": true })
        );
        let run = RunJson {
            live: true,
            pay_url: "https://checkout.stripe.com/c/pay/cs_live_x".into(),
            ..Default::default()
        };
        assert_eq!(
            pay_link(&run),
            Some((
                "LIVE — you will be charged: pay at the vendor's page".to_string(),
                "https://checkout.stripe.com/c/pay/cs_live_x".to_string()
            ))
        );
        assert_eq!(
            pay_link(&RunJson {
                pay_url: "http://x".into(),
                ..run
            }),
            None,
            "only an https page is offered"
        );
    }

    /// ★ **A run reads as where it stands**: the account and its signup state,
    /// the instance and its phase, the run's state and monetize's words; its
    /// lines in monetize's order.
    #[test]
    fn a_run_reads_as_where_it_stands() {
        let r = RunJson {
            product: "gunnar".into(),
            label: "Test: private Gunnar small".into(),
            username: "test-261011153012".into(),
            company: "tb261011153012".into(),
            signup_state: "CREATED".into(),
            phase: "installing".into(),
            state: "running".into(),
            words: "installing".into(),
            started_unix_ms: 0,
            lines: vec![LineJson {
                at_unix_ms: 0,
                source: "installer".into(),
                line: "pairing 3 machine(s)".into(),
            }],
            ..Default::default()
        };
        let [_, product, action, account, instance, state, words] = run_cells(&r, 0);
        assert_eq!(
            (
                product.as_str(),
                action.as_str(),
                account.as_str(),
                instance.as_str(),
                state.as_str(),
                words.as_str()
            ),
            (
                "gunnar",
                "Test: private Gunnar small",
                "test-261011153012 (created)",
                "tb261011153012 (installing)",
                "running",
                "installing"
            )
        );
        assert_eq!(state_tone("done"), Tone::Pass);
        assert_eq!(state_tone("failed"), Tone::Bad);
        assert_eq!(
            line_cells(&r.lines[0], 0)[1..],
            ["installer".to_string(), "pairing 3 machine(s)".to_string()]
        );
        let hosted = RunJson {
            company: String::new(),
            ..r
        };
        assert_eq!(run_cells(&hosted, 0)[4], NONE);
    }
}