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:?}");
}
}
}