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

nordisk/monetize

crates/monetize-site-buy/src/purchase.rs

//! **The account's grow, as the browser sees it** — moved as it is from Gunnar's console
//! (gunnar-ui-server `api::increase` `Window`/`window_of`/`render_*`, `api::subscription`
//! `Attempt`/`phase_word`/`state_name`/`order_action`/`target_caps`), over monetize-proto's
//! types. Every string is monetize's; nothing here prices or composes a sentence.

use std::collections::BTreeMap;

use monetize_proto as mpb;
use serde::{Deserialize, Serialize};

/// `Bundle` as the browser sees it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Bundle {
    pub id: String,
    pub label: String,
    /// The amount, as monetize rendered it (`Bundle.price` on the wire is already words).
    pub price_words: String,
    pub period: String,
    /// ★ A32-29: `100.00 SEK / month + VAT (…)`, composed here once ([`price_line`]); empty
    /// when there is no amount. The console draws it beside the pay button.
    pub price_line: String,
    /// ★ A32-29: the offer button's words, `+10 GiB storage — 100.00 SEK / month`, composed
    /// here once ([`offer_words`]); the console draws them on the button.
    pub offer_words: String,
    pub delta: BTreeMap<String, u64>,
    pub can_serve: bool,
    pub reason: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub action: String,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub target: BTreeMap<String, u64>,
}

/// **The account's plan, as monetize words it** (`ListBundlesResponse.plan`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Plan {
    pub state: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub period: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub label: String,
    pub cap_bytes: u64,
    pub paid_until_unix_ms: u64,
    /// ★ A32-29: the paid-until day as words ([`date_words`]); empty when there is none.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub paid_until_words: String,
    pub days_left: u32,
    pub days_total: u32,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub ends_words: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub price_words: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub banner: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub banner_action: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub banner_tone: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub bump_words: String,
    pub bump_min_bytes: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub progress: Option<RoomLine>,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub progress_reference: String,
}

/// **A day as the browser shows it**: `YYYY-MM-DD` of a unix time in milliseconds (UTC); empty
/// for 0 (no day). A32-29: the one copy; the consoles carried their own calendar arithmetic.
pub fn date_words(ms: u64) -> String {
    if ms == 0 {
        return String::new();
    }
    let days = (ms / 86_400_000) as i64;
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}-{m:02}-{d:02}")
}

/// `PlanStatus` as the browser sees it.
pub fn render_plan(p: mpb::PlanStatus) -> Plan {
    Plan {
        state: p.state,
        period: p.period,
        label: p.label,
        cap_bytes: p.cap_bytes,
        paid_until_unix_ms: p.paid_until_unix_ms,
        paid_until_words: date_words(p.paid_until_unix_ms),
        days_left: p.days_left,
        days_total: p.days_total,
        ends_words: p.ends_words,
        price_words: p.price_words,
        banner: p.banner,
        banner_action: p.banner_action,
        banner_tone: p.banner_tone,
        bump_words: p.bump_words,
        bump_min_bytes: p.bump_min_bytes,
        progress: p.progress.map(|r| RoomLine {
            state: r.state,
            words: r.words,
        }),
        progress_reference: p.progress_reference,
    }
}

/// `Bundle` as the browser sees it.
pub fn render_bundle(b: mpb::Bundle) -> Bundle {
    let offer_words = offer_words(
        &b.label,
        &b.delta.iter().map(|(k, v)| (k.clone(), *v)).collect(),
        &b.price,
        &b.period,
    );
    Bundle {
        id: b.id,
        label: b.label,
        price_line: price_line(&b.price, &b.period),
        offer_words,
        price_words: b.price,
        period: b.period,
        delta: b.delta.into_iter().collect(),
        can_serve: b.can_serve,
        reason: b.reason,
        action: b.action,
        target: b.target.into_iter().collect(),
    }
}

/// **The shop window: only what this box can sell** (owner 2026-09-20: a bundle the box cannot
/// serve is not rendered at all; a PLAN row keeps its refusal).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Window {
    pub offers: Vec<Bundle>,
    pub withheld: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub unavailable: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pending: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub late: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub plan: Option<Plan>,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub market: String,
    /// ★ A32-29: **when the console asks again** ([`POLL`]), the server's schedule; only on a
    /// window still being read (an answered window is not asked again).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub poll: Option<Poll>,
}

/// **How a console follows a window that is still being read** (A32-29: the schedule is the
/// server's, sent with every window): ask again every `again_ms`; past `late_after_ms` draw the
/// `late` line and ask every `late_again_ms`; stop at `give_up_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Poll {
    pub again_ms: u32,
    pub late_after_ms: u32,
    pub late_again_ms: u32,
    pub give_up_ms: u32,
}

/// The one schedule (it was gunnar-ui-web's `offers::next_poll_ms`, MEASURED 2026-10-05).
pub const POLL: Poll = Poll {
    again_ms: 2_000,
    late_after_ms: 60_000,
    late_again_ms: 10_000,
    give_up_ms: 10 * 60_000,
};

impl Poll {
    /// Whether `asked_ms` of asking is past [`Poll::late_after_ms`].
    pub fn is_late(&self, asked_ms: u32) -> bool {
        asked_ms >= self.late_after_ms
    }

    /// How long to wait before asking again after `asked_ms` of asking; `None`: stop.
    pub fn next_ms(&self, asked_ms: u32) -> Option<u32> {
        if asked_ms >= self.give_up_ms {
            None
        } else if self.is_late(asked_ms) {
            Some(self.late_again_ms)
        } else {
            Some(self.again_ms)
        }
    }
}

/// The line beside the spinner while monetize's cloud reading runs.
pub const AWAITING_PROVIDER: &str = "Awaiting the cloud provider…";
/// The line once the reading is still running after a minute.
pub const PROVIDER_LATE: &str =
    "The cloud provider has not answered yet. The sizes on offer appear here as soon as it does.";
/// The line under an empty shop that has rows.
pub const NOTHING_FITS: &str = "None of the sizes on offer can be added to this account right now.";

/// **monetize's list, reduced to the rows it can serve.**
pub fn window(bundles: Vec<mpb::Bundle>) -> Window {
    let declared = bundles.len();
    let offers: Vec<Bundle> = bundles
        .into_iter()
        .filter(|b| {
            // A PLAN row keeps its refusal: it is the customer's next step, not a size the box
            // cannot hold (owner, 2026-10-05).
            if !b.action.is_empty() {
                return true;
            }
            if !b.can_serve {
                tracing::info!(target: crate::relay::AUDIT_TARGET, bundle = %b.id, label = %b.label, monetize_said = %b.reason, "a bundle monetize cannot serve is left out of the window");
            }
            b.can_serve
        })
        .map(render_bundle)
        .collect();
    let withheld = declared - offers.len();
    Window {
        unavailable: (offers.is_empty() && withheld > 0).then(|| NOTHING_FITS.to_owned()),
        offers,
        withheld,
        pending: None,
        late: None,
        plan: None,
        market: String::new(),
        poll: None,
    }
}

/// **monetize's whole answer to `Purchase.Bundles`**: [`window`] over its rows, or — while the
/// window is still being read — no rows and the two lines the panel draws while it asks again.
pub fn window_of(answer: mpb::ListBundlesResponse) -> Window {
    if answer.reading {
        return Window {
            offers: Vec::new(),
            withheld: 0,
            unavailable: None,
            pending: Some(AWAITING_PROVIDER.to_owned()),
            late: Some(PROVIDER_LATE.to_owned()),
            plan: None,
            market: String::new(),
            poll: Some(POLL),
        };
    }
    let plan = answer.plan.map(render_plan);
    Window {
        plan,
        ..window(answer.bundles)
    }
}

/// monetize's `RoomProgress`, for the page.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomLine {
    pub state: String,
    pub words: String,
}

/// monetize's `UsersGrant`, for the page.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsersLine {
    pub principal: String,
    pub seats: u32,
    pub seats_open: u32,
    pub room_bytes: u64,
    pub need_bytes: u64,
    pub from_unused_bytes: u64,
    pub bought_bytes: u64,
    pub granted: bool,
    pub invited: String,
    pub refunded: bool,
    pub reason: String,
}

impl From<mpb::UsersGrant> for UsersLine {
    fn from(u: mpb::UsersGrant) -> UsersLine {
        UsersLine {
            principal: u.principal,
            seats: u.seats,
            seats_open: u.seats_open,
            room_bytes: u.room_bytes,
            need_bytes: u.need_bytes,
            from_unused_bytes: u.from_unused_bytes,
            bought_bytes: u.bought_bytes,
            granted: u.granted,
            invited: u.invited,
            refunded: u.refunded,
            reason: u.reason,
        }
    }
}

/// **Where the buyer is sent, and how the site follows what happens there.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Attempt {
    pub reference: String,
    pub state: &'static str,
    pub url: String,
    pub reason: String,
    pub words: String,
    #[serde(default)]
    pub before: BTreeMap<String, u64>,
    #[serde(default)]
    pub after: BTreeMap<String, u64>,
    #[serde(default)]
    pub captured: bool,
    #[serde(default)]
    pub price_words: String,
    #[serde(default)]
    pub period: String,
    /// ★ A32-29: the chosen offer's price line ([`price_line`]); empty when there is no amount.
    #[serde(default)]
    pub price_line: String,
    #[serde(default)]
    pub phase: String,
    #[serde(default)]
    pub cancellable: bool,
    #[serde(default)]
    pub users: Option<UsersLine>,
    #[serde(default)]
    pub room: Option<RoomLine>,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub action: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub paid_until_unix_ms: Option<u64>,
    /// ★ A32-29: that day as words ([`date_words`]); absent while the order names no day.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub paid_until_words: String,
}

/// monetize's `PurchasePhase` as the word the browser reads; empty when unset.
pub fn phase_word(wire: i32) -> String {
    match mpb::PurchasePhase::try_from(wire) {
        Ok(mpb::PurchasePhase::WaitingForPayment) => "waiting_for_payment",
        Ok(mpb::PurchasePhase::PaidAdding) => "paid_adding",
        Ok(mpb::PurchasePhase::Done) => "done",
        Ok(mpb::PurchasePhase::Cancelled) => "cancelled",
        Ok(mpb::PurchasePhase::Declined) => "declined",
        Ok(mpb::PurchasePhase::Refunded) => "refunded",
        _ => "",
    }
    .to_owned()
}

/// monetize's purchase ladder as one lower-case word.
pub fn state_name(wire: i32) -> &'static str {
    match mpb::PurchaseState::try_from(wire) {
        Ok(mpb::PurchaseState::Pending) => "pending",
        Ok(mpb::PurchaseState::Paid) => "paid",
        Ok(mpb::PurchaseState::Declined) => "declined",
        Ok(mpb::PurchaseState::Released) => "released",
        _ => "unknown",
    }
}

/// **What an order does to the plan, off the order's own `Reserved` row**, and its period.
pub fn order_action(rows: &[mpb::LedgerRow]) -> (String, String) {
    let none = (String::new(), String::new());
    let Some(row) = rows.iter().rev().find(|r| r.kind == "Reserved") else {
        return none;
    };
    let Ok(step) = serde_json::from_str::<serde_json::Value>(&row.json) else {
        return none;
    };
    let Some(purchase) = step
        .get("Reserved")
        .and_then(|r| r.get("purchase"))
        .or_else(|| step.get("purchase"))
    else {
        return none;
    };
    if let Some(plan) = purchase.get("plan").filter(|p| !p.is_null()) {
        let verb = plan
            .get("verb")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned();
        let period = plan
            .get("period")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_lowercase();
        return (verb, period);
    }
    let sold_nothing = purchase
        .get("delta")
        .and_then(|d| d.as_object())
        .is_some_and(|d| d.is_empty());
    let no_users = purchase.get("users").is_none_or(|u| u.is_null());
    if sold_nothing && no_users {
        ("renew".to_owned(), String::new())
    } else {
        none
    }
}

/// **The caps a bundle order resolves to**: what the tenant holds, each of the bundle's meters
/// raised by its delta (saturating) — monetize's own `target_of`, so THE TICKET MATCHES.
pub fn target_caps(
    current: &BTreeMap<String, u64>,
    delta: &BTreeMap<String, u64>,
) -> BTreeMap<String, u64> {
    let mut target = current.clone();
    for (meter, add) in delta {
        let held = current.get(meter).copied().unwrap_or(0);
        target.insert(meter.clone(), held.saturating_add(*add));
    }
    target
}

/// **The caps the order for `chosen` names** (the ticket covers exactly these): a renewal
/// (`prolong`/`renew`) holds what it holds; a plan row ends at its `target`; a plain bundle is
/// the held caps raised by its delta.
pub fn order_caps(chosen: &mpb::Bundle, held: &BTreeMap<String, u64>) -> BTreeMap<String, u64> {
    if is_renewal(chosen) {
        held.clone()
    } else if !chosen.target.is_empty() {
        chosen.target.clone().into_iter().collect()
    } else {
        target_caps(held, &chosen.delta.clone().into_iter().collect())
    }
}

/// A `prolong` or `renew` row is a RENEWAL: the renew ticket, no caps raised.
pub fn is_renewal(chosen: &mpb::Bundle) -> bool {
    matches!(chosen.action.as_str(), "prolong" | "renew")
}

/// **The attempt a bundle order answers**: before and after for the meters it raises only.
pub fn bundle_attempt(
    started: mpb::StartPurchaseResponse,
    chosen: &mpb::Bundle,
    held: &BTreeMap<String, u64>,
    caps: &BTreeMap<String, u64>,
    url: String,
) -> Attempt {
    let raised: std::collections::BTreeSet<&String> = chosen.delta.keys().collect();
    let pick = |m: &BTreeMap<String, u64>| {
        m.iter()
            .filter(|(k, _)| raised.contains(k))
            .map(|(k, v)| (k.clone(), *v))
            .collect()
    };
    Attempt {
        reference: started.reference,
        state: state_name(started.state),
        url,
        reason: started.reason,
        words: started.words,
        before: pick(held),
        after: pick(caps),
        captured: false,
        price_words: chosen.price.clone(),
        period: chosen.period.clone(),
        price_line: price_line(&chosen.price, &chosen.period),
        phase: String::new(),
        cancellable: false,
        users: None,
        room: None,
        action: chosen.action.clone(),
        paid_until_unix_ms: None,
        paid_until_words: String::new(),
    }
}

/// **The attempt a status poll answers** (the way back from the vendor).
pub fn status_attempt(progress: mpb::PurchaseStatusResponse, url: String) -> Attempt {
    let phase = phase_word(progress.phase);
    let captured = phase == "paid_adding";
    let users = progress.users.clone().map(UsersLine::from);
    let room = progress.room.clone().map(|r| RoomLine {
        state: r.state,
        words: r.words,
    });
    let (action, period) = order_action(&progress.rows);
    let paid = progress.state == mpb::PurchaseState::Paid as i32;
    let paid_until_unix_ms = progress
        .fact
        .as_ref()
        .filter(|_| paid)
        .and_then(|fact| fact.paid_until_unix_ms);
    let after = if paid {
        progress
            .fact
            .map(|fact| fact.caps.into_iter().collect())
            .unwrap_or_default()
    } else {
        Default::default()
    };
    Attempt {
        reference: progress.reference,
        state: state_name(progress.state),
        url,
        reason: progress.reason,
        words: String::new(),
        before: Default::default(),
        after,
        captured,
        price_words: String::new(),
        period,
        price_line: String::new(),
        phase,
        cancellable: progress.cancellable,
        users,
        room,
        action,
        paid_until_words: paid_until_unix_ms.map(date_words).unwrap_or_default(),
        paid_until_unix_ms,
    }
}

/// **The attempt a cancel answers**: the order closed, nothing to pay (Gunnar's console's).
pub fn cancelled_attempt(closed: mpb::CancelPurchaseResponse) -> Attempt {
    Attempt {
        reference: closed.reference,
        state: state_name(closed.state),
        url: String::new(),
        reason: closed.reason,
        words: String::new(),
        before: Default::default(),
        after: Default::default(),
        captured: false,
        price_words: String::new(),
        period: String::new(),
        price_line: String::new(),
        phase: phase_word(closed.phase),
        cancellable: false,
        users: None,
        room: None,
        action: String::new(),
        paid_until_unix_ms: None,
        paid_until_words: String::new(),
    }
}

// ── the account's own renewal and history (Gunnar's `api::subscription`, moved as it is) ──

/// **What another period would come to** (`GET /api/account/subscription`). No price and no
/// cost: monetize's words only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct Renewal {
    /// The ledger key a renewal would run under.
    pub reference: String,
    /// Whether monetize will sell another period.
    pub can_serve: bool,
    /// monetize's refusal in its own words, for the operator's log.
    pub reason: String,
    /// monetize's sentence for the CUSTOMER. The page shows this when set.
    pub words: String,
    /// `month` | `year` — monetize's word, verbatim.
    pub period: String,
    /// What renewing would cost, in monetize's own words (`220.00 SEK`).
    pub price_words: String,
    /// The caps the tenant holds today and would go on holding.
    pub caps: BTreeMap<String, u64>,
    /// This account has bought nothing, so there is nothing to renew (monetize refused AND the
    /// product holds no entitlement for it).
    pub nothing_to_renew: bool,
    /// ★ A32-29: **the Prolong button's price line**, composed here once ([`price_line`]);
    /// empty when there is no price. The consoles draw it.
    #[serde(default)]
    pub price_line: String,
    /// ★ A32-29: **the customer's sentence when it cannot be prolonged**: monetize's `words`,
    /// or [`RENEWING_UNAVAILABLE`] when it sent none; empty when it can be, or when nothing
    /// was bought (the page then offers a plan instead).
    #[serde(default)]
    pub line: String,
}

/// What every price line adds after the amount.
pub const VAT_NOTE: &str = "+ VAT (your country's rate, added at checkout)";
/// The Prolong line when monetize refused and sent no words of its own.
pub const RENEWING_UNAVAILABLE: &str = "Prolonging is not available right now.";

/// **`100.00 SEK / month + VAT (…)`** from monetize's own amount and period words; empty when
/// monetize sent no amount: a price is never composed from nothing (A32-29: one copy, here).
pub fn price_line(price_words: &str, period: &str) -> String {
    let amount = price_words.trim();
    if amount.is_empty() {
        return String::new();
    }
    let per = if period.trim().is_empty() {
        String::new()
    } else {
        format!(" / {}", period.trim())
    };
    format!("{amount}{per} {VAT_NOTE}")
}

/// A meter as the customer reads it on an offer: `pack_bytes` is "storage" (journey J7: never
/// a meter id).
fn meter_word(meter: &str) -> &'static str {
    match meter {
        "pack_bytes" | "disk_bytes" => "storage",
        "cache_bytes" => "read cache",
        "cold_bytes" => "archive",
        "lfs_bytes" => "large files",
        _ => "room",
    }
}

/// A byte count as an offer says it: `10 GiB`, `1.5 GiB` (one decimal, none when it is `.0`).
fn offer_bytes(count: u64) -> String {
    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
    if count < 1024 {
        return format!("{count} B");
    }
    let mut value = count as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit + 1 < UNITS.len() {
        value /= 1024.0;
        unit += 1;
    }
    format!("{value:.1} {}", UNITS[unit]).replacen(".0 ", " ", 1)
}

/// **What an offer's button says**: `+10 GiB storage — 100.00 SEK / month`. What it adds, in
/// customer words (monetize's `label` only when it sent no delta), then monetize's amount and
/// period. No VAT: the panel says it once. No amount, no price: one is never composed here
/// (A32-29: one copy, here; the console's own went).
pub fn offer_words(
    label: &str,
    delta: &BTreeMap<String, u64>,
    price_words: &str,
    period: &str,
) -> String {
    let adds = delta
        .iter()
        .map(|(meter, bytes)| format!("+{} {}", offer_bytes(*bytes), meter_word(meter)))
        .collect::<Vec<_>>()
        .join(", ");
    let what = if adds.is_empty() {
        label.trim().to_owned()
    } else {
        adds
    };
    let amount = price_words.trim();
    if amount.is_empty() {
        return what;
    }
    let per = if period.trim().is_empty() {
        String::new()
    } else {
        format!(" / {}", period.trim())
    };
    format!("{what} \u{2014} {amount}{per}")
}

/// The customer's Prolong sentence ([`Renewal::line`]).
fn renewal_words(can_serve: bool, nothing_to_renew: bool, words: &str) -> String {
    if can_serve || nothing_to_renew {
        String::new()
    } else if words.trim().is_empty() {
        RENEWING_UNAVAILABLE.to_owned()
    } else {
        words.to_owned()
    }
}

/// The renewal a quote answers.
pub fn renewal_of(quote: mpb::QuoteResponse) -> Renewal {
    let price_line = if quote.can_serve {
        price_line(&quote.price_words, &quote.period)
    } else {
        String::new()
    };
    let line = renewal_words(quote.can_serve, false, &quote.words);
    Renewal {
        reference: quote.reference,
        can_serve: quote.can_serve,
        reason: quote.reason,
        words: quote.words,
        period: quote.period,
        price_words: quote.price_words,
        caps: quote.caps.into_iter().collect(),
        nothing_to_renew: false,
        price_line,
        line,
    }
}

/// **A refused renewal is an answer, not a failure**: monetize's refusal (`FAILED_PRECONDITION`)
/// with its words unchanged, and whether the account has bought nothing at all.
pub fn refused_renewal(reason: &str, words: Option<String>, nothing_to_renew: bool) -> Renewal {
    let words = words.unwrap_or_default();
    let line = renewal_words(false, nothing_to_renew, &words);
    Renewal {
        reference: String::new(),
        can_serve: false,
        reason: reason.to_owned(),
        words,
        period: String::new(),
        price_words: String::new(),
        caps: Default::default(),
        nothing_to_renew,
        price_line: String::new(),
        line,
    }
}

/// The attempt a renewal starts (`POST /api/account/subscription/renew`).
pub fn renew_attempt(started: mpb::StartPurchaseResponse, url: String) -> Attempt {
    Attempt {
        reference: started.reference,
        state: state_name(started.state),
        url,
        reason: started.reason,
        words: started.words,
        before: Default::default(),
        after: Default::default(),
        captured: false,
        price_words: String::new(),
        period: String::new(),
        price_line: String::new(),
        phase: String::new(),
        cancellable: false,
        users: None,
        room: None,
        // The renew endpoint IS a renewal: another period of what is held.
        action: "renew".to_owned(),
        paid_until_unix_ms: None,
        paid_until_words: String::new(),
    }
}

/// **One line of what this account has bought** (`GET /api/account/subscription/history`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct Past {
    /// The ledger key, so support and customer can name the same thing.
    pub reference: String,
    /// monetize's own word for how it ended.
    pub outcome: String,
    /// When it ended.
    pub at_unix_ms: u64,
    /// ★ A32-29: that day as words ([`date_words`]).
    #[serde(default)]
    pub date_words: String,
    /// What the tenant held after it, per meter.
    pub caps: BTreeMap<String, u64>,
    /// What this purchase added; empty for a renewal.
    pub delta: BTreeMap<String, u64>,
    /// How many ledger rows are behind this one line.
    pub steps: u32,
    /// What the customer was charged, in monetize's own words.
    pub price_words: String,
}

/// Field by field, never `serde` over the proto message: a field added upstream must not reach
/// a browser because nobody looked. `price` (the raw Money) is not read.
pub fn past_of(a: mpb::PurchaseAttempt) -> Past {
    Past {
        reference: a.reference,
        outcome: a.outcome,
        date_words: date_words(a.at_unix_ms),
        at_unix_ms: a.at_unix_ms,
        caps: a.caps.into_iter().collect(),
        delta: a.delta.into_iter().collect(),
        steps: a.steps,
        price_words: a.price_words,
    }
}

// ── Your purchases (Gunnar's `api::subscription::purchases`, moved as it is) ─────────────

/// **Your purchases**: what this account bought at this shop — its private instances first, then
/// its room here; for an account WITHOUT room here, one line per instance and the way to buy
/// room here too.
#[derive(Debug, Serialize)]
pub struct Purchases {
    pub estates: Vec<Instance>,
    pub rooms: Vec<RoomBought>,
    pub storage_lines: Vec<Line>,
    pub buy: Option<Line>,
}

#[derive(Debug, Serialize)]
pub struct Instance {
    pub company: String,
    pub host: String,
    pub url: String,
    pub state: String,
    pub words: String,
    pub step: String,
    pub resources_words: String,
    pub monthly_words: String,
    pub paid_until: String,
    /// Where "Grow" leads: the sign-up page's grow of this instance.
    pub grow_url: String,
    /// Where "Manage" / "Renew" leads: the instance's renewal on the site.
    pub renew_url: String,
    /// ★ ACCT-6: why its front needs a repair, monetize's words; empty while it stands. A
    /// console draws "Restore front" (in a Repair section) from this alone.
    pub repair_words: String,
}

#[derive(Debug, Serialize)]
pub struct RoomBought {
    pub state: String,
    pub paid_until: String,
    pub cap_bytes: u64,
}

#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct Line {
    pub words: String,
    pub url: String,
}

/// Pure: **an account without room here** — one monetize KNOWS to hold no room here (it bought
/// a private instance, or is an account at this shop); never one monetize knows nothing of.
pub fn without_room(answer: &mpb::EstateOrderPurchasesResponse) -> bool {
    answer.rooms.is_empty() && (!answer.estates.is_empty() || answer.account_here)
}

/// Pure: **Your purchases** from `EstateOrder.Purchases`, on the site `here` that sells
/// `product` ("Gunnar", "Holger"), with its `pages` and the shop's word on what an account is
/// (`offer`, "10GB"; asked only for an account without room here).
pub fn purchases_of(
    answer: mpb::EstateOrderPurchasesResponse,
    pages: &crate::urls::Pages,
    product: &str,
    here: &str,
    offer: Option<String>,
) -> Purchases {
    let without = without_room(&answer);
    let estates: Vec<Instance> = answer
        .estates
        .into_iter()
        .map(|e| Instance {
            grow_url: format!("/signup?plan=grow&instance={}", e.company),
            renew_url: format!("{}{}", pages.manage, e.company),
            company: e.company,
            host: e.host,
            url: e.url,
            state: e.state,
            words: e.words,
            step: e.step,
            resources_words: e.resources_words,
            monthly_words: e.monthly_words,
            paid_until: e.paid_until,
            repair_words: e.repair_words,
        })
        .collect();
    let rooms: Vec<RoomBought> = answer
        .rooms
        .into_iter()
        .map(|r| RoomBought {
            state: r.state,
            paid_until: r.paid_until,
            cap_bytes: r.cap_bytes,
        })
        .collect();
    let storage_lines = if without {
        estates
            .iter()
            .map(|e| Line {
                words: format!("Your {product} is {}", e.host),
                url: if e.url.is_empty() {
                    format!("https://{}", e.host)
                } else {
                    e.url.clone()
                },
            })
            .collect()
    } else {
        Vec::new()
    };
    let buy = offer.filter(|_| without).map(|offer| Line {
        words: format!("Want space here on {here} too? Buy {offer}/month"),
        url: pages.account.clone(),
    });
    Purchases {
        estates,
        rooms,
        storage_lines,
        buy,
    }
}

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

    /// ★ ACCT-6: the row carries monetize's repair words as given, and none for a front that
    /// stands.
    #[test]
    fn a_purchase_row_carries_monetizes_repair_words() {
        let estate = |company: &str, repair: &str| mpb::EstatePurchase {
            company: company.into(),
            host: format!("{company}.gunnar.rs"),
            state: "ready".into(),
            repair_words: repair.into(),
            ..Default::default()
        };
        let answer = mpb::EstateOrderPurchasesResponse {
            estates: vec![
                estate(
                    "acme",
                    "acme.gunnar.rs's front has not answered for 4 hours.",
                ),
                estate("bolt", ""),
            ],
            ..Default::default()
        };
        let p = purchases_of(
            answer,
            &crate::urls::Pages::default(),
            "Gunnar",
            "gunnar.rs",
            None,
        );
        assert_eq!(
            p.estates[0].repair_words,
            "acme.gunnar.rs's front has not answered for 4 hours."
        );
        assert_eq!(p.estates[1].repair_words, "");
        let json = serde_json::to_value(&p).unwrap();
        assert_eq!(
            json["estates"][0]["repair_words"],
            "acme.gunnar.rs's front has not answered for 4 hours."
        );
    }

    #[test]
    fn only_this_products_enforced_priced_meters_are_sellable() {
        let meter = |n: &str, e: mpb::Enforcement, unit: &str| mpb::MeterInfo {
            name: n.into(),
            meaning: format!("{n}!"),
            enforcement: e as i32,
            unit: unit.into(),
            ..Default::default()
        };
        let answer = mpb::ListProductsResponse {
            products: vec![
                mpb::ProductInfo {
                    id: "gunnar".into(),
                    meters: vec![
                        meter("pack_bytes", mpb::Enforcement::Enforced, "disk_gib_month"),
                        meter("repos", mpb::Enforcement::Enforced, ""),
                        meter("lfs", mpb::Enforcement::Measured, "gib"),
                    ],
                    ..Default::default()
                },
                mpb::ProductInfo {
                    id: "holger".into(),
                    meters: vec![meter("artifacts", mpb::Enforcement::Enforced, "gib")],
                    ..Default::default()
                },
            ],
            ..Default::default()
        };
        assert_eq!(
            sellable(&answer, "gunnar"),
            vec![Sellable {
                meter: "pack_bytes".into(),
                meaning: "pack_bytes!".into()
            }]
        );
        assert!(sellable(&answer, "nobody").is_empty());
    }

    #[test]
    fn an_offer_drops_the_price_and_the_cost() {
        let quote = mpb::QuoteResponse {
            reference: "r".into(),
            can_serve: true,
            price: Some(mpb::Money {
                minor: 1_000,
                ..Default::default()
            }),
            cost: Some(mpb::Money {
                minor: 133_700,
                ..Default::default()
            }),
            delta: [("pack_bytes".to_owned(), 90u64)].into_iter().collect(),
            ..Default::default()
        };
        let shown = serde_json::to_string(&render_offer(quote)).unwrap();
        assert!(
            !shown.contains("133700")
                && !shown.contains("1000")
                && shown.contains("\"delta\":{\"pack_bytes\":90}"),
            "{shown}"
        );
    }

    #[test]
    fn the_target_is_monetizes_and_saturates() {
        let held = BTreeMap::from([("disk_bytes".to_string(), 5u64), ("users".to_string(), 1)]);
        assert_eq!(
            target_caps(&held, &BTreeMap::from([("disk_bytes".to_string(), 10u64)])),
            BTreeMap::from([("disk_bytes".to_string(), 15u64), ("users".to_string(), 1)])
        );
        assert_eq!(
            target_caps(
                &BTreeMap::from([("a".to_string(), u64::MAX)]),
                &BTreeMap::from([("a".to_string(), 1u64)])
            )["a"],
            u64::MAX
        );
        let plan = mpb::Bundle {
            action: "upgrade".into(),
            target: [("disk_bytes".to_string(), 20u64)].into_iter().collect(),
            ..Default::default()
        };
        assert_eq!(
            order_caps(&plan, &held),
            BTreeMap::from([("disk_bytes".to_string(), 20u64)])
        );
        let renew = mpb::Bundle {
            action: "prolong".into(),
            ..Default::default()
        };
        assert!(is_renewal(&renew));
        assert_eq!(order_caps(&renew, &held), held);
    }

    #[test]
    fn a_row_the_box_cannot_serve_is_not_in_the_window_but_a_plan_row_keeps_its_refusal() {
        let w = window(vec![
            mpb::Bundle {
                id: "a".into(),
                can_serve: true,
                ..Default::default()
            },
            mpb::Bundle {
                id: "b".into(),
                can_serve: false,
                ..Default::default()
            },
            mpb::Bundle {
                id: "c".into(),
                can_serve: false,
                action: "prolong".into(),
                reason: "already paid ahead".into(),
                ..Default::default()
            },
        ]);
        assert_eq!(
            w.offers.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(),
            ["a", "c"]
        );
        assert_eq!(w.withheld, 1);
        let reading = window_of(mpb::ListBundlesResponse {
            reading: true,
            ..Default::default()
        });
        assert_eq!(reading.pending.as_deref(), Some(AWAITING_PROVIDER));
        assert!(window(vec![mpb::Bundle {
            can_serve: false,
            ..Default::default()
        }])
        .unavailable
        .is_some());
    }
}

// ── an operator's orders for a tenant (Gunnar's console's `api::increase`) ──────────────

/// A meter this deployment will sell room on, in monetize's words (no price: `unit` only says it
/// is billed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Sellable {
    pub meter: String,
    pub meaning: String,
}

/// What a tenant could buy more of. `unavailable`: why there are no meters, when the site could
/// not find out (monetize unreachable, or no enforced, priced meter of this product).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Purchasable {
    pub meters: Vec<Sellable>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub unavailable: Option<String>,
}

/// Pure: **the meters of `product` that monetize says are enforced and priced.**
pub fn sellable(answer: &mpb::ListProductsResponse, product: &str) -> Vec<Sellable> {
    answer
        .products
        .iter()
        .filter(|p| p.id == product)
        .flat_map(|p| p.meters.iter())
        .filter(|m| m.enforcement == mpb::Enforcement::Enforced as i32 && !m.unit.is_empty())
        .map(|m| Sellable {
            meter: m.name.clone(),
            meaning: m.meaning.clone(),
        })
        .collect()
}

/// What an order would come to. **No price and no cost**: field by field, never `serde` over
/// the proto message, so the provider's cost cannot reach a browser under any name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Offer {
    pub reference: String,
    pub can_serve: bool,
    pub reason: String,
    pub words: String,
    pub period: String,
    pub current: BTreeMap<String, u64>,
    pub caps: BTreeMap<String, u64>,
    pub delta: BTreeMap<String, u64>,
}

/// Pure: `QuoteResponse` as the browser sees it ([`Offer`]).
pub fn render_offer(quote: mpb::QuoteResponse) -> Offer {
    Offer {
        reference: quote.reference,
        can_serve: quote.can_serve,
        reason: quote.reason,
        words: quote.words,
        period: quote.period,
        current: quote.current.into_iter().collect(),
        caps: quote.caps.into_iter().collect(),
        delta: quote.delta.into_iter().collect(),
    }
}

/// **An operator's order, as the console follows it**: the reference, monetize's state word,
/// the vendor's own link (empty: nowhere to send anybody) and monetize's reason.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Placed {
    pub reference: String,
    pub state: &'static str,
    pub url: String,
    pub reason: String,
}