crates/monetize-site-buy/src/purchase.rs
//! **The account's grow, as the browser sees it** — moved as it is from Gunnar's console
//! (gunnar-ui-server `api::increase` `Window`/`window_of`/`render_*`, `api::subscription`
//! `Attempt`/`phase_word`/`state_name`/`order_action`/`target_caps`), over monetize-proto's
//! types. Every string is monetize's; nothing here prices or composes a sentence.
use std::collections::BTreeMap;
use monetize_proto as mpb;
use serde::{Deserialize, Serialize};
/// `Bundle` as the browser sees it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Bundle {
pub id: String,
pub label: String,
/// The amount, as monetize rendered it (`Bundle.price` on the wire is already words).
pub price_words: String,
pub period: String,
/// ★ A32-29: `100.00 SEK / month + VAT (…)`, composed here once ([`price_line`]); empty
/// when there is no amount. The console draws it beside the pay button.
pub price_line: String,
/// ★ A32-29: the offer button's words, `+10 GiB storage — 100.00 SEK / month`, composed
/// here once ([`offer_words`]); the console draws them on the button.
pub offer_words: String,
pub delta: BTreeMap<String, u64>,
pub can_serve: bool,
pub reason: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub action: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub target: BTreeMap<String, u64>,
}
/// **The account's plan, as monetize words it** (`ListBundlesResponse.plan`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Plan {
pub state: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub period: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub label: String,
pub cap_bytes: u64,
pub paid_until_unix_ms: u64,
/// ★ A32-29: the paid-until day as words ([`date_words`]); empty when there is none.
#[serde(skip_serializing_if = "String::is_empty")]
pub paid_until_words: String,
pub days_left: u32,
pub days_total: u32,
#[serde(skip_serializing_if = "String::is_empty")]
pub ends_words: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub price_words: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub banner: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub banner_action: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub banner_tone: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub bump_words: String,
pub bump_min_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress: Option<RoomLine>,
#[serde(skip_serializing_if = "String::is_empty")]
pub progress_reference: String,
}
/// **A day as the browser shows it**: `YYYY-MM-DD` of a unix time in milliseconds (UTC); empty
/// for 0 (no day). A32-29: the one copy; the consoles carried their own calendar arithmetic.
pub fn date_words(ms: u64) -> String {
if ms == 0 {
return String::new();
}
let days = (ms / 86_400_000) as i64;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
/// `PlanStatus` as the browser sees it.
pub fn render_plan(p: mpb::PlanStatus) -> Plan {
Plan {
state: p.state,
period: p.period,
label: p.label,
cap_bytes: p.cap_bytes,
paid_until_unix_ms: p.paid_until_unix_ms,
paid_until_words: date_words(p.paid_until_unix_ms),
days_left: p.days_left,
days_total: p.days_total,
ends_words: p.ends_words,
price_words: p.price_words,
banner: p.banner,
banner_action: p.banner_action,
banner_tone: p.banner_tone,
bump_words: p.bump_words,
bump_min_bytes: p.bump_min_bytes,
progress: p.progress.map(|r| RoomLine {
state: r.state,
words: r.words,
}),
progress_reference: p.progress_reference,
}
}
/// `Bundle` as the browser sees it.
pub fn render_bundle(b: mpb::Bundle) -> Bundle {
let offer_words = offer_words(
&b.label,
&b.delta.iter().map(|(k, v)| (k.clone(), *v)).collect(),
&b.price,
&b.period,
);
Bundle {
id: b.id,
label: b.label,
price_line: price_line(&b.price, &b.period),
offer_words,
price_words: b.price,
period: b.period,
delta: b.delta.into_iter().collect(),
can_serve: b.can_serve,
reason: b.reason,
action: b.action,
target: b.target.into_iter().collect(),
}
}
/// **The shop window: only what this box can sell** (owner 2026-09-20: a bundle the box cannot
/// serve is not rendered at all; a PLAN row keeps its refusal).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Window {
pub offers: Vec<Bundle>,
pub withheld: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pending: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub late: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan: Option<Plan>,
#[serde(skip_serializing_if = "String::is_empty")]
pub market: String,
/// ★ A32-29: **when the console asks again** ([`POLL`]), the server's schedule; only on a
/// window still being read (an answered window is not asked again).
#[serde(skip_serializing_if = "Option::is_none")]
pub poll: Option<Poll>,
}
/// **How a console follows a window that is still being read** (A32-29: the schedule is the
/// server's, sent with every window): ask again every `again_ms`; past `late_after_ms` draw the
/// `late` line and ask every `late_again_ms`; stop at `give_up_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Poll {
pub again_ms: u32,
pub late_after_ms: u32,
pub late_again_ms: u32,
pub give_up_ms: u32,
}
/// The one schedule (it was gunnar-ui-web's `offers::next_poll_ms`, MEASURED 2026-10-05).
pub const POLL: Poll = Poll {
again_ms: 2_000,
late_after_ms: 60_000,
late_again_ms: 10_000,
give_up_ms: 10 * 60_000,
};
impl Poll {
/// Whether `asked_ms` of asking is past [`Poll::late_after_ms`].
pub fn is_late(&self, asked_ms: u32) -> bool {
asked_ms >= self.late_after_ms
}
/// How long to wait before asking again after `asked_ms` of asking; `None`: stop.
pub fn next_ms(&self, asked_ms: u32) -> Option<u32> {
if asked_ms >= self.give_up_ms {
None
} else if self.is_late(asked_ms) {
Some(self.late_again_ms)
} else {
Some(self.again_ms)
}
}
}
/// The line beside the spinner while monetize's cloud reading runs.
pub const AWAITING_PROVIDER: &str = "Awaiting the cloud provider…";
/// The line once the reading is still running after a minute.
pub const PROVIDER_LATE: &str =
"The cloud provider has not answered yet. The sizes on offer appear here as soon as it does.";
/// The line under an empty shop that has rows.
pub const NOTHING_FITS: &str = "None of the sizes on offer can be added to this account right now.";
/// **monetize's list, reduced to the rows it can serve.**
pub fn window(bundles: Vec<mpb::Bundle>) -> Window {
let declared = bundles.len();
let offers: Vec<Bundle> = bundles
.into_iter()
.filter(|b| {
// A PLAN row keeps its refusal: it is the customer's next step, not a size the box
// cannot hold (owner, 2026-10-05).
if !b.action.is_empty() {
return true;
}
if !b.can_serve {
tracing::info!(target: crate::relay::AUDIT_TARGET, bundle = %b.id, label = %b.label, monetize_said = %b.reason, "a bundle monetize cannot serve is left out of the window");
}
b.can_serve
})
.map(render_bundle)
.collect();
let withheld = declared - offers.len();
Window {
unavailable: (offers.is_empty() && withheld > 0).then(|| NOTHING_FITS.to_owned()),
offers,
withheld,
pending: None,
late: None,
plan: None,
market: String::new(),
poll: None,
}
}
/// **monetize's whole answer to `Purchase.Bundles`**: [`window`] over its rows, or — while the
/// window is still being read — no rows and the two lines the panel draws while it asks again.
pub fn window_of(answer: mpb::ListBundlesResponse) -> Window {
if answer.reading {
return Window {
offers: Vec::new(),
withheld: 0,
unavailable: None,
pending: Some(AWAITING_PROVIDER.to_owned()),
late: Some(PROVIDER_LATE.to_owned()),
plan: None,
market: String::new(),
poll: Some(POLL),
};
}
let plan = answer.plan.map(render_plan);
Window {
plan,
..window(answer.bundles)
}
}
/// monetize's `RoomProgress`, for the page.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomLine {
pub state: String,
pub words: String,
}
/// monetize's `UsersGrant`, for the page.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsersLine {
pub principal: String,
pub seats: u32,
pub seats_open: u32,
pub room_bytes: u64,
pub need_bytes: u64,
pub from_unused_bytes: u64,
pub bought_bytes: u64,
pub granted: bool,
pub invited: String,
pub refunded: bool,
pub reason: String,
}
impl From<mpb::UsersGrant> for UsersLine {
fn from(u: mpb::UsersGrant) -> UsersLine {
UsersLine {
principal: u.principal,
seats: u.seats,
seats_open: u.seats_open,
room_bytes: u.room_bytes,
need_bytes: u.need_bytes,
from_unused_bytes: u.from_unused_bytes,
bought_bytes: u.bought_bytes,
granted: u.granted,
invited: u.invited,
refunded: u.refunded,
reason: u.reason,
}
}
}
/// **Where the buyer is sent, and how the site follows what happens there.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Attempt {
pub reference: String,
pub state: &'static str,
pub url: String,
pub reason: String,
pub words: String,
#[serde(default)]
pub before: BTreeMap<String, u64>,
#[serde(default)]
pub after: BTreeMap<String, u64>,
#[serde(default)]
pub captured: bool,
#[serde(default)]
pub price_words: String,
#[serde(default)]
pub period: String,
/// ★ A32-29: the chosen offer's price line ([`price_line`]); empty when there is no amount.
#[serde(default)]
pub price_line: String,
#[serde(default)]
pub phase: String,
#[serde(default)]
pub cancellable: bool,
#[serde(default)]
pub users: Option<UsersLine>,
#[serde(default)]
pub room: Option<RoomLine>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paid_until_unix_ms: Option<u64>,
/// ★ A32-29: that day as words ([`date_words`]); absent while the order names no day.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub paid_until_words: String,
}
/// monetize's `PurchasePhase` as the word the browser reads; empty when unset.
pub fn phase_word(wire: i32) -> String {
match mpb::PurchasePhase::try_from(wire) {
Ok(mpb::PurchasePhase::WaitingForPayment) => "waiting_for_payment",
Ok(mpb::PurchasePhase::PaidAdding) => "paid_adding",
Ok(mpb::PurchasePhase::Done) => "done",
Ok(mpb::PurchasePhase::Cancelled) => "cancelled",
Ok(mpb::PurchasePhase::Declined) => "declined",
Ok(mpb::PurchasePhase::Refunded) => "refunded",
_ => "",
}
.to_owned()
}
/// monetize's purchase ladder as one lower-case word.
pub fn state_name(wire: i32) -> &'static str {
match mpb::PurchaseState::try_from(wire) {
Ok(mpb::PurchaseState::Pending) => "pending",
Ok(mpb::PurchaseState::Paid) => "paid",
Ok(mpb::PurchaseState::Declined) => "declined",
Ok(mpb::PurchaseState::Released) => "released",
_ => "unknown",
}
}
/// **What an order does to the plan, off the order's own `Reserved` row**, and its period.
pub fn order_action(rows: &[mpb::LedgerRow]) -> (String, String) {
let none = (String::new(), String::new());
let Some(row) = rows.iter().rev().find(|r| r.kind == "Reserved") else {
return none;
};
let Ok(step) = serde_json::from_str::<serde_json::Value>(&row.json) else {
return none;
};
let Some(purchase) = step
.get("Reserved")
.and_then(|r| r.get("purchase"))
.or_else(|| step.get("purchase"))
else {
return none;
};
if let Some(plan) = purchase.get("plan").filter(|p| !p.is_null()) {
let verb = plan
.get("verb")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
let period = plan
.get("period")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_lowercase();
return (verb, period);
}
let sold_nothing = purchase
.get("delta")
.and_then(|d| d.as_object())
.is_some_and(|d| d.is_empty());
let no_users = purchase.get("users").is_none_or(|u| u.is_null());
if sold_nothing && no_users {
("renew".to_owned(), String::new())
} else {
none
}
}
/// **The caps a bundle order resolves to**: what the tenant holds, each of the bundle's meters
/// raised by its delta (saturating) — monetize's own `target_of`, so THE TICKET MATCHES.
pub fn target_caps(
current: &BTreeMap<String, u64>,
delta: &BTreeMap<String, u64>,
) -> BTreeMap<String, u64> {
let mut target = current.clone();
for (meter, add) in delta {
let held = current.get(meter).copied().unwrap_or(0);
target.insert(meter.clone(), held.saturating_add(*add));
}
target
}
/// **The caps the order for `chosen` names** (the ticket covers exactly these): a renewal
/// (`prolong`/`renew`) holds what it holds; a plan row ends at its `target`; a plain bundle is
/// the held caps raised by its delta.
pub fn order_caps(chosen: &mpb::Bundle, held: &BTreeMap<String, u64>) -> BTreeMap<String, u64> {
if is_renewal(chosen) {
held.clone()
} else if !chosen.target.is_empty() {
chosen.target.clone().into_iter().collect()
} else {
target_caps(held, &chosen.delta.clone().into_iter().collect())
}
}
/// A `prolong` or `renew` row is a RENEWAL: the renew ticket, no caps raised.
pub fn is_renewal(chosen: &mpb::Bundle) -> bool {
matches!(chosen.action.as_str(), "prolong" | "renew")
}
/// **The attempt a bundle order answers**: before and after for the meters it raises only.
pub fn bundle_attempt(
started: mpb::StartPurchaseResponse,
chosen: &mpb::Bundle,
held: &BTreeMap<String, u64>,
caps: &BTreeMap<String, u64>,
url: String,
) -> Attempt {
let raised: std::collections::BTreeSet<&String> = chosen.delta.keys().collect();
let pick = |m: &BTreeMap<String, u64>| {
m.iter()
.filter(|(k, _)| raised.contains(k))
.map(|(k, v)| (k.clone(), *v))
.collect()
};
Attempt {
reference: started.reference,
state: state_name(started.state),
url,
reason: started.reason,
words: started.words,
before: pick(held),
after: pick(caps),
captured: false,
price_words: chosen.price.clone(),
period: chosen.period.clone(),
price_line: price_line(&chosen.price, &chosen.period),
phase: String::new(),
cancellable: false,
users: None,
room: None,
action: chosen.action.clone(),
paid_until_unix_ms: None,
paid_until_words: String::new(),
}
}
/// **The attempt a status poll answers** (the way back from the vendor).
pub fn status_attempt(progress: mpb::PurchaseStatusResponse, url: String) -> Attempt {
let phase = phase_word(progress.phase);
let captured = phase == "paid_adding";
let users = progress.users.clone().map(UsersLine::from);
let room = progress.room.clone().map(|r| RoomLine {
state: r.state,
words: r.words,
});
let (action, period) = order_action(&progress.rows);
let paid = progress.state == mpb::PurchaseState::Paid as i32;
let paid_until_unix_ms = progress
.fact
.as_ref()
.filter(|_| paid)
.and_then(|fact| fact.paid_until_unix_ms);
let after = if paid {
progress
.fact
.map(|fact| fact.caps.into_iter().collect())
.unwrap_or_default()
} else {
Default::default()
};
Attempt {
reference: progress.reference,
state: state_name(progress.state),
url,
reason: progress.reason,
words: String::new(),
before: Default::default(),
after,
captured,
price_words: String::new(),
period,
price_line: String::new(),
phase,
cancellable: progress.cancellable,
users,
room,
action,
paid_until_words: paid_until_unix_ms.map(date_words).unwrap_or_default(),
paid_until_unix_ms,
}
}
/// **The attempt a cancel answers**: the order closed, nothing to pay (Gunnar's console's).
pub fn cancelled_attempt(closed: mpb::CancelPurchaseResponse) -> Attempt {
Attempt {
reference: closed.reference,
state: state_name(closed.state),
url: String::new(),
reason: closed.reason,
words: String::new(),
before: Default::default(),
after: Default::default(),
captured: false,
price_words: String::new(),
period: String::new(),
price_line: String::new(),
phase: phase_word(closed.phase),
cancellable: false,
users: None,
room: None,
action: String::new(),
paid_until_unix_ms: None,
paid_until_words: String::new(),
}
}
// ── the account's own renewal and history (Gunnar's `api::subscription`, moved as it is) ──
/// **What another period would come to** (`GET /api/account/subscription`). No price and no
/// cost: monetize's words only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct Renewal {
/// The ledger key a renewal would run under.
pub reference: String,
/// Whether monetize will sell another period.
pub can_serve: bool,
/// monetize's refusal in its own words, for the operator's log.
pub reason: String,
/// monetize's sentence for the CUSTOMER. The page shows this when set.
pub words: String,
/// `month` | `year` — monetize's word, verbatim.
pub period: String,
/// What renewing would cost, in monetize's own words (`220.00 SEK`).
pub price_words: String,
/// The caps the tenant holds today and would go on holding.
pub caps: BTreeMap<String, u64>,
/// This account has bought nothing, so there is nothing to renew (monetize refused AND the
/// product holds no entitlement for it).
pub nothing_to_renew: bool,
/// ★ A32-29: **the Prolong button's price line**, composed here once ([`price_line`]);
/// empty when there is no price. The consoles draw it.
#[serde(default)]
pub price_line: String,
/// ★ A32-29: **the customer's sentence when it cannot be prolonged**: monetize's `words`,
/// or [`RENEWING_UNAVAILABLE`] when it sent none; empty when it can be, or when nothing
/// was bought (the page then offers a plan instead).
#[serde(default)]
pub line: String,
}
/// What every price line adds after the amount.
pub const VAT_NOTE: &str = "+ VAT (your country's rate, added at checkout)";
/// The Prolong line when monetize refused and sent no words of its own.
pub const RENEWING_UNAVAILABLE: &str = "Prolonging is not available right now.";
/// **`100.00 SEK / month + VAT (…)`** from monetize's own amount and period words; empty when
/// monetize sent no amount: a price is never composed from nothing (A32-29: one copy, here).
pub fn price_line(price_words: &str, period: &str) -> String {
let amount = price_words.trim();
if amount.is_empty() {
return String::new();
}
let per = if period.trim().is_empty() {
String::new()
} else {
format!(" / {}", period.trim())
};
format!("{amount}{per} {VAT_NOTE}")
}
/// A meter as the customer reads it on an offer: `pack_bytes` is "storage" (journey J7: never
/// a meter id).
fn meter_word(meter: &str) -> &'static str {
match meter {
"pack_bytes" | "disk_bytes" => "storage",
"cache_bytes" => "read cache",
"cold_bytes" => "archive",
"lfs_bytes" => "large files",
_ => "room",
}
}
/// A byte count as an offer says it: `10 GiB`, `1.5 GiB` (one decimal, none when it is `.0`).
fn offer_bytes(count: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
if count < 1024 {
return format!("{count} B");
}
let mut value = count as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit]).replacen(".0 ", " ", 1)
}
/// **What an offer's button says**: `+10 GiB storage — 100.00 SEK / month`. What it adds, in
/// customer words (monetize's `label` only when it sent no delta), then monetize's amount and
/// period. No VAT: the panel says it once. No amount, no price: one is never composed here
/// (A32-29: one copy, here; the console's own went).
pub fn offer_words(
label: &str,
delta: &BTreeMap<String, u64>,
price_words: &str,
period: &str,
) -> String {
let adds = delta
.iter()
.map(|(meter, bytes)| format!("+{} {}", offer_bytes(*bytes), meter_word(meter)))
.collect::<Vec<_>>()
.join(", ");
let what = if adds.is_empty() {
label.trim().to_owned()
} else {
adds
};
let amount = price_words.trim();
if amount.is_empty() {
return what;
}
let per = if period.trim().is_empty() {
String::new()
} else {
format!(" / {}", period.trim())
};
format!("{what} \u{2014} {amount}{per}")
}
/// The customer's Prolong sentence ([`Renewal::line`]).
fn renewal_words(can_serve: bool, nothing_to_renew: bool, words: &str) -> String {
if can_serve || nothing_to_renew {
String::new()
} else if words.trim().is_empty() {
RENEWING_UNAVAILABLE.to_owned()
} else {
words.to_owned()
}
}
/// The renewal a quote answers.
pub fn renewal_of(quote: mpb::QuoteResponse) -> Renewal {
let price_line = if quote.can_serve {
price_line("e.price_words, "e.period)
} else {
String::new()
};
let line = renewal_words(quote.can_serve, false, "e.words);
Renewal {
reference: quote.reference,
can_serve: quote.can_serve,
reason: quote.reason,
words: quote.words,
period: quote.period,
price_words: quote.price_words,
caps: quote.caps.into_iter().collect(),
nothing_to_renew: false,
price_line,
line,
}
}
/// **A refused renewal is an answer, not a failure**: monetize's refusal (`FAILED_PRECONDITION`)
/// with its words unchanged, and whether the account has bought nothing at all.
pub fn refused_renewal(reason: &str, words: Option<String>, nothing_to_renew: bool) -> Renewal {
let words = words.unwrap_or_default();
let line = renewal_words(false, nothing_to_renew, &words);
Renewal {
reference: String::new(),
can_serve: false,
reason: reason.to_owned(),
words,
period: String::new(),
price_words: String::new(),
caps: Default::default(),
nothing_to_renew,
price_line: String::new(),
line,
}
}
/// The attempt a renewal starts (`POST /api/account/subscription/renew`).
pub fn renew_attempt(started: mpb::StartPurchaseResponse, url: String) -> Attempt {
Attempt {
reference: started.reference,
state: state_name(started.state),
url,
reason: started.reason,
words: started.words,
before: Default::default(),
after: Default::default(),
captured: false,
price_words: String::new(),
period: String::new(),
price_line: String::new(),
phase: String::new(),
cancellable: false,
users: None,
room: None,
// The renew endpoint IS a renewal: another period of what is held.
action: "renew".to_owned(),
paid_until_unix_ms: None,
paid_until_words: String::new(),
}
}
/// **One line of what this account has bought** (`GET /api/account/subscription/history`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct Past {
/// The ledger key, so support and customer can name the same thing.
pub reference: String,
/// monetize's own word for how it ended.
pub outcome: String,
/// When it ended.
pub at_unix_ms: u64,
/// ★ A32-29: that day as words ([`date_words`]).
#[serde(default)]
pub date_words: String,
/// What the tenant held after it, per meter.
pub caps: BTreeMap<String, u64>,
/// What this purchase added; empty for a renewal.
pub delta: BTreeMap<String, u64>,
/// How many ledger rows are behind this one line.
pub steps: u32,
/// What the customer was charged, in monetize's own words.
pub price_words: String,
}
/// Field by field, never `serde` over the proto message: a field added upstream must not reach
/// a browser because nobody looked. `price` (the raw Money) is not read.
pub fn past_of(a: mpb::PurchaseAttempt) -> Past {
Past {
reference: a.reference,
outcome: a.outcome,
date_words: date_words(a.at_unix_ms),
at_unix_ms: a.at_unix_ms,
caps: a.caps.into_iter().collect(),
delta: a.delta.into_iter().collect(),
steps: a.steps,
price_words: a.price_words,
}
}
// ── Your purchases (Gunnar's `api::subscription::purchases`, moved as it is) ─────────────
/// **Your purchases**: what this account bought at this shop — its private instances first, then
/// its room here; for an account WITHOUT room here, one line per instance and the way to buy
/// room here too.
#[derive(Debug, Serialize)]
pub struct Purchases {
pub estates: Vec<Instance>,
pub rooms: Vec<RoomBought>,
pub storage_lines: Vec<Line>,
pub buy: Option<Line>,
}
#[derive(Debug, Serialize)]
pub struct Instance {
pub company: String,
pub host: String,
pub url: String,
pub state: String,
pub words: String,
pub step: String,
pub resources_words: String,
pub monthly_words: String,
pub paid_until: String,
/// Where "Grow" leads: the sign-up page's grow of this instance.
pub grow_url: String,
/// Where "Manage" / "Renew" leads: the instance's renewal on the site.
pub renew_url: String,
/// ★ ACCT-6: why its front needs a repair, monetize's words; empty while it stands. A
/// console draws "Restore front" (in a Repair section) from this alone.
pub repair_words: String,
}
#[derive(Debug, Serialize)]
pub struct RoomBought {
pub state: String,
pub paid_until: String,
pub cap_bytes: u64,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct Line {
pub words: String,
pub url: String,
}
/// Pure: **an account without room here** — one monetize KNOWS to hold no room here (it bought
/// a private instance, or is an account at this shop); never one monetize knows nothing of.
pub fn without_room(answer: &mpb::EstateOrderPurchasesResponse) -> bool {
answer.rooms.is_empty() && (!answer.estates.is_empty() || answer.account_here)
}
/// Pure: **Your purchases** from `EstateOrder.Purchases`, on the site `here` that sells
/// `product` ("Gunnar", "Holger"), with its `pages` and the shop's word on what an account is
/// (`offer`, "10GB"; asked only for an account without room here).
pub fn purchases_of(
answer: mpb::EstateOrderPurchasesResponse,
pages: &crate::urls::Pages,
product: &str,
here: &str,
offer: Option<String>,
) -> Purchases {
let without = without_room(&answer);
let estates: Vec<Instance> = answer
.estates
.into_iter()
.map(|e| Instance {
grow_url: format!("/signup?plan=grow&instance={}", e.company),
renew_url: format!("{}{}", pages.manage, e.company),
company: e.company,
host: e.host,
url: e.url,
state: e.state,
words: e.words,
step: e.step,
resources_words: e.resources_words,
monthly_words: e.monthly_words,
paid_until: e.paid_until,
repair_words: e.repair_words,
})
.collect();
let rooms: Vec<RoomBought> = answer
.rooms
.into_iter()
.map(|r| RoomBought {
state: r.state,
paid_until: r.paid_until,
cap_bytes: r.cap_bytes,
})
.collect();
let storage_lines = if without {
estates
.iter()
.map(|e| Line {
words: format!("Your {product} is {}", e.host),
url: if e.url.is_empty() {
format!("https://{}", e.host)
} else {
e.url.clone()
},
})
.collect()
} else {
Vec::new()
};
let buy = offer.filter(|_| without).map(|offer| Line {
words: format!("Want space here on {here} too? Buy {offer}/month"),
url: pages.account.clone(),
});
Purchases {
estates,
rooms,
storage_lines,
buy,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ ACCT-6: the row carries monetize's repair words as given, and none for a front that
/// stands.
#[test]
fn a_purchase_row_carries_monetizes_repair_words() {
let estate = |company: &str, repair: &str| mpb::EstatePurchase {
company: company.into(),
host: format!("{company}.gunnar.rs"),
state: "ready".into(),
repair_words: repair.into(),
..Default::default()
};
let answer = mpb::EstateOrderPurchasesResponse {
estates: vec![
estate(
"acme",
"acme.gunnar.rs's front has not answered for 4 hours.",
),
estate("bolt", ""),
],
..Default::default()
};
let p = purchases_of(
answer,
&crate::urls::Pages::default(),
"Gunnar",
"gunnar.rs",
None,
);
assert_eq!(
p.estates[0].repair_words,
"acme.gunnar.rs's front has not answered for 4 hours."
);
assert_eq!(p.estates[1].repair_words, "");
let json = serde_json::to_value(&p).unwrap();
assert_eq!(
json["estates"][0]["repair_words"],
"acme.gunnar.rs's front has not answered for 4 hours."
);
}
#[test]
fn only_this_products_enforced_priced_meters_are_sellable() {
let meter = |n: &str, e: mpb::Enforcement, unit: &str| mpb::MeterInfo {
name: n.into(),
meaning: format!("{n}!"),
enforcement: e as i32,
unit: unit.into(),
..Default::default()
};
let answer = mpb::ListProductsResponse {
products: vec![
mpb::ProductInfo {
id: "gunnar".into(),
meters: vec![
meter("pack_bytes", mpb::Enforcement::Enforced, "disk_gib_month"),
meter("repos", mpb::Enforcement::Enforced, ""),
meter("lfs", mpb::Enforcement::Measured, "gib"),
],
..Default::default()
},
mpb::ProductInfo {
id: "holger".into(),
meters: vec![meter("artifacts", mpb::Enforcement::Enforced, "gib")],
..Default::default()
},
],
..Default::default()
};
assert_eq!(
sellable(&answer, "gunnar"),
vec![Sellable {
meter: "pack_bytes".into(),
meaning: "pack_bytes!".into()
}]
);
assert!(sellable(&answer, "nobody").is_empty());
}
#[test]
fn an_offer_drops_the_price_and_the_cost() {
let quote = mpb::QuoteResponse {
reference: "r".into(),
can_serve: true,
price: Some(mpb::Money {
minor: 1_000,
..Default::default()
}),
cost: Some(mpb::Money {
minor: 133_700,
..Default::default()
}),
delta: [("pack_bytes".to_owned(), 90u64)].into_iter().collect(),
..Default::default()
};
let shown = serde_json::to_string(&render_offer(quote)).unwrap();
assert!(
!shown.contains("133700")
&& !shown.contains("1000")
&& shown.contains("\"delta\":{\"pack_bytes\":90}"),
"{shown}"
);
}
#[test]
fn the_target_is_monetizes_and_saturates() {
let held = BTreeMap::from([("disk_bytes".to_string(), 5u64), ("users".to_string(), 1)]);
assert_eq!(
target_caps(&held, &BTreeMap::from([("disk_bytes".to_string(), 10u64)])),
BTreeMap::from([("disk_bytes".to_string(), 15u64), ("users".to_string(), 1)])
);
assert_eq!(
target_caps(
&BTreeMap::from([("a".to_string(), u64::MAX)]),
&BTreeMap::from([("a".to_string(), 1u64)])
)["a"],
u64::MAX
);
let plan = mpb::Bundle {
action: "upgrade".into(),
target: [("disk_bytes".to_string(), 20u64)].into_iter().collect(),
..Default::default()
};
assert_eq!(
order_caps(&plan, &held),
BTreeMap::from([("disk_bytes".to_string(), 20u64)])
);
let renew = mpb::Bundle {
action: "prolong".into(),
..Default::default()
};
assert!(is_renewal(&renew));
assert_eq!(order_caps(&renew, &held), held);
}
#[test]
fn a_row_the_box_cannot_serve_is_not_in_the_window_but_a_plan_row_keeps_its_refusal() {
let w = window(vec![
mpb::Bundle {
id: "a".into(),
can_serve: true,
..Default::default()
},
mpb::Bundle {
id: "b".into(),
can_serve: false,
..Default::default()
},
mpb::Bundle {
id: "c".into(),
can_serve: false,
action: "prolong".into(),
reason: "already paid ahead".into(),
..Default::default()
},
]);
assert_eq!(
w.offers.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(),
["a", "c"]
);
assert_eq!(w.withheld, 1);
let reading = window_of(mpb::ListBundlesResponse {
reading: true,
..Default::default()
});
assert_eq!(reading.pending.as_deref(), Some(AWAITING_PROVIDER));
assert!(window(vec![mpb::Bundle {
can_serve: false,
..Default::default()
}])
.unavailable
.is_some());
}
}
// ── an operator's orders for a tenant (Gunnar's console's `api::increase`) ──────────────
/// A meter this deployment will sell room on, in monetize's words (no price: `unit` only says it
/// is billed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Sellable {
pub meter: String,
pub meaning: String,
}
/// What a tenant could buy more of. `unavailable`: why there are no meters, when the site could
/// not find out (monetize unreachable, or no enforced, priced meter of this product).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Purchasable {
pub meters: Vec<Sellable>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<String>,
}
/// Pure: **the meters of `product` that monetize says are enforced and priced.**
pub fn sellable(answer: &mpb::ListProductsResponse, product: &str) -> Vec<Sellable> {
answer
.products
.iter()
.filter(|p| p.id == product)
.flat_map(|p| p.meters.iter())
.filter(|m| m.enforcement == mpb::Enforcement::Enforced as i32 && !m.unit.is_empty())
.map(|m| Sellable {
meter: m.name.clone(),
meaning: m.meaning.clone(),
})
.collect()
}
/// What an order would come to. **No price and no cost**: field by field, never `serde` over
/// the proto message, so the provider's cost cannot reach a browser under any name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Offer {
pub reference: String,
pub can_serve: bool,
pub reason: String,
pub words: String,
pub period: String,
pub current: BTreeMap<String, u64>,
pub caps: BTreeMap<String, u64>,
pub delta: BTreeMap<String, u64>,
}
/// Pure: `QuoteResponse` as the browser sees it ([`Offer`]).
pub fn render_offer(quote: mpb::QuoteResponse) -> Offer {
Offer {
reference: quote.reference,
can_serve: quote.can_serve,
reason: quote.reason,
words: quote.words,
period: quote.period,
current: quote.current.into_iter().collect(),
caps: quote.caps.into_iter().collect(),
delta: quote.delta.into_iter().collect(),
}
}
/// **An operator's order, as the console follows it**: the reference, monetize's state word,
/// the vendor's own link (empty: nowhere to send anybody) and monetize's reason.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Placed {
pub reference: String,
pub state: &'static str,
pub url: String,
pub reason: String,
}