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));
}
}