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

nordisk/gunnar-ui

crates/gunnar-ui-web/src/purchase.rs

//! **What a customer buying more room is told**, on the host side of the wasm
//! boundary. Compiled only under `metered` and on the money scanner's
//! `METERED_ONLY` list: a community build does not contain it.
//!
//! Owner, 2026-09-28, reading the live "buy more room" pane: *"10.0 GiB more
//! room · 100.00 SEK month · pack_bytes +10.0 GiB · Your order: pending · the
//! customer has not paid yet; nothing is provisioned against a link nobody has
//! used · [Continue]" — this sucks.* Five faults, and each has a rule here:
//!
//! 1. **No internal names.** A meter id (`pack_bytes`) is the product's word,
//!    never the customer's: [`meter_word`] says "storage". monetize's `reason`
//!    is an operator's log line and is never shown to a customer — the phase
//!    says what is happening, in [`phase_title`] / [`phase_detail`].
//! 2. **Price clarity**: amount, currency, period, and that VAT is added at
//!    checkout: the price line is monetize-site-buy's (`price_line`, A32-29), sent with
//!    each offer and order, and so are the offer button's words (`offer_words`, A32-29).
//! 3. **Three steps**, named ([`STEPS`]), and the order's state in customer
//!    words: Waiting for payment · Paid – adding room · Done – you now have X ·
//!    Cancelled or expired.
//! 4. **"Pay with Stripe"** (primary, same tab) and **"Cancel order"**
//!    (secondary). Back from Stripe the page follows the order live, and says
//!    how long the room normally takes if Stripe's notice is slow.
//! 5. **Current → new total** ([`change_line`]), from the numbers the order
//!    was made against (the server's `before` / `after`), never a sum made
//!    here.

use std::collections::BTreeMap;

use crate::fmt;

/// VAT is added by Stripe Tax at checkout (exclusive pricing), so the price a
/// customer is shown is before it — and says so. It names NO country and NO
/// rate: Stripe Tax applies the buyer's own country's rate (0 % under the
/// reverse charge for a business with a VAT id), so "25 % in Denmark" — what
/// this said until 2026-10-02, on every price, SEK included — was wrong for
/// most buyers (`the_vat_note_names_no_country_and_no_rate`).
pub const VAT_NOTE: &str = "+ VAT (your country's rate, added at checkout)";

/// The three steps, in order.
pub const STEPS: [&str; 3] = ["Choose", "Pay at Stripe", "Room added"];

/// How long the room normally takes once paid, when Stripe's notice is slow
/// and the poller picks the payment up instead.
pub const SLOW_NOTICE: &str = "Usually within a minute. If Stripe's notice is slow, our check \
     picks the payment up within 5 minutes — you can leave this page; the room is added either way.";

/// **The one way to pay**: the label and the class every pay link in this
/// console carries — a big PRIMARY BUTTON, never a bare link (owner,
/// 2026-09-28: "I cannot find how to pay"; the link rendered as plain text
/// because `btn` was missing). `pages::subscription::PayButton` is the only
/// place either is used, and `every_pay_link_is_the_shared_button` holds both
/// pages to it.
pub const PAY_LABEL: &str = "Pay with Stripe";
pub const PAY_CLASS: &str = "btn primary pay";
/// The same button for an order still waiting when the page is opened LATER
/// (the tab was closed at Stripe): on the purchases list and the order page.
pub const RESUME_LABEL: &str = "Resume payment";
/// Shown for the moment between the order and the browser leaving for Stripe.
pub const LEAVING: &str = "Taking you to Stripe\u{2026}";

/// **Pressing buy goes straight to Stripe** (owner, 2026-09-28): when the
/// order comes back waiting for payment WITH the vendor's link, the page
/// navigates there at once, same tab — no intermediate page, no extra click.
/// `None` means stay and show the order (with a real button if there is a
/// link the browser could not follow, or "creating your payment link").
pub fn go_to_vendor(p: Phase, url: &str) -> Option<String> {
    (p == Phase::Waiting && pay_url(url).is_some()).then(|| url.to_owned())
}

/// **A link the browser may be sent to pay at**: `https://`, a host, no
/// credentials, nothing but printable ASCII — the server's own rule
/// (`security::https_url`), kept here too so the page never trusts a link it
/// was handed: no `javascript:`, no plain http, no `//host`.
pub fn pay_url(url: &str) -> Option<&str> {
    if url.len() > 4096 || !url.bytes().all(|b| b.is_ascii_graphic()) {
        return None;
    }
    let rest = url.strip_prefix("https://")?;
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    let host = authority.rsplit_once(':').map_or(authority, |(host, _port)| host);
    (!host.is_empty() && !authority.contains('@')).then_some(url)
}

/// The pay button's words: a fresh order whose navigation did not happen, or
/// an order opened later.
pub fn pay_label(fresh: bool) -> &'static str {
    if fresh {
        PAY_LABEL
    } else {
        RESUME_LABEL
    }
}

/// A past purchase still open at the vendor — the purchases list offers
/// "Resume payment" for it. monetize's fold word for an attempt not ended.
pub fn resumable(outcome: &str) -> bool {
    outcome == "open"
}

/// A pending order with no checkout link yet.
pub const CREATING_LINK: &str = "Creating your payment link\u{2026}";
/// …and still none after [`LINK_PATIENCE`] asks.
pub const NO_LINK: &str = "The payment link could not be created. Nothing has been charged \u{2014} \
     cancel the order and choose again, or contact support if it happens again.";
/// How many follow-ups (every few seconds) a pending order may go without a
/// link before the page stops saying "creating" and says it failed.
pub const LINK_PATIENCE: u32 = 20;

/// What a waiting order's pay area shows.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PayArea {
    /// The vendor's link: the button.
    Button(String),
    /// No link yet; keep following.
    Creating,
    /// No link after [`LINK_PATIENCE`] asks: say so, never a page with no way
    /// to pay and no reason.
    Failed,
    /// Not a waiting order: nothing to pay.
    Nothing,
}

/// Decide the pay area: never an order waiting for payment with no way to pay.
pub fn pay_area(p: Phase, url: &str, asks: u32) -> PayArea {
    match p {
        Phase::Waiting if pay_url(url).is_some() => PayArea::Button(url.to_owned()),
        // A link, but not one a browser may follow: never a button, and no
        // more waiting for one either.
        Phase::Waiting if !url.trim().is_empty() => PayArea::Failed,
        Phase::Waiting if asks < LINK_PATIENCE => PayArea::Creating,
        Phase::Waiting => PayArea::Failed,
        _ => PayArea::Nothing,
    }
}

/// The customer's word for a meter. Never the id.
pub fn meter_word(meter: &str) -> &'static str {
    match meter {
        "pack_bytes" => "storage",
        "cache_bytes" => "read cache",
        "cold_bytes" => "archive",
        "lfs_bytes" => "large files",
        _ => "room",
    }
}

/// **What an offer sells, in the customer's words** (journey J7: never a meter
/// id): `+10 GiB of storage, +1 GiB of large files`. `bytes` formats the amount.
pub fn sells_words(delta: &[(String, u64)], bytes: &dyn Fn(u64) -> String) -> String {
    delta.iter().map(|(meter, amount)| format!("+{} of {}", bytes(*amount), meter_word(meter))).collect::<Vec<_>>().join(", ")
}

/// **The offer button**: every offer in a More room panel is ONE button with
/// these classes — what it adds and what it costs ON it (the server's `offer_words`), and
/// nothing loose beside it (owner, 2026-10-02: the label on a button, the price
/// and "+100.0 GiB storage" as two lines of text under it).
pub const OFFER_CLASS: &str = "btn primary offer";

/// **The panel's one VAT sentence**, under its offer buttons.
pub fn panel_vat_note() -> String {
    format!("Each amount {VAT_NOTE}")
}

/// What an offer adds, in customer words: `+10.0 GiB storage`.
pub fn adds_line(delta: &BTreeMap<String, u64>) -> String {
    delta
        .iter()
        .map(|(meter, bytes)| format!("+{} {}", fmt::bytes(*bytes), meter_word(meter)))
        .collect::<Vec<_>>()
        .join(", ")
}

/// **"You have 10.0 GiB → 20.0 GiB after payment"**, for the storage meter
/// (or the first meter raised), out of the server's `before` / `after`.
/// `None` when the server did not send both halves: a total is never guessed.
pub fn change_line(before: &BTreeMap<String, u64>, after: &BTreeMap<String, u64>) -> Option<String> {
    let meter = if after.contains_key("pack_bytes") {
        "pack_bytes"
    } else {
        after.keys().next()?.as_str()
    };
    let to = *after.get(meter)?;
    let from = before.get(meter).copied().unwrap_or(0);
    Some(format!(
        "You have {} {} \u{2192} {} after payment",
        fmt::bytes(from),
        meter_word(meter),
        fmt::bytes(to)
    ))
}

/// Where an order is, in the customer's terms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    /// Nobody has paid yet.
    Waiting,
    /// The money is captured; the room is on its way.
    PaidAdding,
    /// Paid and the room granted.
    Done,
    /// Declined, released, or cancelled: nothing drawn.
    Cancelled,
    /// The server does not know the order (or said something new).
    Unknown,
}

/// **monetize's own phase word → phase** (thin layer, owner 2026-10-04):
/// monetize names where the order is (`PurchasePhase`), so the page draws it
/// rather than re-deriving monetize's state machine. Only an answer that
/// carried no phase (a `Start`, or an older monetize) falls back to the ladder
/// word in [`phase`].
pub fn phase_of(word: &str, state: &str, captured: bool) -> Phase {
    match word {
        "waiting_for_payment" => Phase::Waiting,
        "paid_adding" => Phase::PaidAdding,
        "done" => Phase::Done,
        "cancelled" | "declined" | "refunded" => Phase::Cancelled,
        _ => phase(state, captured),
    }
}

/// monetize's ladder word (and whether the ledger holds a capture) → phase.
pub fn phase(state: &str, captured: bool) -> Phase {
    match state {
        "pending" if captured => Phase::PaidAdding,
        "pending" => Phase::Waiting,
        "paid" => Phase::Done,
        "declined" | "released" | "released_with_orphans" => Phase::Cancelled,
        _ => Phase::Unknown,
    }
}

/// Is the order over? A final phase stops the live follow.
pub fn is_final(p: Phase) -> bool {
    matches!(p, Phase::Done | Phase::Cancelled | Phase::Unknown)
}

/// Which of [`STEPS`] (0-based) the order is on.
pub fn step_of(p: Phase) -> usize {
    match p {
        Phase::Waiting => 1,
        Phase::PaidAdding => 2,
        Phase::Done => 2,
        Phase::Cancelled | Phase::Unknown => 0,
    }
}

/// The headline, in customer words. `now` is the storage the account holds
/// after a granted order (the server's `after`), when it sent it.
pub fn phase_title(p: Phase, now: Option<u64>) -> String {
    match p {
        Phase::Waiting => "Waiting for payment".to_owned(),
        Phase::PaidAdding => "Paid \u{2013} adding room".to_owned(),
        Phase::Done => match now {
            Some(bytes) => format!("Done \u{2013} you now have {} storage", fmt::bytes(bytes)),
            None => "Done \u{2013} the room is yours".to_owned(),
        },
        Phase::Cancelled => "Cancelled or expired".to_owned(),
        Phase::Unknown => "We could not find this order".to_owned(),
    }
}

/// One sentence under the headline. Never monetize's operator `reason`.
pub fn phase_detail(p: Phase) -> &'static str {
    match p {
        Phase::Waiting => "Nothing has been charged yet. Pay with Stripe to continue, or cancel the order.",
        Phase::PaidAdding => SLOW_NOTICE,
        Phase::Done => "The extra room is available now.",
        Phase::Cancelled => "Nothing was charged. You can choose again whenever you like.",
        Phase::Unknown => "If you have just paid, give it a minute and reload this page.",
    }
}

/// The storage the account holds after the order, from the server's `after`.
pub fn now_holds(after: &BTreeMap<String, u64>) -> Option<u64> {
    after.get("pack_bytes").copied()
}

// ── What an order does to the plan (owner, test2, 2026-10-05) ──────────────
//
// The owner paid a PROLONG and the page back from Stripe said "3. Room added —
// Done – you now have 10.0 GiB storage. The extra room is available now. Paid ·
// queued for today's batch — … the disk is enlarged in today's batch". No room
// was added and no disk grows on a prolong. The server now carries monetize's
// verb for the order (`action`) and the granted fact's end
// (`paid_until_unix_ms`, and its day in words, `paid_until_words`); these draw the
// page by them. The day is the server's words (A32-29), never worked out here.

/// What an order does to the account, by monetize's verb.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// Room bought (a plain order, a first plan): the page it always was.
    Room,
    /// Another period of what is held (`prolong` | `renew`): no room, no disk.
    Prolong,
    /// A 1-month plan becomes a 12-month one (`upgrade`).
    Upgrade,
    /// A 12-month plan grows to the same end (`bump`).
    Bump,
}

/// monetize's verb (the server's `action`) → [`Kind`]. Empty or unknown is
/// [`Kind::Room`], the page every order had before plans.
pub fn kind_of(action: &str) -> Kind {
    match action {
        "prolong" | "renew" => Kind::Prolong,
        "upgrade" => Kind::Upgrade,
        "bump" => Kind::Bump,
        _ => Kind::Room,
    }
}

/// The three steps for this kind of order.
pub fn steps(kind: Kind) -> [&'static str; 3] {
    let last = match kind {
        Kind::Room => return STEPS,
        Kind::Prolong => "Prolonged",
        Kind::Upgrade => "Upgraded",
        Kind::Bump => "Raised",
    };
    [STEPS[0], STEPS[1], last]
}

/// monetize's period word as the customer reads it: `year` is "12 months".
fn period_words(period: &str) -> Option<&'static str> {
    match period {
        "year" => Some("12 months"),
        "month" => Some("1 month"),
        _ => None,
    }
}

/// The headline. `now`: the storage held after a granted order; `until`: the
/// granted fact's end in the server's words (empty: none); `period`: monetize's period
/// word for the order.
pub fn order_title(p: Phase, kind: Kind, now: Option<u64>, until: &str, period: &str) -> String {
    let date = Some(until.trim()).filter(|d| !d.is_empty());
    match (p, kind) {
        (_, Kind::Room) => phase_title(p, now),
        (Phase::PaidAdding, Kind::Prolong) => "Paid \u{2013} prolonging".to_owned(),
        (Phase::Done, Kind::Prolong) => match date {
            Some(d) => format!("Prolonged until {d}"),
            None => "Prolonged".to_owned(),
        },
        (Phase::Done, Kind::Upgrade) => match (period_words(period), date) {
            (Some(w), Some(d)) => format!("Upgraded to {w} until {d}"),
            (Some(w), None) => format!("Upgraded to {w}"),
            (None, Some(d)) => format!("Upgraded until {d}"),
            (None, None) => "Upgraded".to_owned(),
        },
        (Phase::Done, Kind::Bump) => match now {
            Some(bytes) => format!("Raised to {} storage", fmt::bytes(bytes)),
            None => "Raised".to_owned(),
        },
        _ => phase_title(p, now),
    }
}

/// The sentence under the headline.
pub fn order_detail(p: Phase, kind: Kind, now: Option<u64>, until: &str) -> String {
    match (p, kind) {
        // The headline says it all; the days left may follow on their own line.
        (Phase::Done, Kind::Prolong) => String::new(),
        (Phase::Done, Kind::Upgrade) => now.map(|b| format!("You now have {} storage.", fmt::bytes(b))).unwrap_or_default(),
        (Phase::Done, Kind::Bump) => Some(until.trim()).filter(|d| !d.is_empty()).map(|d| format!("Your plan runs until {d}, as before.")).unwrap_or_default(),
        _ => phase_detail(p).to_owned(),
    }
}

/// Is the queued enlargement's line (monetize's room words) drawn? Never for
/// a prolong: it adds no room and grows no disk.
pub fn shows_room(kind: Kind) -> bool {
    kind != Kind::Prolong
}

/// The optional line under a prolong's headline: monetize's own day count for
/// the plan the order ended on.
pub fn days_left_line(days_left: u32) -> Option<String> {
    match days_left {
        0 => None,
        1 => Some("1 day left".to_owned()),
        n => Some(format!("{n} days left")),
    }
}

/// The Account page's button for another period — the same renewal the
/// Storage page calls "Prolong 1 month", so the same word.
pub const PROLONG_LABEL: &str = "Prolong";
/// Under a refused read of the renewal.
pub const PROLONG_REFUSED: &str = "Prolonging is not available right now.";

// ── The plan, in one panel (owner 2026-10-17) ──────────────────────────────
//
// "It must be stringent": ONE panel, "Plan" — never "Subscription" (a term is
// paid once; nothing renews by itself) and never "None yet". A free account
// reads "Free" and its button "Buy a plan"; one with a plan reads monetize's
// plan name, its storage and the term it is paid until, and its button "Grow
// your account".

/// The panel's title.
pub const PLAN_PANEL: &str = "Plan";
/// What a free account (monetize's plan state `none`) is called.
pub const FREE_PLAN: &str = "Free";

/// The Account page's button when the account has no plan
/// ([`account_button`]), and the Clans page's link to the same place.
pub const BUY_A_PLAN: &str = "Buy a plan";
/// What You → Clans says, before the [`BUY_A_PLAN`] link, to a person with no room
/// of their own to give (`clans::nothing_to_give`) — on a metered console only.
pub const NOTHING_TO_GIVE: &str = "You have no room to give until you buy a plan.";
pub const NOTHING_TO_RENEW_HREF: &str = "/account/storage";

/// What the Subscription panel draws.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenewalLine {
    /// monetize sells another period: the Renew button, and the server's price line beside it.
    Renew(String),
    /// Nothing to say: nothing bought (the Account page's button says "Buy a plan",
    /// [`account_button`]).
    NothingYet,
    /// A refusal, in the server's customer sentence.
    Said(String),
}

/// **The server's renewal answer as the panel's line** (A32-29): monetize-site-buy composes
/// the price line and the refusal sentence (`price_line`, `line`), so this only picks which
/// of them to draw.
pub fn prolong_line(can_serve: bool, price_line: &str, line: &str) -> RenewalLine {
    if can_serve {
        RenewalLine::Renew(price_line.to_owned())
    } else if line.is_empty() {
        RenewalLine::NothingYet
    } else {
        RenewalLine::Said(line.to_owned())
    }
}

#[cfg(test)]
mod tests {

    /// ★ J7: "Sells pack_bytes +10 GiB" showed the meter id. The customer reads
    /// what the room is, never the id.
    #[test]
    fn what_an_offer_sells_is_said_in_customer_words_never_the_meter_id() {
        let fmt = |b: u64| format!("{} GiB", b >> 30);
        let one = super::sells_words(&[("pack_bytes".to_string(), 10 << 30)], &fmt);
        assert_eq!(one, "+10 GiB of storage");
        let two = super::sells_words(&[("pack_bytes".to_string(), 10 << 30), ("lfs_bytes".to_string(), 1 << 30)], &fmt);
        assert_eq!(two, "+10 GiB of storage, +1 GiB of large files");
        assert!(!two.contains("_bytes"), "{two}");
    }
    use super::*;

    const GIB: u64 = 1 << 30;

    /// ★ **Every pay link on both pages is the shared, unmistakable button.**
    /// The owner could not find how to pay: subscription.rs rendered the
    /// vendor link as `<a class="primary">Continue</a>`, plain text, while
    /// increase.rs had `btn primary`. Now one component, and both pages must
    /// use it and nothing else.
    #[test]
    fn every_pay_link_is_the_shared_button() {
        assert!(PAY_CLASS.split(' ').any(|c| c == "btn") && PAY_CLASS.split(' ').any(|c| c == "primary"));
        assert_eq!(PAY_LABEL, "Pay with Stripe");
        let subscription = crate::code::of(include_str!("pages/subscription.rs"));
        let increase = crate::code::of(include_str!("pages/increase.rs"));
        // The component is defined once, and uses the constants.
        assert!(subscription.contains("pub fn PayButton"));
        assert!(subscription.contains("class=crate::purchase::PAY_CLASS"));
        for (name, page) in [("subscription", subscription), ("increase", increase)] {
            assert!(page.contains("<PayButton") || page.contains("<crate::pages::subscription::PayButton"), "{name} does not use PayButton");
            assert!(!page.contains("\"Continue\""), "{name} still has a Continue link");
            assert!(!page.contains("class=\"primary\" href"), "{name} has a pay link without the button class");
        }
    }

    /// ★ **After a successful order the page goes to the redirect url**: the
    /// decision, and the page's success path calling the navigation with it.
    #[test]
    fn a_successful_order_navigates_straight_to_the_vendor() {
        let url = "https://checkout.stripe.com/c/pay/cs_test_x";
        assert_eq!(go_to_vendor(phase("pending", false), url).as_deref(), Some(url));
        assert_eq!(go_to_vendor(phase("pending", false), ""), None, "no link: stay and say so");
        assert_eq!(go_to_vendor(phase("paid", false), url), None, "paid is never sent to pay");
        assert_eq!(go_to_vendor(phase("pending", true), url), None, "captured is never sent to pay again");
        let page = crate::code::of(include_str!("pages/subscription.rs"));
        assert!(page.contains("fn navigate_to"), "the page has no navigation");
        assert!(page.contains(".assign(url)"), "the navigation is not location.assign (same tab)");
        // The Account page's one order path is the renewal (the rest is the
        // ONE sign-up page's since 2026-10-17); it and Resume go straight there.
        let orders = page.matches("go_to_vendor(").count();
        assert!(orders >= 2, "every order path (renew, resume) must go straight to the vendor: {orders}");
    }

    /// ★ **The operator's order goes straight to the vendor too** (owner
    /// 2026-09-28: Buy goes straight to Stripe EVERYWHERE). The namespace
    /// usage page's "More room" drew the order and a pay button after Buy —
    /// one click more than the customer's page; the browser journey's J10
    /// caught it. Both of its order paths (bundle, typed) navigate at once.
    #[test]
    fn the_operators_order_navigates_straight_to_the_vendor_too() {
        let page = crate::code::of(include_str!("pages/increase.rs"));
        let orders = page.matches("crate::purchase::go_to_vendor(").count();
        assert!(orders >= 2, "the operator's order paths (bundle, typed) must go straight to the vendor: {orders}");
        assert!(page.contains("crate::pages::subscription::navigate_to("), "the operator's page does not navigate");
        assert!(page.contains("crate::purchase::LEAVING"), "leaving for the vendor is not said");
    }

    /// ★ **A pending order opened later shows "Resume payment" as a button**
    /// — the shared button class — on the purchases list and the order page.
    #[test]
    fn a_pending_order_opened_later_shows_resume_payment_as_a_button() {
        assert_eq!(pay_label(false), "Resume payment");
        assert_eq!(pay_label(true), "Pay with Stripe");
        assert!(resumable("open") && !resumable("granted") && !resumable("refunded") && !resumable("declined"));
        let page = crate::code::of(include_str!("pages/subscription.rs"));
        assert!(page.contains("pub fn ResumeButton"));
        assert!(page.contains("<ResumeButton"), "the purchases list offers no Resume payment");
        assert!(page.contains("crate::purchase::RESUME_LABEL"));
        // The resume button is a real button with the pay class.
        let at = page.find("pub fn ResumeButton").unwrap();
        assert!(page[at..].contains("class=crate::purchase::PAY_CLASS"), "Resume payment is not the primary button");
    }

    /// ★ **Only an https link with a host is followed or drawn** (stinky #19):
    /// the server already refuses anything else, and the page does not take
    /// that on trust: `javascript:`, plain http, a scheme-relative or a
    /// credentialed link is never `location.assign`ed nor a button's href.
    #[test]
    fn a_link_that_is_not_https_is_neither_followed_nor_drawn() {
        for bad in [
            "http://checkout.example/pay",
            "javascript:alert(1)",
            "//checkout.example/pay",
            "https://someone@checkout.example/pay",
            "https:///pay",
            " https://checkout.example/pay",
        ] {
            assert_eq!(go_to_vendor(Phase::Waiting, bad), None, "{bad}");
            assert_eq!(pay_area(Phase::Waiting, bad, 0), PayArea::Failed, "{bad}");
        }
        let good = "https://checkout.stripe.com/c/pay/cs_test_x#fragment";
        assert_eq!(go_to_vendor(Phase::Waiting, good).as_deref(), Some(good));
    }

    /// A pending order never shows a page with no way to pay.
    #[test]
    fn a_pending_order_without_a_link_says_so_and_then_fails_clearly() {
        assert_eq!(pay_area(Phase::Waiting, "https://checkout.example/c/x", 0), PayArea::Button("https://checkout.example/c/x".into()));
        assert_eq!(pay_area(Phase::Waiting, "", 0), PayArea::Creating);
        assert_eq!(pay_area(Phase::Waiting, "  ", LINK_PATIENCE - 1), PayArea::Creating);
        assert_eq!(pay_area(Phase::Waiting, "", LINK_PATIENCE), PayArea::Failed);
        assert_eq!(pay_area(Phase::Done, "https://x", 0), PayArea::Nothing, "a paid order is never offered a pay link");
        assert!(CREATING_LINK.starts_with("Creating your payment link"));
    }

    fn map(pairs: &[(&str, u64)]) -> BTreeMap<String, u64> {
        pairs.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect()
    }

    /// Every sentence a customer can be shown, for every phase and the offer
    /// lines, carries no meter id and none of monetize's log words.
    #[test]
    fn no_customer_text_names_an_internal_meter() {
        let mut said = vec![
            adds_line(&map(&[("pack_bytes", 10 * GIB), ("cache_bytes", GIB)])),
            change_line(&map(&[("pack_bytes", 5 * GIB)]), &map(&[("pack_bytes", 15 * GIB)])).unwrap(),
            SLOW_NOTICE.to_owned(),
        ];
        for p in [Phase::Waiting, Phase::PaidAdding, Phase::Done, Phase::Cancelled, Phase::Unknown] {
            said.push(phase_title(p, Some(15 * GIB)));
            said.push(phase_detail(p).to_owned());
        }
        said.extend(STEPS.iter().map(|s| (*s).to_owned()));
        for line in &said {
            for internal in ["pack_bytes", "cache_bytes", "_bytes", "provisioned", "tenant", "ledger", "meter"] {
                assert!(!line.contains(internal), "{internal:?} reached a customer: {line:?}");
            }
        }
    }

    /// ★ **The VAT note names no country and no rate** (owner, 2026-10-02).
    /// "+ VAT (25 % in Denmark, calculated at checkout)" stood beside every
    /// price, SEK included: Stripe Tax applies the BUYER's country's rate at
    /// checkout (and 0 % for a business under the reverse charge), so a rate or
    /// a country written here is wrong for most buyers.
    /// ★ **A paid prolong reads "Prolonged until <date>"** (owner, test2,
    /// 2026-10-05: "it should have said prolonged or renewed for <date>"):
    /// step 3 "Prolonged", the headline the granted fact's end, no room line
    /// and no sentence about room or disk.
    #[test]
    fn a_paid_prolong_reads_prolonged_until_its_new_end_and_nothing_about_room() {
        let until = "2026-12-05"; // the server's words for the day
        for verb in ["prolong", "renew"] {
            let k = kind_of(verb);
            assert_eq!(k, Kind::Prolong, "{verb}");
            assert_eq!(steps(k), ["Choose", "Pay at Stripe", "Prolonged"]);
            assert_eq!(order_title(Phase::Done, k, Some(10 * GIB), until, "month"), "Prolonged until 2026-12-05");
            let detail = order_detail(Phase::Done, k, Some(10 * GIB), until);
            assert!(!shows_room(k), "a prolong draws monetize's room line");
            for never in ["room", "Room", "disk", "batch", "added"] {
                assert!(!detail.contains(never), "{verb}: {detail:?} speaks of {never}");
            }
            assert_eq!(order_title(Phase::PaidAdding, k, None, "", "month"), "Paid \u{2013} prolonging");
        }
        assert_eq!(days_left_line(61).as_deref(), Some("61 days left"));
        assert_eq!(days_left_line(1).as_deref(), Some("1 day left"));
        assert_eq!(days_left_line(0), None);
        assert_eq!(order_title(Phase::Done, Kind::Prolong, None, "", ""), "Prolonged", "no fact, no date");
    }

    /// ★ A32-29: **the order's day is the server's words** (monetize-site-buy
    /// `Attempt.paid_until_words`): the title and the sentence carry them as they are, and
    /// no console works a date out of the number.
    #[test]
    fn the_orders_day_is_the_servers_words() {
        let day = "the server's day";
        assert_eq!(order_title(Phase::Done, Kind::Prolong, None, day, ""), "Prolonged until the server's day");
        assert_eq!(order_title(Phase::Done, Kind::Upgrade, None, day, "year"), "Upgraded to 12 months until the server's day");
        assert_eq!(order_detail(Phase::Done, Kind::Bump, None, day), "Your plan runs until the server's day, as before.");
        assert_eq!(order_title(Phase::Done, Kind::Prolong, None, "", ""), "Prolonged", "no day, no date");
        assert_eq!(order_detail(Phase::Done, Kind::Bump, None, ""), "", "no day, no sentence");
    }

    /// ★ An upgrade and a bump say what they did, with the fact's numbers;
    /// a room order is the page it always was.
    #[test]
    fn an_upgrade_and_a_bump_say_what_they_did() {
        let until = "2027-10-05"; // the server's words for the day
        let up = kind_of("upgrade");
        assert_eq!(steps(up)[2], "Upgraded");
        assert_eq!(order_title(Phase::Done, up, Some(100 * GIB), until, "year"), "Upgraded to 12 months until 2027-10-05");
        assert_eq!(order_detail(Phase::Done, up, Some(100 * GIB), until), "You now have 100.0 GiB storage.");
        let bump = kind_of("bump");
        assert_eq!(steps(bump)[2], "Raised");
        assert_eq!(order_title(Phase::Done, bump, Some(150 * GIB), until, "year"), "Raised to 150.0 GiB storage");
        assert_eq!(order_detail(Phase::Done, bump, Some(150 * GIB), until), "Your plan runs until 2027-10-05, as before.");
        assert!(shows_room(up) && shows_room(bump), "room was added: its line stays");
        for verb in ["", "start", "something-new"] {
            let k = kind_of(verb);
            assert_eq!(k, Kind::Room);
            assert_eq!(steps(k), STEPS);
            assert_eq!(order_title(Phase::Done, k, Some(10 * GIB), until, "month"), phase_title(Phase::Done, Some(10 * GIB)));
            assert_eq!(order_detail(Phase::Done, k, Some(10 * GIB), until), phase_detail(Phase::Done));
        }
    }

    /// ★ **One name**: the Account page's button is the same renewal as
    /// Storage's "Prolong 1 month", so it says "Prolong" (owner, 2026-10-05).
    #[test]
    fn the_account_pages_renewal_is_called_prolong() {
        assert_eq!(PROLONG_LABEL, "Prolong");
        for said in [PROLONG_LABEL, PROLONG_REFUSED, BUY_A_PLAN, GROW_YOUR_ACCOUNT, PLAN_PANEL, FREE_PLAN] {
            assert!(!said.to_lowercase().contains("renew"), "{said:?}");
        }
        // The Clans page's line to a person with nothing to give, with the same link.
        assert_eq!((NOTHING_TO_GIVE, BUY_A_PLAN, NOTHING_TO_RENEW_HREF), ("You have no room to give until you buy a plan.", "Buy a plan", "/account/storage"));
    }

    /// ★ **Nothing bought reads as that** (owner, test2, 2026-10-05), and A32-29: **the Prolong
    /// line is the server's.** monetize-site-buy's renewal answer carries `price_line` and
    /// `line` (empty when it can be prolonged, or when nothing was bought); the console draws
    /// them and composes neither.
    #[test]
    fn the_prolong_line_is_drawn_from_the_servers_words() {
        assert_eq!((PLAN_PANEL, FREE_PLAN, NOTHING_TO_RENEW_HREF), ("Plan", "Free", "/account/storage"));
        assert_eq!(prolong_line(false, "", ""), RenewalLine::NothingYet);
        assert_eq!(prolong_line(false, "", "Ask us."), RenewalLine::Said("Ask us.".to_owned()));
        let price = "100.00 SEK / month + VAT (your country's rate, added at checkout)";
        assert_eq!(prolong_line(true, price, ""), RenewalLine::Renew(price.to_owned()));
        // The console keeps no refusal sentence of its own for monetize's refusal: the server
        // says it (PROLONG_REFUSED stays: it is said when the server was not reached at all).
        assert!(!include_str!("purchase.rs").contains(concat!("pub const RENEWING_", "UNAVAILABLE")));
    }

    /// ★ A32-29: **an offer's price line and an order's are the server's** (monetize-site-buy
    /// `Bundle.price_line`, `Attempt.price_line`): the console reads them and composes none.
    #[test]
    fn the_price_beside_the_pay_button_is_the_servers_line() {
        let b: crate::offers::Bundle = serde_json::from_str(r#"{"id":"b","price_words":"100.00 SEK","period":"month","price_line":"the server's line"}"#).unwrap();
        assert_eq!(b.price_line, "the server's line");
        assert!(!include_str!("purchase.rs").contains(concat!("pub fn price_", "line(")), "no price line is composed in the console");
    }

    #[test]
    fn the_vat_note_names_no_country_and_no_rate() {
        let said = [VAT_NOTE.to_owned(), panel_vat_note()];
        for line in &said {
            let note = line.split("VAT").nth(1).unwrap_or_default();
            assert!(line.contains("VAT"), "{line:?} does not say VAT");
            assert!(!note.chars().any(|c| c.is_ascii_digit()), "a rate in the VAT note: {line:?}");
            assert!(!line.contains('%'), "a rate in the VAT note: {line:?}");
            for country in ["Denmark", "Danish", "Sweden", "Swedish", "Finland", "Norway", "Germany", "EU ", "DK", "SE ", "Ireland"] {
                assert!(!line.contains(country), "{country:?} named in the VAT note: {line:?}");
            }
            assert!(line.contains("checkout"), "the note says where the VAT is added: {line:?}");
        }
    }

    // ★ An offer is ONE button, in customer words, with its price on it (owner, 2026-10-02):
    // the words are the server's since A32-29 (monetize-site-buy `offer_words` and its test).

    /// ★ **The More room panel draws each offer as ONE button and no loose
    /// text beside it** — on the customer's Account page and on the operator's
    /// namespace page. The page source of the offers arm is held to it: one
    /// `<button>` whose words are the server's `offer_words`, no `<span>` beside it, and
    /// the VAT said once for the panel.
    #[test]
    fn the_more_room_panel_draws_each_offer_as_one_button_and_nothing_loose() {
        // The customer's offers are the ONE sign-up page's since 2026-10-17;
        // the operator's namespace page keeps its panel.
        {
            let (name, page) = ("increase", crate::code::of(include_str!("pages/increase.rs")));
            let at = page.find("crate::offers::Room::Offered(rows) =>").unwrap_or_else(|| panic!("{name}: no offers arm"));
            let end = at + page[at..].find("crate::offers::Room::Typed =>").unwrap_or_else(|| panic!("{name}: no typed arm"));
            let arm = &page[at..end];
            assert_eq!(arm.matches("<button").count(), 1, "{name}: one button per offer");
            assert!(arm.contains("b.offer_words"), "{name}: the button does not say the offer's words (the server's, A32-29)");
            assert!(arm.contains("crate::purchase::OFFER_CLASS"), "{name}: the offer is not the offer button");
            assert!(!arm.contains("<span"), "{name}: loose text beside the offer button");
            assert!(!arm.contains("price_line("), "{name}: the VAT is said on every offer instead of once");
            assert!(!arm.contains("adds_line(") && !arm.contains("offers::added("), "{name}: what it adds is a loose line");
            assert_eq!(arm.matches("crate::purchase::panel_vat_note()").count(), 1, "{name}: the VAT note, once for the panel");
        }
        assert!(OFFER_CLASS.split(' ').any(|c| c == "btn"), "an offer is drawn as a button");
    }

    #[test]
    fn the_change_is_current_to_new_total() {
        assert_eq!(
            change_line(&map(&[("pack_bytes", 5 * GIB)]), &map(&[("pack_bytes", 15 * GIB)])).as_deref(),
            Some("You have 5.0 GiB storage \u{2192} 15.0 GiB after payment")
        );
        // A first purchase: nothing held before.
        assert_eq!(
            change_line(&BTreeMap::new(), &map(&[("pack_bytes", 10 * GIB)])).as_deref(),
            Some("You have 0 B storage \u{2192} 10.0 GiB after payment")
        );
        assert_eq!(change_line(&BTreeMap::new(), &BTreeMap::new()), None, "no target, no line");
        assert_eq!(adds_line(&map(&[("pack_bytes", 10 * GIB)])), "+10.0 GiB storage");
    }

    /// The four states, in customer words, from monetize's ladder.
    #[test]
    fn the_states_render_in_customer_words() {
        assert_eq!(phase_title(phase("pending", false), None), "Waiting for payment");
        // monetize's own word wins over the ladder (thin layer, 2026-10-04).
        assert_eq!(phase_of("paid_adding", "pending", false), Phase::PaidAdding);
        assert_eq!(phase_of("refunded", "released", false), Phase::Cancelled);
        assert_eq!(phase_of("", "pending", true), Phase::PaidAdding, "no word: the ladder");
        assert_eq!(phase_title(phase("pending", true), None), "Paid \u{2013} adding room");
        assert_eq!(phase_title(phase("paid", false), Some(15 * GIB)), "Done \u{2013} you now have 15.0 GiB storage");
        for ended in ["declined", "released", "released_with_orphans"] {
            assert_eq!(phase_title(phase(ended, false), None), "Cancelled or expired");
        }
        assert_eq!(phase("unknown", false), Phase::Unknown);
    }

    /// Back from Stripe: a slow notice keeps the page following and says how
    /// long; a granted order stops following and names the total.
    #[test]
    fn the_return_from_stripe_shows_the_right_state() {
        let waiting = phase("pending", false);
        assert!(!is_final(waiting));
        assert_eq!(STEPS[step_of(waiting)], "Pay at Stripe");
        let adding = phase("pending", true);
        assert!(!is_final(adding));
        assert!(phase_detail(adding).contains("5 minutes"));
        let done = phase("paid", false);
        assert!(is_final(done));
        assert_eq!(STEPS[step_of(done)], "Room added");
        assert_eq!(now_holds(&map(&[("pack_bytes", 15 * GIB)])), Some(15 * GIB));
    }
}

/// **A door to the ONE sign-up page, as the server named it** (owner
/// 2026-10-07; You → Account → Storage): an absolute `https://` address (a
/// sold instance's private Gunnar is bought from its provider) or a same-site
/// rooted path — login-offer.js's rule. Anything else is no door.
pub fn signup_door(url: Option<String>) -> Option<String> {
    url.filter(|u| u.starts_with("https://") || (u.starts_with('/') && !u.starts_with("//") && !u.starts_with("/\\")))
}

/// The Account page's button when the account HAS a plan.
pub const GROW_YOUR_ACCOUNT: &str = "Grow your account";

/// ★ **The Account page's ONE button, by monetize's answer** (owner
/// 2026-10-17): "Buy a plan" for an account with no plan (monetize's plan
/// state `none`: free), "Grow your account" for one that has a plan. The
/// state is the plan gauge of monetize's window
/// (`/api/account/subscription/bundles`, `plan.state`) — the same word the
/// sign-up page reads for "your plan now"; no plan logic of the console's.
pub fn account_button(plan_state: Option<&str>) -> &'static str {
    match plan_state {
        Some(state) if !state.is_empty() && state != "none" => GROW_YOUR_ACCOUNT,
        _ => BUY_A_PLAN,
    }
}

/// ★ **`/account/storage` is no second purchase page** (owner 2026-10-17):
/// it sends to the ONE sign-up page in the account's signed-in mode — the
/// server's door (`/auth/status` `buy_url`) — and, where the server names no
/// door (a member, a sold Gunnar), to the Account page.
pub fn storage_goes_to(buy_url: Option<String>) -> String {
    signup_door(buy_url).unwrap_or_else(|| "/account".to_owned())
}

#[cfg(test)]
mod account_button_tests {
    use super::{account_button, storage_goes_to, BUY_A_PLAN, GROW_YOUR_ACCOUNT};

    /// ★ Free → "Buy a plan"; a plan → "Grow your account". monetize's word.
    #[test]
    fn a_free_account_reads_buy_a_plan_and_a_planned_one_grow_your_account() {
        assert_eq!((BUY_A_PLAN, GROW_YOUR_ACCOUNT), ("Buy a plan", "Grow your account"));
        // monetize's `none` (State::Free) and no gauge at all: no plan.
        for free in [Some("none"), Some(""), None] {
            assert_eq!(account_button(free), "Buy a plan", "{free:?}");
        }
        // Every state of a held plan, lapsed ones too: the account has one.
        for planned in ["paid", "grace", "read_only", "retention"] {
            assert_eq!(account_button(Some(planned)), "Grow your account", "{planned}");
        }
    }

    /// ★ `/account/storage` goes to the sign-up page's signed-in mode — the
    /// server's door — or, with none, to Account. Never a page of its own.
    #[test]
    fn account_storage_sends_to_the_signup_door_or_to_account() {
        assert_eq!(storage_goes_to(Some("/signup?plan=10gb".into())), "/signup?plan=10gb");
        for none in [None, Some(String::new()), Some("//evil.example/x".into()), Some("javascript:alert(1)".into())] {
            assert_eq!(storage_goes_to(none.clone()), "/account", "{none:?}");
        }
    }
}

#[cfg(test)]
mod signup_door_tests {
    use super::signup_door;

    /// **A door is the server's, as named — or none**: a rooted path on this
    /// console, or the provider's https page; never another site in a local
    /// shape, never a scheme a browser would run.
    #[test]
    fn a_door_is_a_rooted_path_or_an_https_page() {
        assert_eq!(signup_door(Some("/signup?plan=10gb".into())).as_deref(), Some("/signup?plan=10gb"));
        assert_eq!(signup_door(Some("https://gunnar.rs/signup?plan=private".into())).as_deref(), Some("https://gunnar.rs/signup?plan=private"));
        for bad in ["//evil.example/signup", "/\\evil.example", "javascript:alert(1)", "http://gunnar.rs/signup", ""] {
            assert_eq!(signup_door(Some(bad.into())), None, "{bad:?}");
        }
        assert_eq!(signup_door(None), None);
    }
}

/// **An address a selling site is sent to from outside the console** (UW-6). The vendor's
/// ways back, `/paid/{reference}` and `/cancel/{reference}`, are not the console's: the server
/// serves them as the ONE order page (monetize-site-buy `assets::paid_js`, T16, A28 M11).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Address {
    /// `/billing/{tenant}`: the pay link every refusal and Grace line ends in
    /// (gunnar's fed `billing_url`); leads to [`PAY_PAGE`].
    PayPage,
}

/// Where the pay link leads: the tenant's own storage page, where room is
/// bought and a plan prolonged.
pub const PAY_PAGE: &str = "/account/storage";

/// Pure: **which of the outside addresses `path` is**. `None` for every other path.
pub fn vendor_address(path: &str) -> Option<Address> {
    let r = path.strip_prefix("/billing/")?.trim_end_matches('/');
    (!r.is_empty() && !r.contains('/')).then_some(Address::PayPage)
}

#[cfg(test)]
mod vendor_address_tests {
    use super::{vendor_address, Address, PAY_PAGE};

    /// ★ T16 (A28 M11): **the vendor's ways back are not the console's** any more: the server
    /// serves `/paid/{reference}` and `/cancel/{reference}` as the ONE order page (monetize-site-buy
    /// `assets::paid_js`), so the console draws no pane of its own for them.
    #[test]
    fn the_vendors_ways_back_are_the_servers_order_page_not_a_pane() {
        for path in ["/paid/pur_42", "/cancel/pur_42", "/paid/gunnar/kim/2026-10-17+pack_bytes=1"] {
            assert_eq!(vendor_address(path), None, "{path}");
        }
        assert_eq!(vendor_address("/billing/team"), Some(Address::PayPage));
    }

    /// ★ UW-6: **the pay link every refusal ends in is a real page**: `/billing/{tenant}` leads
    /// to the tenant's renew page. Anything else is not the console's.
    #[test]
    fn the_pay_link_address_is_a_real_page() {
        assert_eq!(vendor_address("/billing/team"), Some(Address::PayPage));
        assert_eq!(vendor_address("/billing/team/"), Some(Address::PayPage));
        assert_eq!(PAY_PAGE, "/account/storage", "the tenant's own renew/buy page");
        for none in ["/billing/", "/billing", "/billing/a/b", "/cancelled/x", "/repos", "/"] {
            assert_eq!(vendor_address(none), None, "{none:?}");
        }
    }
}