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

nordisk/holger-ui

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());
    }
}