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

//! **ACCOUNTS** (ONE monetize, 5.4): one monetize serves several products
//! (gunnar, holger, …), each billed through its OWN Stripe, and an account is
//! `(product, tenant)`. Members are not accounts. A Private Gunnar the tenant
//! bought is another ROW on its gunnar account, never an account of its own.
//!
//! ```text
//! GET /api/accounts                       → {accounts: [AccountJson], vendors: {product: vendor}}
//! GET /api/accounts/<product>/<tenant…>   → {account, rows: [AccountRowJson], payments: [PaymentJson]}
//! GET /api/instances/<company>/log        → {lines: [{at_unix_ms, source, line}]}
//! ```
//!
//! Both are `Accounts.List` / `Accounts.Rows` (monetize-proto `accounts.proto`)
//! reshaped for the browser; nothing is decided here. The one thing this side
//! adds is when a private instance's estate last called home (pull model:
//! monetize never reaches into an estate, every estate calls monetize): the
//! fleet's newest report of that estate ([`with_last_heard`], the same
//! `Guardian.Cases` read as the estates page, UW-8). `None` = never.

use std::collections::BTreeMap;

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

use crate::api::{state_name, ApiError};
use crate::app::AppState;

/// The row kind of the account's subscription on the shared product.
pub const KIND_SAAS: &str = "saas";
/// The row kind of a private instance (a Private Gunnar) on the account.
pub const KIND_PRIVATE_INSTANCE: &str = "private-instance";

/// One account, one line.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountJson {
    pub product: String,
    pub tenant: String,
    /// The vendor instance that bills it (`stripe/gunnar`); empty for a free product.
    pub vendor: String,
    /// The SaaS plan's state (`paid`, `grace`, …); `None`: it holds no SaaS plan.
    pub state: Option<String>,
    pub paid_until_unix_ms: Option<u64>,
    pub rows: u32,
    pub instances: u32,
    pub last_unix_ms: u64,
    pub needs_you: bool,
    /// Real money at all, by the payments' own word; `None`: none said.
    pub livemode: Option<bool>,
}

/// One row on an account: its SaaS plan or a private instance.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountRowJson {
    /// [`KIND_SAAS`] | [`KIND_PRIVATE_INSTANCE`].
    pub kind: String,
    /// Empty for the SaaS plan, the company for an instance.
    pub id: String,
    pub vendor: String,
    /// The plan (`10gb`) or, for an instance, what it costs (`132.00 EUR a month`).
    pub plan: String,
    /// Where an instance is served; `None` for the SaaS plan.
    pub host: Option<String>,
    /// When the instance's estate last called home; `None` = never (and always
    /// for the SaaS plan, which has no estate of its own).
    pub last_heard_unix_ms: Option<u64>,
    /// The billing state: the fact's state, or the instance order's.
    pub billing: String,
    pub paid_until_unix_ms: Option<u64>,
    /// An instance's paid period as the order says it (`2027-04-07`).
    pub paid_until: String,
    pub since_unix_ms: u64,
    pub words: String,
    pub why: String,
    pub needs_you: bool,
    /// A private order's phase (`owed`, `held`, `laid`, …): the console offers the
    /// operator's settles on an owed or waiting one (UW-3). Empty for the SaaS plan.
    #[serde(default)]
    pub phase: String,
    /// ★ A32-29: **the settles this row offers** ([`settles_of`]), with their words: the
    /// console draws them and decides none.
    #[serde(default)]
    pub actions: Vec<ActionJson>,
}

/// **One settle a row offers**: the verb the console posts back, its button and its confirm.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionJson {
    pub verb: String,
    pub label: String,
    pub confirm: String,
}

impl ActionJson {
    /// The settle `verb` with its button and confirm words (the console's words since UW-3/F2/F5).
    pub fn of(verb: &str) -> ActionJson {
        let (label, confirm) = match verb {
            "retry" => ("Set up again", "Confirm: set it up again"),
            "refund" => ("Refund", "Confirm: refund the order"),
            "abandon" => ("Abandon this install", "Confirm: abandon the install"),
            "recheck" => ("Check again", "Confirm: check every box again"),
            "first-link" => ("Send the first sign-in link again", "Confirm: mail it to the first admin's address"),
            other => (other, "Confirm"),
        };
        ActionJson { verb: verb.to_owned(), label: label.to_owned(), confirm: confirm.to_owned() }
    }
}

/// Pure: **the settles a row of `kind` in `phase` offers** (UW-3, moved from monetize-ui-web,
/// A32-29): an owed private order may be set up again or refunded; a held one (already being set
/// up) refunded; nothing else.
pub fn settles_of(kind: &str, phase: &str) -> Vec<ActionJson> {
    if kind != KIND_PRIVATE_INSTANCE {
        return Vec::new();
    }
    match phase {
        "owed" => vec![ActionJson::of("retry"), ActionJson::of("refund")],
        "held" => vec![ActionJson::of("refund")],
        _ => Vec::new(),
    }
}

/// One purchase on the account (an attempt, folded).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentJson {
    pub reference: String,
    pub vendor: String,
    pub words: String,
    pub at_unix_ms: u64,
    pub outcome: String,
    pub needs_you: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountsJson {
    pub accounts: Vec<AccountJson>,
    /// product → the vendor instance it is billed through.
    pub vendors: BTreeMap<String, String>,
}

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

/// Pure: one `AccountSummary` as the console's line.
pub fn account_json(a: &pb::AccountSummary) -> AccountJson {
    let state = match pb::State::try_from(a.state) {
        Ok(pb::State::Unspecified) | Err(_) => None,
        Ok(s) => Some(state_name(s).to_owned()),
    };
    AccountJson {
        product: a.product.clone(),
        tenant: a.tenant.clone(),
        vendor: a.vendor.clone(),
        state,
        paid_until_unix_ms: a.paid_until_unix_ms,
        rows: a.rows,
        instances: a.instances,
        last_unix_ms: a.last_unix_ms,
        needs_you: a.needs_you,
        livemode: a.livemode,
    }
}

/// Pure: `Accounts.List`'s answer.
pub fn accounts_json(r: &pb::ListAccountsResponse) -> AccountsJson {
    AccountsJson { accounts: r.accounts.iter().map(account_json).collect(), vendors: r.vendors.iter().map(|(k, v)| (k.clone(), v.clone())).collect() }
}

fn saas_row(row: &pb::AccountRow, f: &pb::EntitlementFact) -> AccountRowJson {
    AccountRowJson {
        kind: KIND_SAAS.into(),
        id: row.id.clone(),
        vendor: row.vendor.clone(),
        plan: f.plan.clone(),
        host: None,
        last_heard_unix_ms: None,
        billing: pb::State::try_from(f.state).map(state_name).unwrap_or("unknown").to_owned(),
        paid_until_unix_ms: f.paid_until_unix_ms,
        paid_until: String::new(),
        since_unix_ms: f.issued_unix_ms.unwrap_or(row.at_unix_ms),
        words: row.words.clone(),
        why: String::new(),
        needs_you: row.needs_you,
        phase: String::new(),
        actions: Vec::new(),
    }
}

fn instance_row(row: &pb::AccountRow, i: &pb::AccountInstance) -> AccountRowJson {
    AccountRowJson {
        kind: KIND_PRIVATE_INSTANCE.into(),
        id: if row.id.is_empty() { i.company.clone() } else { row.id.clone() },
        vendor: row.vendor.clone(),
        plan: i.monthly_words.clone(),
        host: Some(i.host.clone()).filter(|h| !h.is_empty()),
        // Filled from the fleet's reports ([`with_last_heard`]); never heard until then.
        last_heard_unix_ms: None,
        billing: i.state.clone(),
        paid_until_unix_ms: None,
        paid_until: i.paid_until.clone(),
        since_unix_ms: row.at_unix_ms,
        words: row.words.clone(),
        why: i.why.clone(),
        needs_you: row.needs_you,
        phase: i.phase.clone(),
        actions: settles_of(KIND_PRIVATE_INSTANCE, &i.phase),
    }
}

/// Pure: `Accounts.Rows`' answer — the SaaS plan and the private instances as
/// rows, the purchases as payments. A row of a kind this console does not know
/// is skipped, never guessed at.
pub fn detail_json(r: &pb::AccountRowsResponse) -> AccountDetailJson {
    let mut d = AccountDetailJson { account: r.account.as_ref().map(account_json).unwrap_or_default(), ..AccountDetailJson::default() };
    for row in &r.rows {
        match (row.kind.as_str(), &row.fact, &row.instance, &row.sale) {
            ("subscription", Some(f), _, _) => d.rows.push(saas_row(row, f)),
            ("instance", _, Some(i), _) => d.rows.push(instance_row(row, i)),
            ("purchase", _, _, sale) => d.payments.push(PaymentJson {
                reference: if row.id.is_empty() { sale.as_ref().map(|s| s.reference.clone()).unwrap_or_default() } else { row.id.clone() },
                vendor: row.vendor.clone(),
                words: row.words.clone(),
                at_unix_ms: row.at_unix_ms,
                outcome: sale.as_ref().map(|s| s.outcome.clone()).unwrap_or_default(),
                needs_you: row.needs_you,
            }),
            _ => {}
        }
    }
    d
}

/// ★ UW-8: **a private instance's "last heard" is its estate's newest call home**, from
/// the fleet (`Guardian.Cases`, [`crate::estates::fleet`]): the estate named by the
/// instance's company. The SaaS plan has no estate of its own and stays `None`; an
/// instance the fleet does not know stays `None` (never heard).
pub fn with_last_heard(d: &mut AccountDetailJson, fleet: &[crate::estates::FleetEstate]) {
    for row in d.rows.iter_mut().filter(|r| r.kind == KIND_PRIVATE_INSTANCE) {
        row.last_heard_unix_ms = fleet.iter().find(|e| e.name == row.id).and_then(|e| e.last_heard_unix_ms);
    }
}

/// `GET /api/accounts`.
pub async fn list(State(state): State<AppState>) -> Result<Json<AccountsJson>, ApiError> {
    let r = state.monetize.accounts().list(pb::ListAccountsRequest { product: String::new(), limit: 0 }).await?.into_inner();
    Ok(Json(accounts_json(&r)))
}

/// `GET /api/accounts/<product>/<tenant…>` (a tenant may carry slashes).
pub async fn rows(State(state): State<AppState>, Path((product, tenant)): Path<(String, String)>) -> Result<Json<AccountDetailJson>, ApiError> {
    let r = state.monetize.accounts().rows(pb::AccountRowsRequest { product, tenant }).await?.into_inner();
    let mut d = detail_json(&r);
    if d.rows.iter().any(|row| row.kind == KIND_PRIVATE_INSTANCE) {
        // The fleet unread is "never heard", never a failed page.
        if let Ok(fleet) = crate::estates::read(&state.monetize).await {
            with_last_heard(&mut d, &fleet);
        }
    }
    Ok(Json(d))
}

/// One line of a private instance's setup log.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLineJson {
    pub at_unix_ms: u64,
    pub source: String,
    pub line: String,
}

/// ★ UW-13: `GET /api/instances/<company>/log` — `Accounts.InstanceLog`: the order's phases
/// and the installer's kept lines, oldest first.
pub async fn instance_log(State(state): State<AppState>, Path(company): Path<String>) -> Result<Json<serde_json::Value>, ApiError> {
    let r = state.monetize.accounts().instance_log(pb::InstanceLogRequest { company }).await?.into_inner();
    let lines: Vec<LogLineJson> = r.lines.into_iter().map(|l| LogLineJson { at_unix_ms: l.at_unix_ms, source: l.source, line: l.line }).collect();
    Ok(Json(serde_json::json!({ "lines": lines })))
}

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

    /// ★ A32-29: **the settles a row offers are the server's**, with their confirm words; the
    /// console only draws them. An owed private order: set it up again or refund; a held one:
    /// refund; any other phase, and the SaaS plan, none.
    #[test]
    fn a_private_rows_settles_are_the_servers() {
        let act = |v: &str, l: &str, c: &str| ActionJson { verb: v.into(), label: l.into(), confirm: c.into() };
        assert_eq!(settles_of(KIND_PRIVATE_INSTANCE, "owed"), vec![act("retry", "Set up again", "Confirm: set it up again"), act("refund", "Refund", "Confirm: refund the order")]);
        assert_eq!(settles_of(KIND_PRIVATE_INSTANCE, "held"), vec![act("refund", "Refund", "Confirm: refund the order")]);
        for phase in ["laid", "ready", ""] {
            assert!(settles_of(KIND_PRIVATE_INSTANCE, phase).is_empty(), "{phase}");
        }
        assert!(settles_of(KIND_SAAS, "owed").is_empty(), "a SaaS plan is no order");
        let inst = pb::AccountInstance { kind: "estate".into(), company: "acme".into(), phase: "owed".into(), ..Default::default() };
        assert_eq!(instance_row(&pb::AccountRow::default(), &inst).actions, settles_of(KIND_PRIVATE_INSTANCE, "owed"), "the row carries them");
    }

    fn summary(product: &str, tenant: &str, state: pb::State) -> pb::AccountSummary {
        pb::AccountSummary {
            product: product.into(),
            tenant: tenant.into(),
            vendor: format!("stripe/{product}"),
            state: state as i32,
            paid_until_unix_ms: Some(1_793_836_800_000),
            rows: 3,
            instances: 1,
            last_unix_ms: 1_760_000_000_000,
            needs_you: false,
            livemode: Some(false),
        }
    }

    /// ★ **An account is (product, tenant), billed through its PRODUCT's
    /// Stripe**: the line carries both, the vendor instance, the counts, and
    /// no SaaS state when the account holds no SaaS plan.
    #[test]
    fn an_account_line_is_product_tenant_and_its_products_stripe() {
        let a = account_json(&summary("gunnar", "acme", pb::State::Paid));
        assert_eq!((a.product.as_str(), a.tenant.as_str(), a.vendor.as_str()), ("gunnar", "acme", "stripe/gunnar"));
        assert_eq!(a.state.as_deref(), Some("paid"));
        assert_eq!((a.rows, a.instances, a.paid_until_unix_ms, a.livemode), (3, 1, Some(1_793_836_800_000), Some(false)));
        let only_instance = account_json(&summary("gunnar", "bolt", pb::State::Unspecified));
        assert_eq!(only_instance.state, None, "no fact: no SaaS plan, never `unknown`");
        let list = accounts_json(&pb::ListAccountsResponse {
            accounts: vec![summary("gunnar", "acme", pb::State::Paid), summary("holger", "acme", pb::State::Grace)],
            vendors: [("gunnar".to_string(), "stripe/gunnar".to_string()), ("holger".to_string(), "stripe/holger".to_string())].into(),
        });
        assert_eq!(list.accounts.iter().map(|a| (a.product.as_str(), a.tenant.as_str())).collect::<Vec<_>>(), [("gunnar", "acme"), ("holger", "acme")], "the same company on two products is two accounts");
        assert_eq!(list.vendors.get("holger").map(String::as_str), Some("stripe/holger"));
    }

    /// ★ **A Private Gunnar is a ROW on the gunnar account**: the SaaS plan and
    /// the instance are rows (kind, plan/stop, host, last heard, billing,
    /// since), the purchases are the account's payments. Pull model: the
    /// estate calls home, so a row carries when it last did (`None` = never,
    /// until the pull channel lands); nothing here says "reach".
    #[test]
    fn the_saas_plan_and_a_private_instance_are_rows_and_purchases_are_payments() {
        let fact = pb::EntitlementFact { tenant: "acme".into(), plan: "10gb".into(), state: pb::State::Paid as i32, paid_until_unix_ms: Some(1_793_836_800_000), issued_unix_ms: Some(1_759_276_800_000), ..Default::default() };
        let inst = pb::AccountInstance { kind: "estate".into(), company: "acme".into(), host: "acme.gunnar.rs".into(), phase: "laid".into(), state: "ready".into(), monthly_words: "132.00 EUR a month".into(), paid_until: "2027-04-07".into(), why: String::new(), ..Default::default() };
        let sale = pb::Sale { reference: "gunnar/acme/2026-10-01+ab12".into(), outcome: "granted".into(), drawn: true, ..Default::default() };
        let r = pb::AccountRowsResponse {
            account: Some(summary("gunnar", "acme", pb::State::Paid)),
            rows: vec![
                pb::AccountRow { kind: "subscription".into(), vendor: "stripe/gunnar".into(), words: "Paid until 2026-11-05 · 10 GiB".into(), at_unix_ms: 1_759_300_000_000, fact: Some(fact), ..Default::default() },
                pb::AccountRow { kind: "instance".into(), id: "acme".into(), vendor: "stripe/gunnar".into(), words: "Private Gunnar acme · laid · 132.00 EUR a month".into(), at_unix_ms: 1_759_900_000_000, instance: Some(inst), ..Default::default() },
                pb::AccountRow { kind: "purchase".into(), id: "gunnar/acme/2026-10-01+ab12".into(), vendor: "stripe/gunnar".into(), words: "Sold acme 10 GiB · 110.00 SEK".into(), at_unix_ms: 1_759_276_800_000, needs_you: true, sale: Some(sale), ..Default::default() },
            ],
        };
        let d = detail_json(&r);
        assert_eq!((d.account.product.as_str(), d.account.tenant.as_str()), ("gunnar", "acme"));
        assert_eq!(d.rows.len(), 2, "two rows; the purchase is a payment: {d:?}");
        let saas = &d.rows[0];
        assert_eq!((saas.kind.as_str(), saas.plan.as_str(), saas.billing.as_str()), (KIND_SAAS, "10gb", "paid"));
        let saas_j = serde_json::to_value(saas).unwrap();
        assert!(saas.host.is_none(), "a SaaS plan has no host of its own");
        assert!(saas_j.get("reach").is_none() && saas_j["last_heard_unix_ms"].is_null(), "{saas_j}");
        assert_eq!((saas.paid_until_unix_ms, saas.since_unix_ms), (Some(1_793_836_800_000), 1_759_276_800_000), "since = when the fact was issued");
        let pg = &d.rows[1];
        assert_eq!((pg.kind.as_str(), pg.id.as_str(), pg.plan.as_str()), (KIND_PRIVATE_INSTANCE, "acme", "132.00 EUR a month"));
        let pg_j = serde_json::to_value(pg).unwrap();
        assert_eq!(pg.host.as_deref(), Some("acme.gunnar.rs"));
        assert!(pg_j.get("reach").is_none() && pg_j.get("last_heard_unix_ms").is_some_and(|v| v.is_null()), "never heard until the fleet says: {pg_j}");
        assert_eq!((pg.billing.as_str(), pg.paid_until.as_str(), pg.since_unix_ms), ("ready", "2027-04-07", 1_759_900_000_000));
        assert_eq!(pg.vendor, "stripe/gunnar", "billed through gunnar's Stripe");
        assert_eq!(d.payments.len(), 1);
        let p = &d.payments[0];
        assert_eq!((p.reference.as_str(), p.outcome.as_str(), p.needs_you, p.at_unix_ms), ("gunnar/acme/2026-10-01+ab12", "granted", true, 1_759_276_800_000));
    }

    /// ★ UW-8: **"last heard" is the fleet's newest report of the instance's estate** (the
    /// same `Guardian.Cases` as the estates page): the instance `acme` takes estate `acme`'s
    /// newest box report; the SaaS plan stays `None`; an estate the fleet does not know is
    /// never heard.
    #[test]
    fn a_private_instance_is_last_heard_when_its_estate_last_called_home() {
        let inst = |c: &str| pb::AccountRow { kind: "instance".into(), id: c.into(), instance: Some(pb::AccountInstance { company: c.into(), ..Default::default() }), ..Default::default() };
        let r = pb::AccountRowsResponse {
            account: Some(summary("gunnar", "acme", pb::State::Paid)),
            rows: vec![pb::AccountRow { kind: "subscription".into(), fact: Some(pb::EntitlementFact::default()), ..Default::default() }, inst("acme"), inst("ghost")],
        };
        let boxes = |ms: &[(&str, u64)]| ms.iter().map(|(role, heard)| pb::GuardianBox { role: role.to_string(), heard_unix_ms: *heard, ..Default::default() }).collect();
        let fleet = crate::estates::fleet(pb::GuardianCasesResponse {
            estates: vec![
                pb::GuardianEstate { instance: "acme".into(), product: "gunnar".into(), boxes: boxes(&[("main", 7_000), ("twin", 9_000)]), ..Default::default() },
                pb::GuardianEstate { instance: "gunnar".into(), product: "gunnar".into(), boxes: boxes(&[("main", 99)]), ..Default::default() },
            ],
        });
        let mut d = detail_json(&r);
        with_last_heard(&mut d, &fleet);
        assert_eq!(d.rows.iter().map(|r| (r.id.as_str(), r.last_heard_unix_ms)).collect::<Vec<_>>(), [("", None), ("acme", Some(9_000)), ("ghost", None)]);
    }

    /// A SaaS row without an issue time is dated by the row; an instance whose
    /// job stopped carries why.
    #[test]
    fn a_row_without_its_own_date_is_dated_by_the_row_and_a_stopped_instance_says_why() {
        let r = pb::AccountRowsResponse {
            account: Some(summary("gunnar", "bolt", pb::State::Free)),
            rows: vec![
                pb::AccountRow { kind: "subscription".into(), at_unix_ms: 42, fact: Some(pb::EntitlementFact { state: pb::State::Free as i32, ..Default::default() }), ..Default::default() },
                pb::AccountRow { kind: "instance".into(), id: "bolt".into(), at_unix_ms: 43, needs_you: true, instance: Some(pb::AccountInstance { company: "bolt".into(), host: "bolt.gunnar.rs".into(), state: "stopped".into(), why: "lay failed: quota".into(), ..Default::default() }), ..Default::default() },
            ],
        };
        let d = detail_json(&r);
        assert_eq!((d.rows[0].since_unix_ms, d.rows[0].billing.as_str()), (42, "free"));
        assert_eq!((d.rows[1].why.as_str(), d.rows[1].needs_you), ("lay failed: quota", true));
    }
}