src/purchase.rs
//! **The paid half of the buyer's pages — what they show** (A28 M13: compiled only under
//! `metered`), pure and host-tested; `pages/plan.rs` renders it. Moved out of `crate::mine`,
//! so an open console compiles no word of it:
//!
//! * **My plan** (`/plan`): for each repository, the room used against the room bought
//! (`UsageService.ReadUsage`: the `disk_bytes` reading and its cap), where its plan stands
//! (paid until, grace, suspended: holger's words from `MyRepositories`), and holger's link to
//! the ONE shop for more room (`RoomService.Room`);
//! * **Your purchases**, the account's **Plan** panel, **Prolong** and **Past purchases**
//! (A14, A16, Gunnar's Account page), each over the site's shared buy doors.
//!
//! **Thin**, as `crate::mine` is: every word about a plan, a price or an order is holger's or
//! monetize's.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
#[cfg(not(target_arch = "wasm32"))]
use crate::admin_queue::pb;
#[cfg(target_arch = "wasm32")]
use crate::grpc::pb;
use crate::mine::{pb_account, pb_room, refusal, AccountDoor, RefusalView};
use crate::util::{disk_view, fmt_bytes, DiskView};
// ── My plan ─────────────────────────────────────────────────────────────────
/// One repository's plan, as the page shows it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanRow {
pub name: String,
/// holger's word for where the plan stands ("paid", "grace", "lapsed", …).
pub state: String,
pub tone: crate::util::Tone,
/// holger's sentence about the plan.
pub words: String,
pub plan: String,
/// "1.0 GB of 5.0 GB", "2.0 kB (no cap)", or holger's refusal to count.
pub room: String,
/// Used / bought, 0..=100, when both are known.
pub percent: Option<u8>,
/// holger's link to the ONE shop for more room; empty: none.
pub shop_url: String,
pub shop_words: String,
}
/// Pure: the tone of holger's plan word (a word this console does not know is untoned).
pub fn plan_tone(state: &str) -> crate::util::Tone {
use crate::util::Tone;
match state {
"paid" => Tone::Good,
"grace" => Tone::Warn,
"lapsed" | "suspended" | "retention" => Tone::Bad,
_ => Tone::Neutral,
}
}
/// Pure: the room line and the share of the cap used, from holger's `ReadUsage`.
pub fn room_line(u: Option<&pb::ReadUsageResponse>, refused: Option<&RefusalView>) -> (String, Option<u8>) {
if let Some(r) = refused {
return (format!("not counted: {}", r.text), None);
}
let Some(u) = u else { return ("not counted".into(), None) };
let cap = u.caps.iter().find(|c| c.meter == "disk_bytes").map(|c| c.limit);
let v = disk_view(u.meters.iter().map(|m| (m.name.as_str(), m.scope == pb::MeterScope::Repository as i32, m.counted, m.value, m.source.as_str(), m.refusal_reason.as_str())));
match (v, cap) {
(DiskView::Counted { bytes, .. }, Some(cap)) if cap > 0 => {
let pct = ((bytes as u128 * 100) / cap as u128).min(100) as u8;
(format!("{} of {}", fmt_bytes(bytes as i64), fmt_bytes(cap as i64)), Some(pct))
}
(DiskView::Counted { bytes, .. }, _) => (format!("{} (no cap)", fmt_bytes(bytes as i64)), None),
(DiskView::Refused(why), _) => (format!("not counted: {why}"), None),
(DiskView::NotReported, _) => ("this server does not report the room used".into(), None),
}
}
/// Pure: one plan row from holger's three answers about the repository.
pub fn plan_row(m: &pb_account::MyRepository, usage: Result<&pb::ReadUsageResponse, &RefusalView>, room: Result<&pb_room::RoomReply, &RefusalView>) -> PlanRow {
let (line, percent) = match usage {
Ok(u) => room_line(Some(u), None),
Err(r) => room_line(None, Some(r)),
};
let (shop_url, shop_words) = match room {
Ok(r) => (r.shop_url.clone(), r.words.clone()),
Err(r) => (String::new(), r.text.clone()),
};
let state = if m.plan_state.is_empty() { "free".to_owned() } else { m.plan_state.clone() };
PlanRow {
name: m.name.clone(),
tone: plan_tone(&state),
state,
words: m.plan_words.clone(),
plan: m.plan.clone(),
room: line,
percent,
shop_url,
shop_words,
}
}
/// The plan page: one row per repository holger says this person has.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanView {
pub rows: Vec<PlanRow>,
pub words: String,
}
pub async fn load_plan(door: &impl AccountDoor) -> Result<PlanView, RefusalView> {
let mine = door.mine().await.map_err(|e| refusal(&e))?;
let mut rows = Vec::new();
for m in &mine.repositories {
let usage = door.usage(&m.name).await.map_err(|e| refusal(&e));
let room = door.room(&m.name).await.map_err(|e| refusal(&e));
rows.push(plan_row(m, usage.as_ref(), room.as_ref()));
}
Ok(PlanView { rows, words: mine.words })
}
// ── Your purchases (A14, Gunnar's Account page) ─────────────────────────────────────────
/// The site's buy door for the person's purchases (the shared buy flow's relay on holger's
/// front): `GET /api/account/purchases`.
pub const PURCHASES_DOOR: &str = "/api/account/purchases";
/// One instance the person bought, as the page draws it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Bought {
pub company: String,
pub host: String,
pub open_url: String,
pub state: String,
pub step: String,
pub monthly: String,
pub paid_until: String,
pub grow: String,
pub grow_url: String,
pub manage: String,
pub renew_url: String,
}
/// **Your purchases**: the instances, the lines for an account without room here, and the
/// way to buy room here.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Purchases {
pub estates: Vec<Bought>,
pub lines: Vec<(String, String)>,
pub buy: Option<(String, String)>,
}
impl Purchases {
pub fn is_empty(&self) -> bool {
self.estates.is_empty() && self.lines.is_empty() && self.buy.is_none()
}
}
/// Pure: a link the page follows — the site's own page (rooted, never `//` or `/\`) or an https
/// address; anything else is no link.
pub fn followed(u: &str) -> String {
let u = u.trim();
let local = u.starts_with('/') && !u.starts_with("//") && !u.starts_with("/\\");
if local || u.starts_with("https://") { u.to_owned() } else { String::new() }
}
/// Pure: **Your purchases** from the site's answer.
pub fn purchases_of(body: &str) -> Option<Purchases> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let s = |e: &serde_json::Value, k: &str| e.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_owned();
let estates = v.get("estates").and_then(|e| e.as_array()).cloned().unwrap_or_default().iter().map(|e| {
let company = s(e, "company");
Bought {
grow: format!("Grow {company}’s Holger"),
manage: format!("Manage {company}"),
host: s(e, "host"),
open_url: { let u = followed(&s(e, "url")); if u.starts_with("https://") { u } else { String::new() } },
state: s(e, "state"),
step: s(e, "step"),
monthly: s(e, "monthly_words"),
paid_until: s(e, "paid_until"),
grow_url: { let u = followed(&s(e, "grow_url")); if u.starts_with('/') { u } else { String::new() } },
renew_url: { let u = followed(&s(e, "renew_url")); if u.starts_with('/') { u } else { String::new() } },
company,
}
}).collect();
let line = |l: &serde_json::Value| (s(l, "words"), followed(&s(l, "url")));
let lines = v.get("storage_lines").and_then(|e| e.as_array()).cloned().unwrap_or_default().iter().map(line).collect();
let buy = v.get("buy").filter(|b| b.is_object()).map(line);
Some(Purchases { estates, lines, buy })
}
/// The person's purchases, asked of the site's buy door with this tab's session.
#[cfg(target_arch = "wasm32")]
pub async fn load_purchases() -> Option<Purchases> {
match crate::session::door(PURCHASES_DOOR, None).await.ok()? {
(200, body) => purchases_of(&body),
_ => None,
}
}
// ── The account's Plan, Prolong and Past purchases (A16, Gunnar's Account page) ─────────────
/// The site's shared doors for the account's own plan (monetize-site-buy's relay on holger's
/// front, Gunnar's paths): the window (its plan gauge), the renewal's quote and order, the
/// history, an order's status (Resume payment), and an instance's row buttons.
pub const BUNDLES_DOOR: &str = "/api/account/subscription/bundles";
pub const RENEWAL_DOOR: &str = "/api/account/subscription";
pub const RENEW_DOOR: &str = "/api/account/subscription/renew";
pub const HISTORY_DOOR: &str = "/api/account/subscription/history";
pub const STATUS_DOOR: &str = "/api/account/subscription/status?reference=";
pub const SIGN_IN_AGAIN_DOOR: &str = "/auth/private/sign-in-again";
pub const RESTORE_FRONT_DOOR: &str = "/auth/private/restore-front";
/// Where "Buy a plan" and "Grow your account" lead: the ONE sign-up page, signed in.
pub const PLAN_PAGE: &str = "/signup?plan=account";
/// Gunnar's words (gunnar-ui-web `purchase.rs`), one place each.
pub const PLAN_PANEL: &str = "Plan";
pub const FREE_PLAN: &str = "Free";
pub const BUY_A_PLAN: &str = "Buy a plan";
pub const GROW_YOUR_ACCOUNT: &str = "Grow your account";
pub const PROLONG_LABEL: &str = "Prolong";
pub const PROLONG_REFUSED: &str = "Prolonging is not available right now.";
pub const PAST_PURCHASES: &str = "Past purchases";
pub const RESUME_LABEL: &str = "Resume payment";
pub const LEAVING: &str = "Taking you to Stripe\u{2026}";
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.";
pub const SIGN_IN_AGAIN_LABEL: &str = "Send my sign-in link again";
pub const RESTORE_FRONT_LABEL: &str = "Restore front";
/// **The Plan panel** (Gunnar's, owner 2026-10-17): "Free" and "Buy a plan", or the plan's
/// name, its storage, the term it is paid until and "Grow your account". Every word about the
/// plan is monetize's.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AccountPlan {
pub name: String,
pub button: &'static str,
pub planned: bool,
pub storage: String,
pub paid_until: String,
/// monetize's line for a term that has ended (grace, read only).
pub ends: String,
pub banner: String,
pub banner_bad: bool,
}
/// Pure: Gunnar's `account_button`: "Buy a plan" without a plan, "Grow your account" with one.
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,
}
}
/// Pure: **the Plan panel from the window** — `None` while monetize is still reading (no plan
/// and a pending line), as Gunnar's panel keeps its spinner then.
pub fn account_plan_of(body: &str) -> Option<AccountPlan> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let plan = v.get("plan").filter(|p| p.is_object());
if plan.is_none() && v.get("pending").is_some_and(|p| !p.is_null()) {
return None;
}
let s = |k: &str| plan.and_then(|p| p.get(k)).and_then(|x| x.as_str()).unwrap_or_default().to_owned();
let n = |k: &str| plan.and_then(|p| p.get(k)).and_then(|x| x.as_u64()).unwrap_or_default();
let state = s("state");
let button = account_button(Some(state.as_str()));
let planned = button == GROW_YOUR_ACCOUNT;
Some(AccountPlan {
name: if planned { s("label") } else { FREE_PLAN.to_owned() },
button,
planned,
storage: if planned && n("cap_bytes") > 0 { crate::util::fmt_bytes(n("cap_bytes") as i64) } else { String::new() },
paid_until: if planned { s("paid_until_words") } else { String::new() },
ends: if planned && state != "paid" { s("ends_words") } else { String::new() },
banner: s("banner"),
banner_bad: s("banner_tone") == "bad",
})
}
/// **What the Prolong line draws** (Gunnar's `renewal_line`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenewalLine {
/// monetize sells another period: the Prolong button, and its price line.
Renew(String),
/// Nothing bought, so nothing to renew: no line.
NothingYet,
/// A refusal, in monetize's customer words.
Said(String),
}
/// Pure: the renewal's answer as the Prolong line.
pub fn renewal_line_of(body: &str) -> RenewalLine {
let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else { return RenewalLine::Said(PROLONG_REFUSED.to_owned()) };
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_owned();
let b = |k: &str| v.get(k).and_then(|x| x.as_bool()).unwrap_or_default();
// A32-29: monetize-site-buy composes both lines; the console only picks one.
if b("can_serve") {
RenewalLine::Renew(s("price_line"))
} else if s("line").is_empty() {
RenewalLine::NothingYet
} else {
RenewalLine::Said(s("line"))
}
}
/// One row of Past purchases (Gunnar's): when, what, monetize's price words, how it ended, and
/// whether it is still open at the vendor (Resume payment).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PastRow {
pub reference: String,
pub date: String,
pub what: String,
pub price: String,
pub outcome: String,
pub resumable: bool,
}
/// Pure: the history as Past purchases. A renewal sells no new room: an empty delta is "another
/// period".
pub fn past_of(body: &str) -> Vec<PastRow> {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
v.as_array().cloned().unwrap_or_default().iter().map(|r| {
let s = |k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_owned();
let delta: Vec<u64> = r.get("delta").and_then(|d| d.as_object()).map(|d| d.values().filter_map(|n| n.as_u64()).collect()).unwrap_or_default();
let outcome = s("outcome");
PastRow {
reference: s("reference"),
date: s("date_words"),
what: if delta.is_empty() { "another period".to_owned() } else { delta.iter().map(|n| crate::util::fmt_bytes(*n as i64)).collect::<Vec<_>>().join(", ") },
price: s("price_words"),
resumable: outcome == "open",
outcome,
}
}).collect()
}
/// Pure: where an order's answer sends the browser to pay (an https checkout while it waits for
/// payment), else nothing.
pub fn pay_now(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let url = v.get("url").and_then(|u| u.as_str()).unwrap_or_default();
let waiting = v.get("phase").and_then(|p| p.as_str()).is_none_or(|p| p.is_empty() || p == "waiting_for_payment") && v.get("state").and_then(|s| s.as_str()) == Some("pending");
(waiting && url.starts_with("https://")).then(|| url.to_owned())
}
/// Pure: the words a door answered — its `words`, else its `error`, else `fallback`.
pub fn words_of(body: &str, fallback: &str) -> String {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
let w = |k: &str| v.get(k).and_then(|x| x.as_str()).filter(|w| !w.is_empty()).map(str::to_owned);
w("words").or_else(|| w("error")).unwrap_or_else(|| fallback.to_owned())
}
/// One GET of a site door with this tab's session: the body when it answered 200.
#[cfg(target_arch = "wasm32")]
pub async fn site_get(path: &str) -> Option<String> {
match crate::session::door(path, None).await.ok()? {
(200, body) => Some(body),
_ => None,
}
}
/// One POST of a site door with this tab's session: `Ok(body)` on 200, `Err(words)` otherwise.
#[cfg(target_arch = "wasm32")]
pub async fn site_post(path: &str, body: &serde_json::Value) -> Result<String, String> {
match crate::session::door(path, Some(body)).await {
Ok((200, b)) => Ok(b),
Ok((_, b)) => Err(words_of(&b, "That did not work. Try again in a moment.")),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ A16: **the Plan panel is Gunnar's** — "Free" and "Buy a plan" without a plan; the
/// plan's name, its storage, the term it is paid until and "Grow your account" with one; the
/// spinner while monetize still reads.
#[test]
fn the_plan_panel_is_free_or_the_plan_with_its_one_button() {
let free = account_plan_of(r#"{"offers":[],"withheld":0,"plan":{"state":"none","cap_bytes":0,"paid_until_unix_ms":0,"days_left":0,"days_total":0,"bump_min_bytes":0}}"#).unwrap();
assert_eq!((free.name.as_str(), free.button, free.planned, free.storage.as_str()), ("Free", "Buy a plan", false, ""));
assert_eq!(account_plan_of(r#"{"offers":[],"withheld":0}"#).unwrap().button, BUY_A_PLAN, "no plan answered: free");
let paid = account_plan_of(r#"{"offers":[],"withheld":0,"plan":{"state":"paid","label":"5 GB","cap_bytes":5000000000,"paid_until_unix_ms":1798761600000,"paid_until_words":"the server's day","days_left":3,"days_total":30,"bump_min_bytes":0}}"#).unwrap();
// A32-29: the day is the server's words (monetize-site-buy `date_words`).
assert_eq!((paid.name.as_str(), paid.button, paid.storage.as_str(), paid.paid_until.as_str(), paid.ends.as_str()), ("5 GB", "Grow your account", "5.0 GB", "the server's day", ""));
let lapsed = account_plan_of(r#"{"plan":{"state":"grace","label":"5 GB","ends_words":"Your term ended; read only until you pay.","cap_bytes":1,"paid_until_unix_ms":1,"days_left":0,"days_total":30,"bump_min_bytes":0}}"#).unwrap();
assert_eq!(lapsed.ends, "Your term ended; read only until you pay.");
assert!(account_plan_of(r#"{"offers":[],"withheld":0,"pending":"Awaiting the cloud provider…"}"#).is_none(), "still reading");
let past = past_of(r#"[{"reference":"r","outcome":"captured","at_unix_ms":1,"date_words":"the server's day","delta":{},"price_words":"x"}]"#);
assert_eq!(past[0].date, "the server's day", "a past purchase's day is the server's words");
assert!(!include_str!("purchase.rs").contains(concat!("pub fn date_", "of(")), "no calendar arithmetic in the purchase page");
}
/// ★ A16: **Prolong** (Gunnar's), and A32-29: **its words are the server's.**
/// monetize-site-buy's renewal answer carries `price_line` (the amount, period and VAT
/// note) and `line` (the refusal sentence; empty when it can be prolonged or nothing was
/// bought): the console draws them and composes neither.
#[test]
fn prolong_is_monetizes_answer() {
assert_eq!(renewal_line_of(r#"{"can_serve":true,"words":"","period":"month","price_words":"100.00 SEK","nothing_to_renew":false,"price_line":"the server's price line","line":""}"#), RenewalLine::Renew("the server's price line".into()));
assert_eq!(renewal_line_of(r#"{"can_serve":false,"nothing_to_renew":true,"words":"","price_line":"","line":""}"#), RenewalLine::NothingYet);
assert_eq!(renewal_line_of(r#"{"can_serve":false,"nothing_to_renew":false,"words":"","price_line":"","line":"The server's sentence."}"#), RenewalLine::Said("The server's sentence.".into()));
assert!(!include_str!("purchase.rs").contains(concat!("pub fn price_", "line(")), "no price line is composed in the console");
assert_eq!(pay_now(r#"{"state":"pending","phase":"","url":"https://checkout.mock.invalid/c"}"#).as_deref(), Some("https://checkout.mock.invalid/c"));
assert_eq!(pay_now(r#"{"state":"paid","phase":"done","url":"https://checkout.mock.invalid/c"}"#), None);
assert_eq!(pay_now(r#"{"state":"pending","url":"javascript:x"}"#), None);
}
/// ★ A16: **Past purchases** (Gunnar's): when, what (a renewal is "another period"),
/// monetize's price words, the outcome, and Resume payment for one still open.
#[test]
fn past_purchases_are_monetizes_rows() {
let rows = past_of(r#"[{"reference":"holger/x14/p-1","outcome":"granted","at_unix_ms":1798761600000,"date_words":"2027-01-01","caps":{},"delta":{"disk_bytes":5000000000},"steps":6,"price_words":"100.00 SEK"},{"reference":"holger/x14/p-2","outcome":"open","at_unix_ms":0,"caps":{},"delta":{},"steps":1,"price_words":""}]"#);
assert_eq!(rows[0], PastRow { reference: "holger/x14/p-1".into(), date: "2027-01-01".into(), what: "5.0 GB".into(), price: "100.00 SEK".into(), outcome: "granted".into(), resumable: false });
assert_eq!((rows[1].what.as_str(), rows[1].resumable), ("another period", true));
assert!(past_of("not json").is_empty());
assert_eq!(words_of(r#"{"words":"Sent."}"#, "x"), "Sent.");
assert_eq!(words_of(r#"{"error":"no order for \"acme\""}"#, "x"), "no order for \"acme\"");
assert_eq!(words_of("", "x"), "x");
}
/// ★ **Your purchases** (A14, Gunnar's Account page): the person's instances, each with
/// "Grow <company>'s Holger" and "Manage <company>" on the site; links are the site's own
/// pages or an instance's https address, never anything else.
#[test]
fn your_purchases_name_each_instance_with_its_grow_and_manage_pages() {
let body = r#"{"estates":[{"company":"acme","host":"acme.holger.rs","url":"https://acme.holger.rs","state":"ready","words":"","step":"Mini","resources_words":"","monthly_words":"96.20 EUR a month","paid_until":"2026-12-01","grow_url":"/signup?plan=grow&instance=acme","renew_url":"/private?renew=acme"},{"company":"evil","host":"x","url":"javascript:x","state":"ready","words":"","step":"","resources_words":"","monthly_words":"","paid_until":"","grow_url":"//evil.example","renew_url":"https://evil.example"}],"rooms":[],"storage_lines":[{"words":"Your Holger is acme.holger.rs","url":"https://acme.holger.rs"}],"buy":{"words":"Want space here on holger.rs too? Buy 5GB/month","url":"/signup?plan=account"}}"#;
let p = purchases_of(body).expect("an answer");
assert_eq!(p.estates.len(), 2);
let acme = &p.estates[0];
assert_eq!((acme.grow.as_str(), acme.grow_url.as_str(), acme.manage.as_str(), acme.renew_url.as_str(), acme.open_url.as_str()), ("Grow acme’s Holger", "/signup?plan=grow&instance=acme", "Manage acme", "/private?renew=acme", "https://acme.holger.rs"));
let evil = &p.estates[1];
assert_eq!((evil.grow_url.as_str(), evil.open_url.as_str()), ("", ""), "only the site's pages or an https address");
assert_eq!(p.lines, vec![("Your Holger is acme.holger.rs".to_string(), "https://acme.holger.rs".to_string())]);
assert_eq!(p.buy, Some(("Want space here on holger.rs too? Buy 5GB/month".to_string(), "/signup?plan=account".to_string())));
assert!(purchases_of("not json").is_none());
assert!(purchases_of(r#"{"estates":[],"rooms":[],"storage_lines":[],"buy":null}"#).unwrap().is_empty());
}
}