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