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-server/src/test_purchase.rs

//! **THE TEST BUTTON** (owner 2026-10-11): each product's declared test actions
//! and the runs they made, reshaped from monetize's `TestPurchase` service for
//! the browser. Thin: monetize decides what is offered, whether it may run and
//! what a run did; this side only carries it.
//!
//! ```text
//! GET  /api/test-purchase              → {actions: [ActionJson], runs: [RunJson]}
//! POST /api/test-purchase/start        {product, action, site, live} → RunJson
//! GET  /api/test-purchase/run/<id>     → RunJson
//! ```
//!
//! The POST is a mutation under `/api/`, so the console's own gate floors it
//! at admin with a fresh passkey touch (`auth::role_floor`, `needs_step_up`);
//! monetize refuses anyone but the operator a second time.

use axum::extract::State;
use axum::Json;
use monetize_proto as pb;
use serde::{Deserialize, Serialize};

use crate::api::ApiError;
use crate::app::AppState;

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
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,
    /// The site the button sells as (its estate) and that site's host (ST-8).
    pub site: String,
    pub site_host: String,
    /// The site's vendor key is LIVE: a press is a real purchase the operator pays himself.
    pub live: bool,
    /// ★ A33-11: monetize's one sentence for the button.
    pub words: String,
}

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

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
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,
    /// LIVE: the vendor's own checkout page the operator pays at himself.
    pub pay_url: String,
    pub live: bool,
}

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

#[derive(Debug, Clone, Deserialize)]
pub struct StartBody {
    pub product: String,
    pub action: String,
    /// The offer's site; monetize refuses a press that names none where one is needed.
    #[serde(default)]
    pub site: String,
    /// The operator's "LIVE — you will be charged"; monetize refuses a live press without it.
    #[serde(default)]
    pub live: bool,
}

/// Pure: the press as monetize takes it — carried, never decided here.
pub fn start_request(body: StartBody) -> pb::TestPurchaseStartRequest {
    pb::TestPurchaseStartRequest { product: body.product, action: body.action, site: body.site, live: body.live }
}

/// Pure: one offer for the browser.
pub fn action_json(a: &pb::TestActionOffer) -> ActionJson {
    let sizes = a.sizes.clone().unwrap_or_default();
    ActionJson {
        product: a.product.clone(),
        id: a.id.clone(),
        label: a.label.clone(),
        kind: a.kind.clone(),
        cap_gib: a.cap_gib,
        front_plan: sizes.front_plan,
        main_plan: sizes.main_plan,
        main_data_gib: sizes.main_data_gib,
        runnable: a.runnable,
        why_not: a.why_not.clone(),
        site: a.site.clone(),
        site_host: a.site_host.clone(),
        live: a.live,
        words: a.words.clone(),
    }
}

/// Pure: one run for the browser, its lines as monetize ordered them.
pub fn run_json(r: &pb::TestPurchaseRun) -> RunJson {
    RunJson {
        id: r.id.clone(),
        product: r.product.clone(),
        action: r.action.clone(),
        kind: r.kind.clone(),
        label: r.label.clone(),
        username: r.username.clone(),
        company: r.company.clone(),
        vendor: r.vendor.clone(),
        session: r.session.clone(),
        started_unix_ms: r.started_unix_ms,
        state: r.state.clone(),
        words: r.words.clone(),
        signup_state: r.signup_state.clone(),
        phase: r.phase.clone(),
        mailbox: r.mailbox.clone(),
        lines: r.lines.iter().map(|l| LineJson { at_unix_ms: l.at_unix_ms, source: l.source.clone(), line: l.line.clone() }).collect(),
        site: r.site.clone(),
        pay_url: r.pay_url.clone(),
        live: r.live,
    }
}

/// **Who the console is to monetize on the test button**: the OPERATOR — the
/// console's operator bearer when it holds one (the ONE box: its console sits
/// on loopback beside monetize and its plain client carries the SERVICE
/// bearer, which monetize refuses here — live 2026-10-14, an admin's press got
/// `permission denied`), else its own client, which outside an estate signs
/// every call with the console's key (the "console's signed call").
pub fn door(state: &AppState) -> &crate::grpc::Monetize {
    state.operator.as_ref().unwrap_or(&state.monetize)
}

/// `GET /api/test-purchase`: every declared action and the last 20 runs.
pub async fn list(State(state): State<AppState>) -> Result<Json<TestPurchaseJson>, ApiError> {
    let m = door(&state).clone();
    let actions = m.test_purchase().actions(pb::TestActionsRequest::default()).await?.into_inner().actions;
    let runs = m.test_purchase().list(pb::TestPurchaseListRequest { limit: 20 }).await?.into_inner().runs;
    Ok(Json(TestPurchaseJson { actions: actions.iter().map(action_json).collect(), runs: runs.iter().map(run_json).collect() }))
}

/// `POST /api/test-purchase/start` `{product, action, site, live}`.
pub async fn start(State(state): State<AppState>, Json(body): Json<StartBody>) -> Result<Json<RunJson>, ApiError> {
    let r = door(&state).test_purchase().start(start_request(body)).await?.into_inner();
    Ok(Json(run_json(&r)))
}

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

    /// ★ **An offer and a run reach the browser whole** — the button's words,
    /// whether it may be pressed and why not, and every setup line in order.
    #[test]
    fn an_offer_and_a_run_reach_the_browser_whole() {
        let a = pb::TestActionOffer {
            product: "gunnar".into(),
            id: "private-small".into(),
            label: "Test: private Gunnar small".into(),
            kind: "private_instance".into(),
            cap_gib: 0,
            sizes: Some(pb::EstateSizes { front_plan: "1xCPU-1GB".into(), main_plan: String::new(), main_data_gib: 20, ..Default::default() }),
            runnable: false,
            why_not: "stripe/gunnar: the vendor's key is not a TEST key".into(),
            ..Default::default()
        };
        let j = action_json(&a);
        assert_eq!((j.id.as_str(), j.kind.as_str(), j.front_plan.as_str(), j.main_data_gib, j.runnable), ("private-small", "private_instance", "1xCPU-1GB", 20, false));
        assert!(j.why_not.contains("TEST key"));
        let r = pb::TestPurchaseRun {
            id: "tp-1".into(),
            state: "running".into(),
            phase: "installing".into(),
            lines: vec![
                pb::TestPurchaseLine { at_unix_ms: 1, source: "order".into(), line: "held".into() },
                pb::TestPurchaseLine { at_unix_ms: 2, source: "acme-main".into(), line: "serving".into() },
            ],
            ..Default::default()
        };
        let j = run_json(&r);
        assert_eq!((j.state.as_str(), j.phase.as_str()), ("running", "installing"));
        assert_eq!(j.lines.iter().map(|l| (l.source.as_str(), l.line.as_str())).collect::<Vec<_>>(), [("order", "held"), ("acme-main", "serving")]);
    }

    /// ★ ST-8: **the site and LIVE reach the browser, and go back with the press** — an
    /// offer's site and host and whether its key is live; a run's site and the vendor's
    /// checkout page the operator pays at; the press carries the site and the confirmation.
    #[test]
    fn the_site_and_live_reach_the_browser_and_ride_the_press() {
        let a = pb::TestActionOffer { product: "gunnar".into(), id: "hosted-10gb".into(), site: "test2-gunnar-rs".into(), site_host: "test2.gunnar.rs".into(), live: true, runnable: true, ..Default::default() };
        let j = action_json(&a);
        assert_eq!((j.site.as_str(), j.site_host.as_str(), j.live), ("test2-gunnar-rs", "test2.gunnar.rs", true));
        let r = run_json(&pb::TestPurchaseRun { site: "test2-gunnar-rs".into(), pay_url: "https://checkout.stripe.com/c/pay/cs_live_x".into(), live: true, ..Default::default() });
        assert_eq!((r.site.as_str(), r.pay_url.as_str(), r.live), ("test2-gunnar-rs", "https://checkout.stripe.com/c/pay/cs_live_x", true));
        let body: StartBody = serde_json::from_str(r#"{"product":"gunnar","action":"hosted-10gb","site":"test2-gunnar-rs","live":true}"#).unwrap();
        let req = start_request(body);
        assert_eq!((req.site.as_str(), req.live), ("test2-gunnar-rs", true));
        let old: StartBody = serde_json::from_str(r#"{"product":"gunnar","action":"hosted-10gb"}"#).unwrap();
        assert_eq!((start_request(old).site, false), (String::new(), false), "no site, no confirmation: monetize decides");
    }
}