Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/src/services/accounts.rs

//! **`Accounts`: the accounts ONE monetize holds**, read side.
//!
//! An account is `(product, tenant)` (`monetize::ledger`). Its rows are the
//! subscription (the signed fact monetize holds), every purchase attempt folded
//! (ledger sales and paid signups, the same lines `Ledger.Sales` answers), and
//! the private instances it owns (their `Instance` rows on the ledger, G5
//! 5.4 — the run file only adds what the job says now). Each account names the vendor
//! instance its product bills through: the product's `[products.<id>] vendor`.
//!
//! Nothing here decides anything. It is assembled on every call from the
//! stores that already hold the truth, so there is no second copy to drift.

use std::collections::BTreeMap;
use std::sync::Arc;

use monetize_pay::Money;
use monetize_product::EntitlementFact;
use monetize_proto::accounts_server::Accounts;
use monetize_proto::{self as pb};
use tonic::{Request, Response, Status};

use super::{blocking, ledger_err, product_or_not_found};
use crate::convert::{fact_to_pb, money_to_pb};
use crate::estate_order::{self, phase, EstateRun};
use crate::state::AppState;

/// The most accounts one `List` answers, whatever the caller asks for.
pub const LIST_CEILING: usize = 500;

pub struct AccountsService(pub Arc<AppState>);

#[tonic::async_trait]
impl Accounts for AccountsService {
    async fn list(
        &self,
        req: Request<pb::ListAccountsRequest>,
    ) -> Result<Response<pb::ListAccountsResponse>, Status> {
        let r = req.into_inner();
        blocking(&self.0, move |s| {
            if !r.product.is_empty() {
                product_or_not_found(s, &r.product)?;
            }
            let want = if r.limit == 0 {
                LIST_CEILING
            } else {
                (r.limit as usize).min(LIST_CEILING)
            };
            let book = gather(s, &r.product)?;
            let mut accounts: Vec<pb::AccountSummary> = book
                .iter()
                .map(|((p, t), rows)| summarise_on(s, p, t, rows))
                .collect();
            accounts.sort_by(|a, b| {
                b.last_unix_ms
                    .cmp(&a.last_unix_ms)
                    .then_with(|| (&a.product, &a.tenant).cmp(&(&b.product, &b.tenant)))
            });
            accounts.truncate(want);
            let vendors = s
                .registry
                .product_ids()
                .into_iter()
                .filter(|p| r.product.is_empty() || *p == r.product)
                .filter_map(|p| s.registry.route_of(p).map(|v| (p.to_string(), v)))
                .collect();
            Ok(Response::new(pb::ListAccountsResponse {
                accounts,
                vendors,
            }))
        })
        .await
    }

    async fn rows(
        &self,
        req: Request<pb::AccountRowsRequest>,
    ) -> Result<Response<pb::AccountRowsResponse>, Status> {
        let r = req.into_inner();
        if r.product.is_empty() || r.tenant.is_empty() {
            return Err(Status::invalid_argument(
                "an account is (product, tenant): name both",
            ));
        }
        blocking(&self.0, move |s| {
            product_or_not_found(s, &r.product)?;
            let mut book = gather(s, &r.product)?;
            let rows = book
                .remove(&(r.product.clone(), r.tenant.clone()))
                .ok_or_else(|| {
                    Status::not_found(format!(
                        "no account ({}, {}) here: no fact, no purchase, no instance",
                        r.product, r.tenant
                    ))
                })?;
            let account = summarise_on(s, &r.product, &r.tenant, &rows);
            Ok(Response::new(pb::AccountRowsResponse {
                account: Some(account),
                rows,
            }))
        })
        .await
    }

    async fn instance_log(
        &self,
        req: Request<pb::InstanceLogRequest>,
    ) -> Result<Response<pb::InstanceLogResponse>, Status> {
        let company = req.into_inner().company;
        blocking(&self.0, move |s| {
            let run = s.estates.get(&company).ok_or_else(|| {
                Status::not_found(format!("no private instance {company:?} here"))
            })?;
            let kept = s
                .estates
                .run_dir(&company)
                .map(|d| estate_order::setup_log::read_tail(&d, LOG_LINES))
                .unwrap_or_default();
            Ok(Response::new(pb::InstanceLogResponse {
                lines: log_lines(&run.history, kept),
            }))
        })
        .await
    }
}

/// How many of the installer's kept lines an instance's log shows (its newest).
pub const LOG_LINES: usize = 400;

/// ★ UW-13: **an instance's setup, as narrated** — the order's phases (`order`, `phase:
/// note`) and the installer's kept lines, merged oldest first.
pub fn log_lines(
    history: &[estate_order::Transition],
    kept: Vec<estate_order::setup_log::KeptLine>,
) -> Vec<pb::InstanceLogLine> {
    let phases = history.iter().map(|t| pb::InstanceLogLine {
        at_unix_ms: t.at_ms,
        source: "order".into(),
        line: if t.note.is_empty() {
            t.phase.clone()
        } else if t.phase.is_empty() {
            t.note.clone()
        } else {
            format!("{}: {}", t.phase, t.note)
        },
    });
    let said = kept.into_iter().map(|l| pb::InstanceLogLine {
        at_unix_ms: l.at_unix_ms,
        source: l.source,
        line: l.line,
    });
    let mut lines: Vec<pb::InstanceLogLine> = phases.chain(said).collect();
    lines.sort_by_key(|l| l.at_unix_ms);
    lines
}

/// The vendor instance `product` bills through, empty for a free product.
fn route(s: &AppState, product: &str) -> String {
    s.registry.route_of(product).unwrap_or_default()
}

/// ★ ALICA-5 (2026-10-26): **the vendor instance that bills `tenant` of `product`** — its
/// SITE's own (`@<estate>/<name>`: that estate's `vendor`; a plain name: the product's home
/// estate's), the route every order and signup of that site takes
/// ([`crate::registry::Registry::route_for`]). The product's own route only where no site
/// says one. Live 2026-10-26: `@test2-gunnar-rs/alica` paid through stripe/gunnar-test2 and
/// the page said stripe/gunnar, the product's.
pub(crate) fn vendor_of(s: &AppState, product: &str, tenant: &str) -> String {
    let estate = monetize_product::TenantId(tenant.to_owned())
        .estate()
        .map(|(e, _)| e.to_owned())
        .or_else(|| s.registry.home_of(product));
    s.registry
        .route_for(product, estate.as_deref())
        .ok()
        .filter(|v| !v.trim().is_empty())
        .unwrap_or_else(|| route(s, product))
}

/// Pure: **the vendor a signup sale's reference names** (`signup:<vendor>:<session>`,
/// [`crate::signup::signup_sales`]) — the vendor that took that money; `None` for another
/// reference.
pub(crate) fn signup_vendor(reference: &str) -> Option<&str> {
    let (vendor, session) = reference.strip_prefix("signup:")?.rsplit_once(':')?;
    (!vendor.trim().is_empty() && !session.trim().is_empty()).then_some(vendor)
}

/// Pure: **the account's money mode** — what its drawn sales said (live wins; all test is
/// test), else, when they say nothing, the mode of the key its vendor holds (ALICA-5: an
/// account with only an instance or a given fact read "mode not said" though the box knows
/// its key is live).
pub(crate) fn mode_of(drawn: &[Option<bool>], key: Option<bool>) -> Option<bool> {
    if drawn.contains(&Some(true)) {
        Some(true)
    } else if !drawn.is_empty() && drawn.iter().all(|l| *l == Some(false)) {
        Some(false)
    } else {
        key
    }
}

/// [`summarise`] with the account's own vendor ([`vendor_of`]) and its key's mode.
fn summarise_on(
    s: &AppState,
    product: &str,
    tenant: &str,
    rows: &[pb::AccountRow],
) -> pb::AccountSummary {
    let vendor = vendor_of(s, product, tenant);
    let key = s.registry.find_vendor(&vendor).and_then(|v| v.livemode());
    summarise(product, tenant, &vendor, key, rows)
}

type Book = BTreeMap<(String, String), Vec<pb::AccountRow>>;

/// Every account of `product` (or of every product, when empty), each with
/// its rows in the order `Rows` answers them.
fn gather(s: &AppState, product: &str) -> Result<Book, Status> {
    let wanted = |p: &str| product.is_empty() || p == product;
    let mut book = Book::new();
    let mut put = |p: &str, t: &str, row: pb::AccountRow| {
        book.entry((p.to_string(), t.to_string()))
            .or_default()
            .push(row)
    };

    for p in s.registry.product_ids().into_iter().filter(|p| wanted(p)) {
        for fact in s.ledger.entitlements(p).map_err(ledger_err)? {
            let vendor = vendor_of(s, p, &fact.tenant.0);
            put(p, &fact.tenant.0, subscription_row(&fact, &vendor));
        }
    }
    for sale in s
        .ledger
        .sales()
        .map_err(ledger_err)?
        .iter()
        .filter(|x| wanted(&x.product))
    {
        let vendor = sale
            .vendor
            .clone()
            .filter(|v| !v.trim().is_empty())
            .unwrap_or_else(|| vendor_of(s, &sale.product, &sale.tenant));
        put(
            &sale.product,
            &sale.tenant,
            purchase_row(super::ledger::sale_to_pb(sale), vendor),
        );
    }
    // A paid signup is a purchase too, though it writes no ledger row. Its vendor is the one
    // its own reference names: the vendor that took that money.
    for sale in crate::signup::signup_sales(s, product)
        .map_err(|e| Status::internal(format!("signup store: {e}")))?
    {
        let vendor = signup_vendor(&sale.reference)
            .map(str::to_owned)
            .unwrap_or_else(|| vendor_of(s, &sale.product, &sale.tenant));
        let (p, t) = (sale.product.clone(), sale.tenant.clone());
        put(&p, &t, purchase_row(sale, vendor));
    }
    // **The products a private instance belongs to**: the box's own estate product's lifecycle
    // kind (`gunnar` on a box that sells Gunnar) and every other product it serves (T-OWNERFLOW
    // 2026-10-26: a Holger instance bought on a Holger site is booked on the ledger under
    // `holger`, and the page read `gunnar`'s alone, so it never showed).
    let base = estate_order::instance_product(s);
    let mut kinds = vec![base.clone()];
    for p in s.registry.product_ids() {
        if !kinds.iter().any(|k| k == p) {
            kinds.push(p.to_string());
        }
    }
    let base_apex = s
        .estates
        .cfg
        .as_ref()
        .map(|c| c.apex.clone())
        .unwrap_or_default();
    for instance in kinds.iter().filter(|p| wanted(p)) {
        // The LEDGER's rows (G5): an instance is on the account its order was
        // proven for, whether or not its run file is read here.
        for booked in s.ledger.instances(instance).map_err(ledger_err)? {
            // ★ ST-4/ST-5: booked on its own site's account, named on that site.
            let live = s.estates.get(&booked.company).filter(|run| {
                crate::estate_order::ledger_account(s, run) == booked.tenant
                    && run.ordered_at_ms == booked.ordered_unix_ms
            });
            let terms = live
                .as_ref()
                .and_then(|run| crate::estate_order::run_terms(s, run));
            let apex = terms
                .as_ref()
                .map(|c| c.apex.clone())
                .unwrap_or_else(|| base_apex.clone());
            // The run's own terms name its product; the box's own for its own product.
            let words = terms.as_ref().or(if *instance == base {
                s.estates.cfg.as_ref()
            } else {
                None
            });
            let title = estate_order::lifecycle::words_of(words).title;
            let line = Instance::of(
                &booked,
                live.as_ref(),
                &apex,
                &vendor_of(s, instance, &booked.tenant),
                &title,
            );
            let tenant = line.tenant.clone();
            put(instance, &tenant, line.row());
        }
    }
    for rows in book.values_mut() {
        order(rows);
    }
    Ok(book)
}

/// The subscription first, then the rest newest first.
fn order(rows: &mut [pb::AccountRow]) {
    rows.sort_by(|a, b| {
        (b.kind == "subscription")
            .cmp(&(a.kind == "subscription"))
            .then_with(|| b.at_unix_ms.cmp(&a.at_unix_ms))
            .then_with(|| a.id.cmp(&b.id))
    });
}

fn summarise(
    product: &str,
    tenant: &str,
    vendor: &str,
    key_mode: Option<bool>,
    rows: &[pb::AccountRow],
) -> pb::AccountSummary {
    let fact = rows.iter().find_map(|r| r.fact.as_ref());
    let drawn: Vec<Option<bool>> = rows
        .iter()
        .filter_map(|r| r.sale.as_ref())
        .filter(|s| s.drawn)
        .map(|s| s.livemode)
        .collect();
    let livemode = mode_of(&drawn, key_mode);
    pb::AccountSummary {
        product: product.to_string(),
        tenant: tenant.to_string(),
        vendor: vendor.to_string(),
        state: fact
            .map(|f| f.state)
            .unwrap_or(pb::State::Unspecified as i32),
        paid_until_unix_ms: fact.and_then(|f| f.paid_until_unix_ms),
        rows: rows.len() as u32,
        instances: rows.iter().filter(|r| r.kind == "instance").count() as u32,
        last_unix_ms: rows.iter().map(|r| r.at_unix_ms).max().unwrap_or(0),
        needs_you: rows.iter().any(|r| r.needs_you),
        livemode,
    }
}

fn subscription_row(fact: &EntitlementFact, vendor: &str) -> pb::AccountRow {
    let until = fact
        .paid_until_unix_ms
        .map(|ms| format!(" until {}", estate_order::iso_date(ms)))
        .unwrap_or_default();
    let caps = caps_words(&fact.caps);
    pb::AccountRow {
        kind: "subscription".into(),
        id: String::new(),
        vendor: vendor.to_string(),
        words: format!(
            "{:?}{until}{}",
            fact.state,
            if caps.is_empty() {
                String::new()
            } else {
                format!(" · {caps}")
            }
        ),
        at_unix_ms: fact.issued_unix_ms.unwrap_or(0),
        needs_you: false,
        fact: Some(fact_to_pb(fact)),
        ..Default::default()
    }
}

fn purchase_row(sale: pb::Sale, vendor: String) -> pb::AccountRow {
    let price = sale.price.as_ref().map(pb_money_words).unwrap_or_default();
    let caps = caps_words(&sale.caps);
    pb::AccountRow {
        kind: "purchase".into(),
        id: sale.reference.clone(),
        vendor,
        words: [sale.outcome.as_str(), caps.as_str(), price.as_str()]
            .iter()
            .filter(|w| !w.is_empty())
            .copied()
            .collect::<Vec<_>>()
            .join(" · "),
        at_unix_ms: sale.at_unix_ms,
        needs_you: sale.needs_you,
        sale: Some(sale),
        ..Default::default()
    }
}

/// **One private instance, as an account row.** Built from its ledger line
/// ([`monetize::ledger::InstanceLine`]), with the live run's state and why
/// when this monetize holds it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Instance {
    /// The account it lands on: the ledger row's tenant — the account the
    /// order was proven for. Never the company.
    pub tenant: String,
    pub company: String,
    pub host: String,
    pub phase: String,
    pub state: String,
    pub monthly: Money,
    pub paid_until_ms: u64,
    pub why: String,
    pub vendor: String,
    pub last_ms: u64,
    /// The operator's own buy: an operator test, never revenue.
    pub operator_test: bool,
    /// What the buyer calls the product (its lifecycle's words: `Gunnar`).
    pub title: String,
}

impl Instance {
    pub(crate) fn of(
        line: &monetize::ledger::InstanceLine,
        live: Option<&EstateRun>,
        apex: &str,
        fallback_vendor: &str,
        title: &str,
    ) -> Instance {
        let (state, why) = match live {
            // The operator's console: a setup that failed after payment reads `owed`.
            Some(run) => (
                run.operator_view_as(
                    apex,
                    &crate::estate_order::lifecycle::ProductWords::neutral(),
                )
                .0,
                run.why.clone(),
            ),
            None => (line.phase.clone(), String::new()),
        };
        Instance {
            tenant: line.tenant.clone(),
            company: line.company.clone(),
            host: if line.host.is_empty() {
                format!("{}.{apex}", line.company)
            } else {
                line.host.clone()
            },
            phase: line.phase.clone(),
            state,
            monthly: line.monthly,
            paid_until_ms: line.paid_until_unix_ms,
            why,
            vendor: if line.vendor.is_empty() {
                fallback_vendor.to_string()
            } else {
                line.vendor.clone()
            },
            last_ms: line.last_unix_ms,
            operator_test: line.operator_test,
            title: title.to_string(),
        }
    }

    pub(crate) fn row(&self) -> pb::AccountRow {
        let monthly_words = if self.operator_test {
            format!(
                "{} a month · operator test, not revenue",
                self.monthly.in_words()
            )
        } else {
            format!("{} a month", self.monthly.in_words())
        };
        pb::AccountRow {
            kind: "instance".into(),
            id: self.company.clone(),
            vendor: self.vendor.clone(),
            words: format!(
                "Private {} {} · {} · {monthly_words}",
                self.title, self.company, self.phase
            ),
            at_unix_ms: self.last_ms,
            // The two phases an operator has to act on: a job that stopped,
            // and money taken for an estate that does not run yet.
            needs_you: self.phase == phase::FAILED || self.phase == phase::OWED,
            instance: Some(pb::AccountInstance {
                kind: "estate".into(),
                company: self.company.clone(),
                host: self.host.clone(),
                phase: self.phase.clone(),
                state: self.state.clone(),
                monthly: Some(money_to_pb(self.monthly)),
                monthly_words,
                paid_until: if self.paid_until_ms == 0 {
                    String::new()
                } else {
                    estate_order::iso_date(self.paid_until_ms)
                },
                why: self.why.clone(),
            }),
            ..Default::default()
        }
    }
}

/// `pack_bytes = 20 GiB` reads `20 GiB`; any other meter reads `meter value`.
fn caps_words(caps: &BTreeMap<String, u64>) -> String {
    const GIB: u64 = 1 << 30;
    caps.iter()
        .map(|(meter, v)| {
            if meter.ends_with("_bytes") {
                if v % GIB == 0 {
                    format!("{} GiB", v / GIB)
                } else {
                    format!("{:.2} GiB", *v as f64 / GIB as f64)
                }
            } else {
                format!("{meter} {v}")
            }
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn pb_money_words(m: &pb::Money) -> String {
    let code = match m.currency() {
        pb::Currency::Sek => "SEK",
        pb::Currency::Eur => "EUR",
        pb::Currency::Dkk => "DKK",
        pb::Currency::Unspecified => "",
    };
    format!("{}.{:02} {code}", m.minor / 100, m.minor % 100)
        .trim_end()
        .to_string()
}

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

    fn instance(company: &str, tenant: &str, phase: &str, last_ms: u64) -> Instance {
        Instance {
            tenant: tenant.into(),
            company: company.into(),
            host: format!("{company}.gunnar.rs"),
            phase: phase.into(),
            state: "ready".into(),
            monthly: Money {
                minor: 13_200,
                currency: Currency::Eur,
            },
            paid_until_ms: 0,
            why: String::new(),
            vendor: "stripe/gunnar".into(),
            last_ms,
            operator_test: false,
            title: "Gunnar".into(),
        }
    }

    /// ★ ALICA-5 (live 2026-10-26): **an account on a site names THAT site's vendor**. The
    /// owner paid on test2.gunnar.rs through stripe/gunnar-test2; the Accounts page said
    /// stripe/gunnar, the product's route. The home site's account keeps the home's vendor; a
    /// signup sale names the vendor in its own reference.
    #[tokio::test]
    async fn an_account_on_a_site_names_that_sites_vendor() {
        use monetize_proto::accounts_server::Accounts as _;
        let wrap = |id: &str| {
            monetize_pay::instance::Instance::wrap(id, Box::new(monetize::testing::MockPay::new()))
                .unwrap()
        };
        let site = |vendor: &str, site: &str| crate::config::EstateConfig {
            product: "gunnar".into(),
            vendor: vendor.into(),
            site: site.into(),
            ..Default::default()
        };
        let reg = crate::registry::Registry::new()
            .product(monetize::testing::MockProduct::gunnar())
            .vendor(wrap("stripe/gunnar"))
            .vendor(wrap("stripe/gunnar-test2"))
            .route("gunnar", "stripe/gunnar")
            .home("gunnar", "gunnar-rs")
            .estate("gunnar-rs", site("stripe/gunnar", "gunnar.rs"))
            .estate(
                "test2-gunnar-rs",
                site("stripe/gunnar-test2", "test2.gunnar.rs"),
            );
        let s = Arc::new(AppState::in_memory(reg));
        for tenant in ["@test2-gunnar-rs/alica", "bob"] {
            let fact = monetize_product::EntitlementFact {
                tenant: monetize_product::TenantId(tenant.into()),
                plan: "gunnar/10gb-1m-fi".into(),
                state: monetize_product::State::Paid,
                paid_until_unix_ms: Some(1_795_000_000_000),
                caps: [("pack_bytes".to_string(), 10 << 30)].into(),
                source: "operator".into(),
                signature: vec![],
                issued_unix_ms: None,
                issued_signature: vec![],
            };
            s.ledger
                .set_entitlement("gunnar", &s.signer_for("gunnar").sign_fact(fact))
                .unwrap();
        }
        let out = AccountsService(s.clone())
            .list(Request::new(pb::ListAccountsRequest::default()))
            .await
            .unwrap()
            .into_inner();
        let vendor_of = |t: &str| {
            out.accounts
                .iter()
                .find(|a| a.tenant == t)
                .map(|a| a.vendor.clone())
                .unwrap_or_default()
        };
        assert_eq!(
            vendor_of("@test2-gunnar-rs/alica"),
            "stripe/gunnar-test2",
            "test2's account is billed through test2's vendor: {out:?}"
        );
        assert_eq!(
            vendor_of("bob"),
            "stripe/gunnar",
            "the home's account keeps the home's vendor"
        );
        let rows = AccountsService(s.clone())
            .rows(Request::new(pb::AccountRowsRequest {
                product: "gunnar".into(),
                tenant: "@test2-gunnar-rs/alica".into(),
            }))
            .await
            .unwrap()
            .into_inner();
        assert_eq!(
            rows.rows[0].vendor, "stripe/gunnar-test2",
            "its SaaS row too"
        );
        assert_eq!(rows.account.unwrap().vendor, "stripe/gunnar-test2");
    }

    /// ★ ALICA-5: a signup sale's vendor is the one its reference names (the vendor that took
    /// the money), never guessed from the product.
    #[test]
    fn a_signup_sale_names_the_vendor_in_its_reference() {
        assert_eq!(
            signup_vendor("signup:stripe/gunnar-test2:cs_live_a1"),
            Some("stripe/gunnar-test2")
        );
        assert_eq!(signup_vendor("signup:mock:cs_1"), Some("mock"));
        for other in [
            "estate/acme",
            "signup:",
            "signup:stripe/gunnar:",
            "signup::cs_1",
            "purchase:gunnar:x",
        ] {
            assert_eq!(signup_vendor(other), None, "{other}");
        }
    }

    /// ★ ALICA-5: **the mode is said whenever the box knows it**: the drawn sales' word first
    /// (live wins), else the vendor key's own mode — "mode not said" only where neither says.
    #[test]
    fn an_account_says_its_mode_from_its_sales_else_its_vendors_key() {
        assert_eq!(
            mode_of(&[Some(false), Some(true)], Some(false)),
            Some(true),
            "one live sale is live money"
        );
        assert_eq!(
            mode_of(&[Some(false)], Some(true)),
            Some(false),
            "test sales are test, whatever the key now"
        );
        assert_eq!(
            mode_of(&[], Some(true)),
            Some(true),
            "no sale: the key's mode"
        );
        assert_eq!(
            mode_of(&[None], Some(false)),
            Some(false),
            "sales that say nothing: the key's mode"
        );
        assert_eq!(mode_of(&[], None), None, "nobody says");
        let sub = pb::AccountRow {
            kind: "subscription".into(),
            ..Default::default()
        };
        assert_eq!(
            summarise(
                "gunnar",
                "@test2-gunnar-rs/alica",
                "stripe/gunnar-test2",
                Some(true),
                &[sub]
            )
            .livemode,
            Some(true),
            "an account with no drawn sale says its key's mode"
        );
    }

    /// A Private Gunnar is a row on its owner's gunnar account, beside the
    /// subscription, and the account counts it as an instance.
    #[test]
    fn an_instance_is_a_row_on_its_owners_account() {
        let sub = pb::AccountRow {
            kind: "subscription".into(),
            at_unix_ms: 5,
            ..Default::default()
        };
        let mut rows = vec![
            instance("acme", "acme", phase::CAPTURED, 10).row(),
            sub,
            instance("acme2", "acme", phase::FAILED, 20).row(),
        ];
        order(&mut rows);
        let kinds: Vec<(&str, &str)> = rows
            .iter()
            .map(|r| (r.kind.as_str(), r.id.as_str()))
            .collect();
        assert_eq!(
            kinds,
            vec![
                ("subscription", ""),
                ("instance", "acme2"),
                ("instance", "acme")
            ]
        );
        let a = summarise("gunnar", "acme", "stripe/gunnar", None, &rows);
        assert_eq!(
            (a.rows, a.instances, a.last_unix_ms, a.needs_you),
            (3, 2, 20, true),
            "a failed job waits on the operator"
        );
        let row = &rows[2];
        assert_eq!(row.vendor, "stripe/gunnar");
        assert_eq!(
            row.words,
            "Private Gunnar acme · captured · 132.00 EUR a month"
        );
        let line = row.instance.as_ref().unwrap();
        assert_eq!(
            (
                line.kind.as_str(),
                line.host.as_str(),
                line.monthly_words.as_str()
            ),
            ("estate", "acme.gunnar.rs", "132.00 EUR a month")
        );
    }

    /// ★ UW-13: the order's phases and the installer's kept lines, one log, oldest first;
    /// a phase change says its phase and its note.
    #[test]
    fn an_instances_log_is_its_phases_and_its_installers_lines_in_order() {
        use crate::estate_order::setup_log::KeptLine;
        use crate::estate_order::Transition;
        let history = vec![
            Transition {
                phase: "held".into(),
                at_ms: 10,
                note: "the money is held".into(),
            },
            Transition {
                phase: "laid".into(),
                at_ms: 30,
                note: String::new(),
            },
        ];
        let kept = vec![
            KeptLine {
                at_unix_ms: 20,
                source: "installer".into(),
                phase: "laying".into(),
                line: "front up".into(),
            },
            KeptLine {
                at_unix_ms: 40,
                source: "acme-main".into(),
                phase: "installing".into(),
                line: "serving".into(),
            },
        ];
        let lines = log_lines(&history, kept);
        assert_eq!(
            lines
                .iter()
                .map(|l| (l.at_unix_ms, l.source.as_str(), l.line.as_str()))
                .collect::<Vec<_>>(),
            [
                (10, "order", "held: the money is held"),
                (20, "installer", "front up"),
                (30, "order", "laid"),
                (40, "acme-main", "serving")
            ]
        );
    }

    #[test]
    fn caps_read_in_gib() {
        let caps = BTreeMap::from([
            ("pack_bytes".to_string(), 20u64 << 30),
            ("seats".to_string(), 3),
        ]);
        assert_eq!(caps_words(&caps), "20 GiB, seats 3");
    }
}