crates/gunnar-ui-web/src/pages/increase.rs
//! **The one panel in this console that leads to money**, and it contains
//! none.
//!
//! Compiled only under `metered`. A community build does not render a disabled
//! button or an explanation — it does not contain this file, so there is
//! nothing to render and nothing to explain.
//!
//! # What is written here, and what is not
//!
//! Every visible string below is either **structural** (`Increase`, `Confirm`,
//! `Continue`, `Cancel` — words about the shape of the interaction) or
//! **monetize's own**, rendered as it arrived. There is no price, no currency,
//! no size, no vendor name and no menu written in this file, and the reason is
//! not squeamishness: the same code has to draw the right thing on a deployment
//! that gives room away and on one that charges for it, and it can only do that
//! if it knows neither.
//!
//! * the BUTTONS come from `GET /api/namespaces/{ns}/increase/bundles`, which is
//! monetize's `Purchase.Bundles`. The label, the amount and the period on each
//! one are monetize's own strings, rendered verbatim — this console divides
//! nothing and maps no currency — and the id goes back on the order without
//! ever being parsed;
//! * the METERS come from `GET /api/namespaces/{ns}/increase`, which is
//! monetize's `Products.List` filtered by monetize's own two words;
//! * the TYPED AMOUNT is a number the tenant types, and it is still here. A
//! deployment that declared no bundles answers with an empty list, and a size
//! nobody declared can be asked for no other way. It is echoed back through
//! the same `fmt::bytes` the Room table above uses, so a number means here
//! what it means there. Which of the two shapes is drawn is
//! [`crate::offers::room`]'s decision, not this file's;
//! * what the order SELLS is monetize's `delta`, the period is monetize's word,
//! and a refusal is monetize's sentence;
//! * the LINK is the vendor's, `href` and all. This console does not compose
//! a URL, does not open a payment form, and loads no vendor script.
//!
//! # Why the panel disappears rather than explaining itself
//!
//! When the route is not there — no `metered` server, or no monetize bearer —
//! the fetch fails and this renders **nothing at all**. Not an error, not a
//! greyed-out button: a console that tells a self-hoster "buying more space is
//! unavailable" has told them there is something to buy, which on their box
//! there is not. Absence is the honest state.
//!
//! # The link is an anchor, not a redirect
//!
//! `Purchase.Start` reserves the money and answers with the vendor's hosted
//! page. The tenant clicks it themselves. A `window.location` assignment would
//! move them without their asking, and the one thing worth being slow about is
//! the step where somebody is about to type a card number — on a page this
//! console does not own, which is exactly where it belongs (the card never
//! touches gunnar, and under SCA a 3-D Secure confirm has no URL to hand back
//! at all unless the vendor hosts the page).
//!
//! What happens after they come back is NOT read from the browser. The vendor
//! tells monetize over its own verified webhook; the return URL's query string
//! is the customer's own claim about whether they paid. So the panel polls
//! `…/increase/status` and believes the ledger.
use leptos::prelude::*;
use serde::Deserialize;
use crate::api::segment;
use crate::fmt;
use crate::ui::Panel;
/// One meter this deployment sells room on, as the server sent it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Sellable {
pub meter: String,
pub meaning: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Purchasable {
pub meters: Vec<Sellable>,
/// The server's sentence when it has no meters and that is not an answer.
/// See its twin in `api::increase::Purchasable`.
#[serde(default)]
pub unavailable: Option<String>,
}
/// What an order would come to. No price: see the module docs, and the server's.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Offer {
pub reference: String,
pub can_serve: bool,
pub reason: String,
/// The customer's sentence when monetize sent one; shown instead of
/// `reason`, which is the operator's. Absent from an older server.
#[serde(default)]
pub words: String,
pub period: String,
#[serde(default)]
pub delta: std::collections::BTreeMap<String, u64>,
}
/// **An order placed by pressing a button: one opaque id and nothing else.**
///
/// No meter and no number. Everything about the size and the amount was decided
/// on the far side and stays there; the id came out of the server's own list
/// and goes back unexamined.
#[derive(Debug, Clone, serde::Serialize)]
struct BundleOrder {
bundle: String,
/// The market the window this was pressed in was priced in — the server's, echoed
/// ([`crate::offers::market_of_window`]). Absent: monetize's default.
#[serde(skip_serializing_if = "Option::is_none")]
market: Option<String>,
}
/// An attempt in flight, and where the tenant goes next.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Attempt {
pub reference: String,
pub state: String,
pub url: String,
pub reason: String,
}
/// **The panel, on the namespace's own usage page, under the Room table.**
///
/// It renders in three steps and never skips one: what is sellable, what the
/// order would come to, and — only then — the button.
#[component]
pub fn MoreRoom(#[prop(into)] namespace: Signal<String>) -> impl IntoView {
// Step one. A failure here is silence: the route may simply not exist.
let purchasable = LocalResource::new(move || {
let url = format!("/api/namespaces/{}/increase", segment(&namespace.get()));
async move { crate::api::get::<Purchasable>(&url).await }
});
// **What is on sale, priced.** Its own fetch, and a refusal is not an
// error box: `offers::room` turns an unreadable window into "Awaiting the
// cloud provider…" while it is asked again, then into the server's
// sentence and Retry — never the typed order below (owner, 2026-10-05
// 12:20), and never an empty panel.
//
// **FOLLOWED while the server says it is still being read, or refuses**
// (test2, 2026-10-05) — `subscription::follow_window`, one loop per
// namespace.
let window = RwSignal::new(None::<Result<crate::offers::Shop, crate::api::Fail>>);
let window_late = RwSignal::new(false);
let window_gen = RwSignal::new(0u32);
// The market is the server's (the browser's languages, the one rule): no `?market=` from here.
let window_url = move |ns: String| format!("/api/namespaces/{}/increase/bundles", segment(&ns));
Effect::new(move |_| {
let url = window_url(namespace.get());
window_gen.update(|g| *g = g.wrapping_add(1));
window.set(None);
window_late.set(false);
crate::pages::subscription::follow_window(url, window, window_late, window_gen);
});
// **Retry** (a window refused for a minute): the wait starts again.
let retry_window = Callback::new(move |()| {
window_gen.update(|g| *g = g.wrapping_add(1));
window_late.set(false);
crate::pages::subscription::follow_window(window_url(namespace.get_untracked()), window, window_late, window_gen);
});
// What the tenant asked for. `cap` is a TARGET — the number they want to be
// allowed — because that is what monetize's order message is; the console
// never subtracts, and `delta` in the answer is monetize's subtraction.
let (meter, set_meter) = signal(String::new());
let (cap, set_cap) = signal(String::new());
let (offer, set_offer) = signal(None::<Result<Offer, crate::api::Fail>>);
let (attempt, set_attempt) = signal(None::<Attempt>);
// The chosen offer's price line, for beside the pay button.
let (chosen_price, set_chosen_price) = signal(String::new());
let (busy, set_busy) = signal(false);
// True once the browser has been sent to the vendor: the panel says so
// instead of drawing an order it is leaving.
let (leaving, set_leaving) = signal(false);
// Every change to the numbers throws the last answer away, so a Confirm
// button can never sit under a stale one.
Effect::new(move |_| {
let _ = (meter.get(), cap.get());
set_offer.set(None);
set_attempt.set(None);
});
let ask = move || {
let (ns, meter, cap) = (namespace.get(), meter.get(), cap.get());
let Ok(cap) = cap.trim().parse::<u64>() else {
return;
};
if meter.is_empty() {
return;
}
set_busy.set(true);
leptos::task::spawn_local(async move {
let url = format!(
"/api/namespaces/{}/increase/offer?meter={}&cap={cap}",
segment(&ns),
segment(&meter)
);
set_offer.set(Some(crate::api::get::<Offer>(&url).await));
set_busy.set(false);
});
};
let confirm = move || {
let (ns, meter, cap) = (namespace.get(), meter.get(), cap.get());
let Ok(cap) = cap.trim().parse::<u64>() else {
return;
};
set_busy.set(true);
leptos::task::spawn_local(async move {
let url = format!("/api/namespaces/{}/increase", segment(&ns));
let body = serde_json::json!({ "meter": meter, "cap": cap });
match crate::api::post::<_, Attempt>(&url, &body).await {
// Straight to the vendor, same tab (owner 2026-09-28: everywhere,
// not only on the account page): the order is shown only when there
// is no link to follow or the browser would not go.
Ok(started) => match crate::purchase::go_to_vendor(crate::purchase::phase(&started.state, false), &started.url) {
Some(url) if crate::pages::subscription::navigate_to(&url) => set_leaving.set(true),
_ => set_attempt.set(Some(started)),
},
// The refusal keeps the server's words. Since 2026-09-20 those
// are monetize's CUSTOMER words when it sent them (the server
// reads them off the status, `grpc::Fail::from`) and its
// operator's words verbatim when it did not — never a sentence
// this console made up. "refused" is what happened; "unknown"
// was a word about the ledger and read as a broken state.
Err(fail) => set_attempt.set(Some(Attempt {
reference: String::new(),
state: "refused".to_owned(),
url: String::new(),
reason: fail.message,
})),
}
set_busy.set(false);
});
};
// **Which of the two shapes the panel is in**, decided by
// [`crate::offers::room`] and not here. This closure only says which of the
// three states the read is in, which is the one thing the page knows.
let shape = move || match window.get() {
None => crate::offers::room(crate::offers::Window::Waiting),
Some(Err(fail)) if window_late.get() => {
crate::offers::room(crate::offers::Window::GaveUp(&crate::offers::refusal_words(fail.status, &fail.message, fail.summary())))
}
Some(Err(_)) => crate::offers::room(crate::offers::Window::Refused),
Some(Ok(shop)) if window_late.get() => crate::offers::room(crate::offers::Window::Late(&shop)),
Some(Ok(shop)) => crate::offers::room(crate::offers::Window::Listed(&shop)),
};
// Press one. **No quote step**, and that is not a shortcut: `can_serve` on
// the row IS the quote — monetize runs the same `quote_order` a typed order
// goes through to answer it, on the caps this tenant would end up with. The
// button a tenant presses is already under the answer to the question they
// asked. Everything after it is the ordinary path: the same
// `Purchase.Start`, the same reference, the vendor's own link, the same
// poll.
// `price` and `period` are monetize's words for the chosen offer; the pay
// button drawn when the browser could not leave says them with the VAT.
let order = move |id: String, price_line: String| {
set_chosen_price.set(price_line);
let ns = namespace.get();
set_busy.set(true);
leptos::task::spawn_local(async move {
let url = format!("/api/namespaces/{}/increase/bundle", segment(&ns));
let market = match window.get_untracked() {
Some(Ok(shop)) => crate::offers::market_of_window(&shop),
_ => None,
};
let body = BundleOrder { bundle: id, market };
match crate::api::post::<BundleOrder, Attempt>(&url, &body).await {
// Straight to the vendor, same tab (owner 2026-09-28: everywhere,
// not only on the account page): the order is shown only when there
// is no link to follow or the browser would not go.
Ok(started) => match crate::purchase::go_to_vendor(crate::purchase::phase(&started.state, false), &started.url) {
Some(url) if crate::pages::subscription::navigate_to(&url) => set_leaving.set(true),
_ => set_attempt.set(Some(started)),
},
// The refusal keeps the server's words. Since 2026-09-20 those
// are monetize's CUSTOMER words when it sent them (the server
// reads them off the status, `grpc::Fail::from`) and its
// operator's words verbatim when it did not — never a sentence
// this console made up. "refused" is what happened; "unknown"
// was a word about the ledger and read as a broken state.
Err(fail) => set_attempt.set(Some(Attempt {
reference: String::new(),
state: "refused".to_owned(),
url: String::new(),
reason: fail.message,
})),
}
set_busy.set(false);
});
};
let poll = move || {
let (ns, reference) = (
namespace.get(),
attempt.get().map(|a| a.reference).unwrap_or_default(),
);
if reference.is_empty() {
return;
}
leptos::task::spawn_local(async move {
let url = format!(
"/api/namespaces/{}/increase/status?reference={}",
segment(&ns),
segment(&reference)
);
if let Ok(now) = crate::api::get::<Attempt>(&url).await {
set_attempt.set(Some(now));
}
});
};
move || {
// **THE SILENCE, and its one honest case.** No ROUTE means this
// deployment does not sell: a community build, or a metered one with no
// monetize credential. Nothing is rendered, for the reason the module
// docs give — telling a self-hoster that buying is unavailable has told
// them there is something to buy.
//
// A route that ANSWERS is a different matter, and it used to end here
// too. MEASURED on gunnar.rs 2026-09-17: the panel drew a blank
// rectangle, and the owner — an admin, on a box that sells — had no
// sentence to act on. The server now says which of the two it is, so
// the silence covers only the case it was argued for.
let Some(Ok(purchasable)) = purchasable.get() else {
return ().into_any();
};
if let Some(why) = purchasable.unavailable.clone() {
return view! {
<Panel title="More room">
<div class="panel-body stack">
<p class="hint">{why}</p>
</div>
</Panel>
}
.into_any();
}
if purchasable.meters.is_empty() {
return ().into_any();
}
let meters = purchasable.meters.clone();
if meter.get_untracked().is_empty() {
if let Some(first) = meters.first() {
set_meter.set(first.meter.clone());
}
}
view! {
<Panel title="More room">
<div class="panel-body stack">
<p class="hint">
"What this box will make room for, and what it would take. The numbers \
are the ones the table above counts."
</p>
{move || match shape() {
// Still asking. Neither shape is claimed yet: drawing
// the boxes and then swapping them for buttons would
// move a control out from under a pointer.
crate::offers::Room::Waiting => ().into_any(),
// Still being read (test2, 2026-10-05): the spinner
// and the server's line while the window is asked
// again — never the boxes.
crate::offers::Room::Awaiting(line) => view! {
<crate::pages::subscription::Awaiting line=line />
}.into_any(),
// Refused for a minute: the server's sentence and
// Retry — never the boxes (owner, 2026-10-05 12:20).
crate::offers::Room::Retry(line) => view! {
<crate::pages::subscription::Unreadable line=line retry=retry_window />
}.into_any(),
// ── THE ONE LINE ───────────────────────────────────
//
// The shop has rows and this box can serve none of
// them to this account. The server's sentence, and no
// boxes under it: a typed size against a shop the
// server just called full would only be refused.
crate::offers::Room::Nothing(line) => view! {
<p class="hint">{line}</p>
}.into_any(),
// ── THE BUTTONS ────────────────────────────────────
//
// One per offer, in monetize's order, which is the
// operator's declared order and not this console's to
// sort. Every word on a row is monetize's: the label it
// composed from the delta, the amount it rendered as
// words, the period it named. This console re-renders
// exactly one field — the delta, through the same
// `fmt::bytes` the Room table above uses — and parses
// the id never.
crate::offers::Room::Offered(rows) => view! {
<div class="stack offers">
{rows.into_iter().map(|b| {
let id = b.id.clone();
match crate::offers::row(&b) {
// ONE button per offer, its words and
// price ON it, nothing loose beside it
// (owner, 2026-10-02) — the customer's
// panel's shape; the words are the server's (A32-29).
crate::offers::Row::Press(_) => {
let words = b.offer_words.clone();
let price_line = b.price_line.clone();
view! {
<button
class=crate::purchase::OFFER_CLASS
disabled=move || busy.get()
on:click=move |_| order(id.clone(), price_line.clone())
>
{words}
</button>
}.into_any()
}
// ★ AN OFFER THAT DOES NOT FIT: shown —
// its own words, greyed, as TEXT, no
// button — and monetize's sentence under
// it, verbatim (the customer panel's
// shape, owner test2 2026-10-05).
crate::offers::Row::Marked { why, .. } => {
let words = b.offer_words.clone();
let why = why.to_owned();
view! {
<div class="offer-marked" aria-disabled="true">
<p class="offer-marked-words">{words}</p>
<p class="hint offer-why">{why}</p>
</div>
}.into_any()
}
}
}).collect::<Vec<_>>()}
<p class="hint">{crate::purchase::panel_vat_note()}</p>
</div>
}.into_any(),
// ── THE TYPED ORDER, kept ──────────────────────────
//
// The only way to ask for a size nobody declared, and
// the shape every deployment that declared none is in.
// Quote first, then confirm: unchanged.
crate::offers::Room::Typed => view! {
<div class="row">
<label>
"Meter"
<select
on:change=move |ev| set_meter.set(event_target_value(&ev))
prop:value=move || meter.get()
>
{meters
.iter()
.map(|m| {
let (name, meaning) = (m.meter.clone(), m.meaning.clone());
let label = format!("{name} \u{2014} {meaning}");
view! { <option value=name>{label}</option> }
})
.collect_view()}
</select>
</label>
<label>
"Allowed, after"
<input
type="number"
min="0"
on:input=move |ev| set_cap.set(event_target_value(&ev))
prop:value=move || cap.get()
/>
</label>
// The typed number, spelled the way the table above
// spells its numbers, so the tenant can see what they
// just asked for before anything is quoted.
<p class="meta">
{move || cap
.get()
.trim()
.parse::<u64>()
.map(fmt::bytes)
.unwrap_or_default()}
</p>
<button
class="btn"
disabled=move || busy.get()
on:click=move |_| ask()
>
"Increase"
</button>
</div>
{move || match offer.get() {
None => ().into_any(),
Some(Err(fail)) => view! { <p class="meta">{fail.message}</p> }.into_any(),
Some(Ok(offer)) => {
// J7: the customer's words, never the meter id.
let delta: Vec<(String, u64)> = offer.delta.iter().map(|(m, a)| (m.clone(), *a)).collect();
let sells = crate::purchase::sells_words(&delta, &|b| fmt::bytes(b));
let period = offer.period.clone();
let reason = if offer.words.is_empty() { offer.reason.clone() } else { offer.words.clone() };
let can = offer.can_serve;
view! {
<dl class="metrics">
<div>
<dt>"Sells"</dt>
<dd>{sells}</dd>
</div>
<div>
<dt>"Period"</dt>
<dd>{period}</dd>
</div>
</dl>
// can_serve false: the sentence and no button.
// The wall is the product's, the cloud's or the
// box's, and it is quoted rather than summarised.
{(!can).then(|| view! { <p class="hint">{reason.clone()}</p> })}
{can
.then(|| {
view! {
<button
class="btn primary"
disabled=move || busy.get()
on:click=move |_| confirm()
>
"Confirm"
</button>
}
})}
}
.into_any()
}
}}
}.into_any(),
}}
{move || leaving.get().then(|| view! {
<p class="hint" aria-live="polite">{crate::purchase::LEAVING}</p>
})}
{move || match attempt.get() {
None => ().into_any(),
Some(attempt) => {
let state = attempt.state.clone();
let reason = attempt.reason.clone();
let url = attempt.url.clone();
view! {
<p class="meta">{state}</p>
{(!reason.is_empty()).then(|| view! { <p class="hint">{reason.clone()}</p> })}
// THE LINK. Its href is the vendor's, byte for
// byte, and this console composed none of it.
// `rel` because the page it opens is not ours.
// The ONE pay button (`pages::subscription::
// PayButton`), with the chosen offer's price.
{(!url.is_empty())
.then(|| {
view! {
<crate::pages::subscription::PayButton
url=url.clone()
price=chosen_price.get()
/>
}
})}
<button class="btn quiet" on:click=move |_| poll()>
"Check again"
</button>
}
.into_any()
}
}}
</div>
</Panel>
}
.into_any()
}
}