crates/monetize-server/src/plans.rs
//! **ROOM SOLD AS PLANS, PRICED BY THE RULE** (owner, 2026-10-05) — the
//! server half of [`monetize::plan`] and [`monetize::pricing`], for a product
//! with `[pricing.<product>]` in `monetize.toml`.
//!
//! ```text
//! [pricing.gunnar] the rule's inputs and knobs
//! [pricing.gunnar.plans."10gb-1m"] size_bytes, period — the offers
//!
//! Purchase.Bundles ─▶ window() the rows (start / prolong / upgrade /
//! bump / renew), priced off the CACHED basis,
//! plus the plan's gauge and banner
//! Purchase.Quote ─▶ select() → check_actor → quote(fresh = false)
//! Purchase.Start ─▶ select() → check_actor → quote(fresh = TRUE): the
//! provider's price read NOW and the floor
//! re-checked; then the ordinary transaction
//! ```
//!
//! # The basis is the provider's, cached and kept warm
//!
//! What the estate costs a month — its servers (with their system disks), its
//! IPv4 addresses, and a GiB on main's data tier plus the twin's replica tier
//! — is read through the cloud seam (`Priced::Resource`) and kept for
//! [`BASIS_FRESH`]; a keeper refreshes it every [`BASIS_KEEP_EVERY`] from the
//! start, so a customer never waits on the provider for a window. Two things
//! the seam cannot price are STATED in the config and say so: the IPv4
//! addresses (`base_extra_month_minor`) and the twin's replica tier
//! (`replica_gib_month_milli`, the seam prices one tier per cloud). A
//! deployment whose cloud prices nothing (the box's own iron, a test) states
//! the whole basis (`[pricing.<p>.basis]`).
//!
//! # N
//!
//! The accounts the base is split among: the distinct tenants who PAID for
//! something in the previous calendar month — ledger sales granted at a
//! price, and paid signups — never fewer than the rule's 20. Counted once a
//! month; every plan sold carries the N it was priced at.
//!
//! # Nothing here buys iron
//!
//! A purchase here raises the cap at once out of the free room, exactly as an
//! order always has; past the room it is queued at the estate that grows its own
//! set (`crate::grow::queue_past_room`).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use monetize::order::Period;
use monetize::plan::{self, Current, PlanSpec, PlanTerms, Row, Verb, SIZE_METER};
use monetize::pricing::{CostBasis, Fx, PriceRule, GIB};
use monetize::transaction::Refusal;
use monetize::{Quote, TxError};
use monetize_cloud::{Resource, ServerId, ServerRole, Zone};
use monetize_embed::civil::{iso_date, DAY_MS};
use monetize_pay::{Currency, Market, Money};
use monetize_product::{State, TenantId};
use monetize_proto as pb;
use tonic::Status;
use crate::state::AppState;
/// How long a read basis is used for a WINDOW (owner: at least 10 minutes).
/// A purchase never uses it: `Start` reads the provider afresh.
pub const BASIS_FRESH: Duration = Duration::from_secs(10 * 60);
/// The keeper refreshes every basis this often, before it goes stale.
pub const BASIS_KEEP_EVERY: Duration = Duration::from_secs(8 * 60);
/// **`[pricing.<product>]`** — the price rule's inputs for one product.
///
/// ```toml
/// [pricing.gunnar]
/// currency = "Eur" # what the estate is paid for in
/// base_extra_month_minor = 0 # what the seam cannot price (each machine's IPv4 is read from it)
/// ipv4_fallback_month_minor = 351 # one address a month, only when the provider cannot price it
/// replica_gib_month_milli = 5990 # the twin's replica tier, per GiB-month
/// fx = { SEK = 110000, DKK = 74600 } # per 1 EUR, basis points
/// [pricing.gunnar.rule] # the owner's numbers, when absent
/// [pricing.gunnar.plans."10gb-1m"]
/// size_bytes = 10737418240
/// period = "month"
/// [pricing.gunnar.plans."10gb-12m"]
/// size_bytes = 10737418240
/// period = "year"
/// target = { SEK = 89000 } # the owner's year; the floor still wins
/// ```
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PricingConfig {
/// The currency the estate is paid for in — the cost currency. Every
/// price is computed in it and converted for a market that sells in
/// another ([`PricingConfig::fx`]).
pub currency: Currency,
pub rule: PriceRule,
/// What the cloud seam cannot price, per month, minor units. NOT the
/// public IPv4 addresses any more: each machine's is read from its
/// cloud's price list (`Cloud::catalog`, owner 2026-10-05).
pub base_extra_month_minor: u64,
/// **One public IPv4 address a month, minor units, when the provider
/// cannot price it** (a cloud guest built before `price_ipv4`, a list
/// without the item). 351 = UpCloud's `ipv4_address`, se-sto1, read
/// 2026-10-05 (0.4812 c/h × 730). Only a fallback: the list wins.
pub ipv4_fallback_month_minor: u64,
/// The twin's replica tier, per GiB-month, milli-minor units (the seam
/// prices one disk tier per cloud — main's).
pub replica_gib_month_milli: u64,
/// Units of a market's currency per unit of [`PricingConfig::currency`],
/// basis points. A market with no rate is not sold plans.
pub fx: Fx,
/// The offers.
pub plans: BTreeMap<String, PlanSpec>,
/// **The whole basis, stated**, for a deployment whose cloud prices
/// nothing. Absent: read from the cloud.
pub basis: Option<StatedBasis>,
}
impl Default for PricingConfig {
fn default() -> Self {
PricingConfig {
currency: Currency::Eur,
rule: PriceRule::default(),
base_extra_month_minor: 0,
ipv4_fallback_month_minor: IPV4_FALLBACK_MONTH_MINOR,
replica_gib_month_milli: 0,
fx: Fx::default(),
plans: BTreeMap::new(),
basis: None,
}
}
}
/// [`PricingConfig::ipv4_fallback_month_minor`]'s default: UpCloud's
/// `ipv4_address` in se-sto1 on 2026-10-05, 3.51 EUR a month.
pub const IPV4_FALLBACK_MONTH_MINOR: u64 = 351;
/// `[pricing.<product>.basis]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StatedBasis {
pub base_month_minor: u64,
pub storage_gib_month_milli: u64,
}
impl PricingConfig {
/// Refused by name rather than sold wrong: a 1-month plan above the rule's
/// 1-month size, a plan of no size, and a rule that would sell under its
/// own floor.
pub fn check(&self, product: &str) -> Result<(), String> {
for (id, p) in &self.plans {
if p.size_bytes == 0 {
return Err(format!("[pricing.{product}.plans.{id:?}] size_bytes is 0: a plan of no room sells nothing"));
}
if p.period == Period::Month && p.size_bytes > self.rule.month_max_bytes {
return Err(format!(
"[pricing.{product}.plans.{id:?}] is a 1-month plan of {} — above the rule's month_max_bytes ({}). A 1-month plan is at most that (owner, 2026-10-05: a provider disk never shrinks, and a month of a big plan is money lost)",
plan::gib_words(p.size_bytes),
plan::gib_words(self.rule.month_max_bytes)
));
}
}
let r = &self.rule;
if r.floor_bp < 10_000 || r.markup_bp * r.yearly_bp / 10_000 < r.floor_bp {
return Err(format!(
"[pricing.{product}.rule] markup {} × yearly {} is under the floor {} (basis points): every 12-month plan would be refused at the buy",
r.markup_bp, r.yearly_bp, r.floor_bp
));
}
Ok(())
}
}
/// **The plans, per product, and what was last read about them.** On
/// [`AppState::plans`].
#[derive(Default)]
pub struct PlanBook {
/// `[pricing.<product>]`, as loaded.
pub products: RwLock<BTreeMap<String, PricingConfig>>,
basis: Mutex<BTreeMap<String, (Instant, CostBasis)>>,
reading: Mutex<BTreeSet<String>>,
accounts: Mutex<BTreeMap<String, (u64, u32)>>,
keeper: AtomicBool,
}
impl PlanBook {
pub fn new(products: BTreeMap<String, PricingConfig>) -> PlanBook {
PlanBook {
products: RwLock::new(products),
..PlanBook::default()
}
}
/// The product's pricing, when it sells plans.
pub fn config(&self, product: &str) -> Option<PricingConfig> {
self.products
.read()
.unwrap_or_else(|e| e.into_inner())
.get(product)
.cloned()
}
/// The last basis read for `product`, while it is fresh.
pub fn cached(&self, product: &str) -> Option<CostBasis> {
let held = self.basis.lock().unwrap_or_else(|e| e.into_inner());
held.get(product)
.filter(|(at, _)| at.elapsed() < BASIS_FRESH)
.map(|(_, b)| *b)
}
fn store(&self, product: &str, b: CostBasis) {
self.basis
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(product.to_owned(), (Instant::now(), b));
}
/// A test stands at the end of a basis's life without waiting for it.
pub fn age_for_test(&self, by: Duration) {
for (at, _) in self
.basis
.lock()
.unwrap_or_else(|e| e.into_inner())
.values_mut()
{
*at = at.checked_sub(by).unwrap_or(*at);
}
}
}
// ── the basis ───────────────────────────────────────────────────────────
/// **Read what the estate costs, from the provider, now.** Stores it.
pub fn read_basis(s: &AppState, product: &str) -> Result<CostBasis, String> {
let cfg = s
.plans
.config(product)
.ok_or_else(|| format!("{product} sells no plans"))?;
let b = basis_of(s, product, &cfg)?;
s.plans.store(product, b);
Ok(b)
}
fn basis_of(s: &AppState, product: &str, cfg: &PricingConfig) -> Result<CostBasis, String> {
if let Some(b) = cfg.basis {
return Ok(CostBasis {
currency: cfg.currency,
base_month_minor: b.base_month_minor,
storage_gib_month_milli: b.storage_gib_month_milli,
});
}
let config = s.config.read().unwrap_or_else(|e| e.into_inner()).clone();
let machines: Vec<_> = config
.machines
.values()
.filter(|m| (m.product.is_empty() || m.product == product) && !m.plan.is_empty())
.cloned()
.collect();
if machines.is_empty() {
return Err(format!(
"the base of {product} cannot be priced: [machines] names no server with a plan for it, and [pricing.{product}.basis] states none"
));
}
let cloud_named = |name: &str| s.registry.clouds().find(|c| c.name() == name);
let check = |p: monetize_cloud::Price, what: &str| -> Result<u64, String> {
if p.currency != cfg.currency {
return Err(format!(
"{what} is priced in {:?} and [pricing.{product}] currency is {:?}",
p.currency, cfg.currency
));
}
Ok(p.minor_per_month)
};
let mut base = cfg.base_extra_month_minor;
// One catalog read per cloud and zone: every machine's address is priced from it.
let mut ipv4_of: std::collections::BTreeMap<
(String, String),
Result<Option<monetize_cloud::Price>, String>,
> = std::collections::BTreeMap::new();
for m in &machines {
let cloud = cloud_named(&m.cloud).ok_or_else(|| {
format!(
"machine {} names cloud {:?}, which is not linked",
m.hostname, m.cloud
)
})?;
let role = if m.role == "twin" {
ServerRole::Replica
} else {
ServerRole::Primary
};
// The cloud's own zone: a provider's price list is per zone, and an
// empty one is not in it (MEASURED estate-box 2026-10-05: "upcloud:
// zone is not in the price list").
let zone = cloud
.zones()
.first()
.cloned()
.unwrap_or_else(|| Zone(String::new()));
let p = monetize_cloud::Cloud::price(
&cloud,
&monetize_cloud::cloud::Priced::Resource(&Resource::Server {
plan: m.plan.clone(),
zone: zone.clone(),
role,
}),
)
.map_err(|e| {
format!(
"{} could not price server plan {:?} ({}): {e}",
m.cloud, m.plan, m.hostname
)
})?;
base = base.saturating_add(check(p, &format!("server plan {:?}", m.plan))?);
// ★ Its public IPv4 address, from the SAME price list (owner,
// 2026-10-05: the base counted 3 × a typed 3.00 EUR; UpCloud's list
// said 3.51). One per machine; a provider that cannot price it is
// the stated fallback, said in the journal.
let listed = ipv4_of
.entry((m.cloud.clone(), zone.0.clone()))
.or_insert_with(|| {
monetize_cloud::ask::price_ipv4(&cloud, &zone).map_err(|e| e.to_string())
})
.clone();
let ipv4 = match listed.and_then(|p| {
p.ok_or_else(|| format!("{} prices no public IPv4 address of its own", m.cloud))
}) {
Ok(p) => check(p, &format!("the public IPv4 address of {}", m.hostname))?,
Err(e) => {
eprintln!(
"monetize-server: plans of {product}: {} did not price the public IPv4 address of {} ({e}); the base counts the stated fallback {} a month",
m.cloud,
m.hostname,
Money { minor: cfg.ipv4_fallback_month_minor, currency: cfg.currency }.in_words()
);
cfg.ipv4_fallback_month_minor
}
};
base = base.saturating_add(ipv4);
}
// Main's data tier: a 100 GiB disk on the set's server, through the
// set's cloud — the one the batcher grows.
let (cloud, server) = match config.products.get(product).and_then(|p| p.grow.clone()) {
Some(g) => (g.cloud, g.server),
None => {
let main = machines
.iter()
.find(|m| m.role == "appliance")
.unwrap_or(&machines[0]);
(main.cloud.clone(), main.server.clone())
}
};
let c = cloud_named(&cloud)
.ok_or_else(|| format!("the data set's cloud {cloud:?} is not linked"))?;
let p = monetize_cloud::Cloud::price(
&c,
&monetize_cloud::cloud::Priced::Resource(&Resource::Disk {
server: ServerId(server),
gib: 100,
}),
)
.map_err(|e| format!("{cloud} could not price a data disk: {e}"))?;
let disk_100 = check(p, "a data disk")?;
Ok(CostBasis {
currency: cfg.currency,
base_month_minor: base,
storage_gib_month_milli: disk_100 * 10 + cfg.replica_gib_month_milli,
})
}
/// **The basis for a WINDOW**: the cached one, or — with none — a reading
/// started in the background (one per product at a time) and `None`, which
/// the window answers as `reading`.
fn basis_for_window(s: &Arc<AppState>, product: &str) -> Option<CostBasis> {
if let Some(b) = s.plans.cached(product) {
return Some(b);
}
start_reading(s, product);
// As the wallet reading's window does (`grow::WINDOW_WAIT`): a short wait
// on the ONE reading being made, so a provider that answers at once is
// an answer and not a `reading` the console has to ask again for.
let until = Instant::now() + crate::grow::WINDOW_WAIT;
while Instant::now() < until {
std::thread::sleep(Duration::from_millis(50));
if let Some(b) = s.plans.cached(product) {
return Some(b);
}
}
None
}
fn start_reading(s: &Arc<AppState>, product: &str) {
if !s
.plans
.reading
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(product.to_owned())
{
return;
}
let (s2, p) = (Arc::clone(s), product.to_owned());
// A jera job on the product's lane (A32-14): a reading that fails or panics is an ALERT line
// and a FAILED row, and the product is let go on every path (a panicked raw thread left it
// "reading" for good: its price was never read again).
let started = crate::jobs::once(BASIS_JOB, &crate::jobs::of(s, product), move || {
let (s3, p3) = (Arc::clone(&s2), p.clone());
let _let_go = crate::jobs::Finally(move || {
s3.plans
.reading
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&p3);
});
read_basis(&s2, &p)
.map(|_| ())
.map_err(|e| format!("plans {p}: the provider's price could not be read: {e}"))
});
if !matches!(started, Ok(true)) {
s.plans
.reading
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(product);
}
}
/// The jera job kind of one reading of a product's plan basis (A32-14).
pub const BASIS_JOB: &str = "monetize_plan_basis";
/// The jera duty that keeps every product's basis warm (A32-14).
pub const BASIS_KEEPER: &str = "monetize_plan_keeper";
/// **Warm every product's basis now and keep it warm** — called once at
/// start (`cli::serve`, the bench).
pub fn arm(s: &Arc<AppState>) {
let products: Vec<String> = s
.plans
.products
.read()
.unwrap_or_else(|e| e.into_inner())
.keys()
.cloned()
.collect();
for p in &products {
start_reading(s, p);
}
if products.is_empty() || s.plans.keeper.swap(true, Ordering::SeqCst) {
return;
}
// A jera duty (A32-14): it ends with the state (held weakly); one that cannot start is said.
let weak = Arc::downgrade(s);
let mut first = true;
let started = crate::jobs::every(BASIS_KEEPER, BASIS_KEEP_EVERY, move || {
// The first look only waits, as the thread's first sleep did.
if std::mem::take(&mut first) {
return Ok(crate::jobs::Round::Again);
}
let Some(s2) = weak.upgrade() else {
return Ok(crate::jobs::Round::Done);
};
for p in &products {
start_reading(&s2, p);
}
Ok(crate::jobs::Round::Again)
});
if let Err(e) = started {
eprintln!("monetize-server: ALERT jobs.unstarted {BASIS_KEEPER}: the plan keeper could not start: {e}; a basis is read when a customer asks");
}
}
// ── N ───────────────────────────────────────────────────────────────────
/// The first millisecond of the calendar month `at` is in.
fn month_start(at: u64) -> u64 {
let (y, m, _) = monetize_embed::civil::civil_from_unix_ms(at);
monetize_embed::civil::unix_ms_from_civil(y, m, 1)
}
/// **The distinct tenants who paid for `product` in the calendar month
/// before `now`'s** — granted ledger sales at a price, and paid signups.
pub fn paying_last_month(s: &AppState, product: &str, now: u64) -> u32 {
let this = month_start(now);
let prev = month_start(this.saturating_sub(1));
let mut tenants = BTreeSet::new();
if let Ok(sales) = s.ledger.sales() {
for sale in sales {
if sale.product == product
&& sale.outcome == monetize::ledger::SaleOutcome::Granted
&& sale.price.minor > 0
&& (prev..this).contains(&sale.started_unix_ms)
{
tenants.insert(sale.tenant);
}
}
}
if let Ok(signups) = s.signups.export_all() {
for rec in signups {
let crate::signup::SignupState::Created { namespace, .. } = &rec.state else {
continue;
};
let paid = u64::try_from(rec.signup.paid_at_unix_ms).unwrap_or(0);
if rec.signup.product == product
&& rec.signup.amount.minor > 0
&& (prev..this).contains(&paid)
{
tenants.insert(namespace.clone());
}
}
}
u32::try_from(tenants.len()).unwrap_or(u32::MAX)
}
/// **N** for `product` this month — counted once a month.
pub fn accounts(s: &AppState, product: &str, rule: &PriceRule) -> u32 {
let now = s.now();
let key = month_start(now);
if let Some((k, n)) = s
.plans
.accounts
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(product)
{
if *k == key {
return *n;
}
}
let n = rule.accounts(paying_last_month(s, product, now));
s.plans
.accounts
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(product.to_owned(), (key, n));
n
}
// ── the tenant's plan ───────────────────────────────────────────────────
/// **The plan `tenant` holds**, read back off the purchase behind their
/// current fact. `None`: nothing paid, a hand-set fact, or Retention (they
/// start over).
pub fn current(
s: &AppState,
product: &str,
tenant: &str,
) -> Result<Option<(Current, monetize_product::EntitlementFact)>, Status> {
let Some(fact) = s
.ledger
.entitlement(product, tenant)
.map_err(crate::services::ledger_err)?
else {
return Ok(None);
};
let Some(paid_until) = fact.paid_until_unix_ms else {
return Ok(None);
};
if matches!(fact.state, State::Free | State::Retention) {
return Ok(None);
}
let terms = s
.ledger
.attempt(&fact.plan)
.ok()
.flatten()
.and_then(|a| a.purchase.plan);
let c = Current {
period: terms.as_ref().map(|t| t.period).unwrap_or(Period::Month),
size_bytes: fact.caps.get(SIZE_METER).copied().unwrap_or(0),
paid_until_unix_ms: paid_until,
state: fact.state,
month_price: terms.map(|t| t.month_price),
};
Ok(Some((c, fact)))
}
/// **What a request asks of the plan.**
#[derive(Clone, Copy, Debug)]
pub enum Ask<'a> {
/// A window row by id.
Row(&'a str),
/// A typed target (`caps`): the 12-month size field.
Typed(&'a BTreeMap<String, u64>),
/// `renew = true`: prolong a 1-month plan, renew a 12-month one.
Renew,
}
/// **The row a request resolved to, before anything is priced** — and the
/// ticket it needs.
#[derive(Clone, Debug)]
pub struct Selected {
pub tenant: String,
pub row: Row,
pub current: Option<Current>,
/// The caps the tenant holds now.
pub held: BTreeMap<String, u64>,
/// The caps after; empty for a renewal.
pub target: BTreeMap<String, u64>,
/// What the actor ticket must cover: the caps after, for a window row
/// (as a bundle's resolved caps always were); the caps the request named,
/// for a typed size (as a typed order's always were); none for a renewal.
pub ticket_caps: BTreeMap<String, u64>,
/// `renew` or `order`.
pub purpose: &'static str,
}
/// **Resolve a request into a plan row** — pure but for the ledger read, so
/// the actor ticket is checked before anything is priced or dialled.
pub fn select(
s: &AppState,
cfg: &PricingConfig,
product: &str,
tenant: &str,
ask: Ask<'_>,
) -> Result<Selected, Status> {
if tenant.is_empty() {
return Err(Status::invalid_argument("tenant is required"));
}
let now = s.now();
let cur = current(s, product, tenant)?;
let held = s
.ledger
.entitlement(product, tenant)
.map_err(crate::services::ledger_err)?
.map(|f| f.caps)
.unwrap_or_default();
let current = cur.map(|(c, _)| c);
let rows = plan::rows(&cfg.rule, current.as_ref(), &cfg.plans, now);
let row = match ask {
Ask::Row(id) => rows.into_iter().find(|r| r.id == id).ok_or_else(|| {
Status::failed_precondition(format!(
"{id:?} is not on sale for this account now; ask Purchase.Bundles for what is"
))
})?,
Ask::Renew => rows
.into_iter()
.find(|r| r.verb.keeps_caps())
.ok_or_else(|| {
refused(&Refusal::NothingToRenew {
tenant: tenant.to_owned(),
why: "no plan to prolong or renew right now".into(),
})
})?,
Ask::Typed(caps) => {
if let Some(other) = caps.keys().find(|k| k.as_str() != SIZE_METER) {
return Err(Status::invalid_argument(format!(
"a plan is sized in {SIZE_METER}; {other:?} is not sold as a plan"
)));
}
let Some(size) = caps.get(SIZE_METER).copied() else {
return Err(Status::invalid_argument(format!(
"name the size, in {SIZE_METER}"
)));
};
plan::typed(&cfg.rule, current.as_ref(), size, now)
}
};
let (target, purpose) = if row.verb.keeps_caps() {
(BTreeMap::new(), monetize_embed::PURPOSE_RENEW)
} else {
let mut t = held.clone();
t.insert(SIZE_METER.to_owned(), row.size_bytes);
(t, monetize_embed::PURPOSE_ORDER)
};
let ticket_caps = match ask {
Ask::Typed(caps) if !row.verb.keeps_caps() => caps.clone(),
_ => target.clone(),
};
Ok(Selected {
tenant: tenant.to_owned(),
row,
current,
held,
target,
ticket_caps,
purpose,
})
}
fn refused(r: &Refusal) -> Status {
crate::services::purchase::refused_status(r)
}
// ── prices ──────────────────────────────────────────────────────────────
/// **One row, priced**: what is charged now (in the market's currency), the
/// price per month it locks, what it costs the estate, the credit taken off.
#[derive(Clone, Debug)]
pub struct Priced {
pub price: Money,
pub month_price: Money,
pub cost_month_milli: u128,
/// The span the charge pays for (the floor's).
pub covered_ms: u64,
pub credit: Option<Money>,
pub accounts: u32,
}
fn convert(cfg: &PricingConfig, minor: u64, to: Currency) -> Option<Money> {
cfg.fx.convert(
Money {
minor,
currency: cfg.currency,
},
to,
)
}
/// A price in a market's currency, back in the cost currency (rounded down —
/// the floor is checked against the LESS favourable figure).
fn back(cfg: &PricingConfig, price: Money) -> Option<u64> {
if price.currency == cfg.currency {
return Some(price.minor);
}
let rate = *cfg.fx.0.get(price.currency.code())?;
(rate > 0).then(|| {
u64::try_from(u128::from(price.minor) * 10_000 / u128::from(rate)).unwrap_or(u64::MAX)
})
}
/// **Price a row** under `basis`, for a buyer paying in `to`. `Err` names why
/// it cannot be priced (no rate for the market).
/// **The owner's TARGET for a 12-month plan of `size_bytes`, in `to`** —
/// `[pricing.<p>.plans."<id>"] target` (owner, 2026-10-05: "lower 12 months
/// to 890"), or `None`. The market's own figure when one is named; otherwise
/// a named one, back in the cost currency (rounded down) and on to `to`.
fn plan_target(
cfg: &PricingConfig,
period: Period,
size_bytes: u64,
to: Currency,
) -> Option<Money> {
let target = cfg
.plans
.values()
.find(|s| s.period == period && s.size_bytes == size_bytes)?
.target
.as_ref()?;
if let Some(minor) = target.get(to.code()) {
return Some(Money {
minor: *minor,
currency: to,
});
}
let (code, minor) = target
.iter()
.find(|(c, _)| Currency::from_code(c).is_some())?;
let in_cost = back(
cfg,
Money {
minor: *minor,
currency: Currency::from_code(code)?,
},
)?;
convert(cfg, in_cost, to)
}
pub fn price_row(
cfg: &PricingConfig,
basis: &CostBasis,
accounts: u32,
to: Currency,
row: &Row,
current: Option<&Current>,
now: u64,
) -> Result<Priced, String> {
let r = &cfg.rule;
let in_market = |minor: u64| {
convert(cfg, minor, to).ok_or_else(|| format!("no [pricing] fx rate for {}", to.code()))
};
let cost = r.cost_month_milli(basis, row.size_bytes, accounts);
let covered = row.until_unix_ms.saturating_sub(row.starts_unix_ms);
// ★ THE OWNER'S TARGETS (2026-10-05, "the professional choice"), in
// the market's currency, the floor converted UP into it so it still
// clears once back in the cost currency:
// * a 1-month plan: max(target, rule, floor) — the target raises it;
// * a 12-month plan: max(floor, min(rule, target)) — the target lowers
// it, never under the floor; its month is the year's twelfth, rounded
// up (the rule's own month when the rule's year stands).
// Without a target, the rule's price.
let month_of = |size_bytes: u64| -> Result<Money, String> {
let rule = in_market(r.monthly_minor(basis, size_bytes, accounts))?;
let Some(target) = plan_target(cfg, Period::Month, size_bytes, to) else {
return Ok(rule);
};
let floor = in_market(r.floor_minor(
r.cost_month_milli(basis, size_bytes, accounts),
monetize::pricing::MONTH_MS,
))?;
Ok(Money {
minor: monetize::pricing::month_at_target(target.minor, rule.minor, floor.minor),
currency: to,
})
};
let year_of = |rule_total: Money, rule_month: Money| -> Result<(Money, Money), String> {
let Some(target) = plan_target(cfg, Period::Year, row.size_bytes, to) else {
return Ok((rule_total, rule_month));
};
let floor = in_market(r.floor_minor(cost, covered))?;
let total = monetize::pricing::year_at_target(target.minor, rule_total.minor, floor.minor);
if total == rule_total.minor {
return Ok((rule_total, rule_month));
}
Ok((
Money {
minor: total,
currency: to,
},
Money {
minor: total.div_ceil(12),
currency: to,
},
))
};
let rule_year = || -> Result<(Money, Money), String> {
Ok((
in_market(r.yearly_total_minor(basis, row.size_bytes, accounts))?,
in_market(r.yearly_month_minor(basis, row.size_bytes, accounts))?,
))
};
// A locked month price is honoured while it is in this market's currency
// and still clears the floor at today's cost; otherwise today's price.
let locked_money = |today: Money| -> Result<Money, String> {
match current.and_then(|c| c.month_price) {
Some(m)
if m.currency == to
&& back(cfg, m)
.is_some_and(|c| r.floor_ok(c, cost, monetize::pricing::MONTH_MS)) =>
{
Ok(m)
}
_ => Ok(today),
}
};
let locked = |today: u64| -> Result<Money, String> { locked_money(in_market(today)?) };
let p = match row.verb {
Verb::Start => match row.period {
Period::Month => {
let m = month_of(row.size_bytes)?;
Priced {
price: m,
month_price: m,
cost_month_milli: cost,
covered_ms: covered,
credit: None,
accounts,
}
}
Period::Year => {
let (rule_total, rule_month) = rule_year()?;
let (total, month) = year_of(rule_total, rule_month)?;
Priced {
price: total,
month_price: month,
cost_month_milli: cost,
covered_ms: covered,
credit: None,
accounts,
}
}
},
// The locked month while it clears the floor; else today's (at the
// plan's target, if it has one).
Verb::Prolong => {
let m = locked_money(month_of(row.size_bytes)?)?;
Priced {
price: m,
month_price: m,
cost_month_milli: cost,
covered_ms: covered,
credit: None,
accounts,
}
}
// The locked month, twelve times — at the target clamp when the plan
// has one (never above that rule, never under the floor).
Verb::Renew => {
let m = locked(r.yearly_month_minor(basis, row.size_bytes, accounts))?;
let (total, month) = year_of(
Money {
minor: m.minor.saturating_mul(12),
currency: m.currency,
},
m,
)?;
Priced {
price: total,
month_price: month,
cost_month_milli: cost,
covered_ms: covered,
credit: None,
accounts,
}
}
Verb::Upgrade => {
let (rule_total, rule_month) = rule_year()?;
let (total, month) = year_of(rule_total, rule_month)?;
let c = current.ok_or("an upgrade needs a plan to upgrade")?;
let paid_month = locked_money(month_of(c.size_bytes)?)?;
let credit = plan::upgrade_credit(r, c, paid_month, now);
let charged = Money {
minor: total.minor.saturating_sub(credit.minor),
currency: total.currency,
};
Priced {
price: charged,
month_price: month,
cost_month_milli: cost,
covered_ms: covered,
credit: (credit.minor > 0).then_some(credit),
accounts,
}
}
Verb::Bump => {
let from = current.map(|c| c.size_bytes).unwrap_or(0);
let bump = in_market(r.bump_minor(basis, from, row.size_bytes, covered))?;
let month = in_market(r.yearly_month_minor(basis, row.size_bytes, accounts))?;
// The floor of a bump is about the room it ADDS: the base share
// was paid by the plan it bumps.
let added = cost.saturating_sub(r.cost_month_milli(basis, from, accounts));
Priced {
price: bump,
month_price: month,
cost_month_milli: added,
covered_ms: covered,
credit: None,
accounts,
}
}
};
Ok(p)
}
/// **The floor, at `basis`**: the charge (with any credit, which was paid
/// before) back in the cost currency, after the fee, ≥ cost × 1.10 over the
/// span it pays for.
pub fn clears_floor(cfg: &PricingConfig, p: &Priced) -> bool {
let charged = Money {
minor: p.price.minor + p.credit.map(|c| c.minor).unwrap_or(0),
currency: p.price.currency,
};
back(cfg, charged).is_some_and(|c| cfg.rule.floor_ok(c, p.cost_month_milli, p.covered_ms))
}
/// **The label of a row**, in the customer's words.
pub fn label(row: &Row) -> String {
match row.verb {
Verb::Start => plan::label(row.size_bytes, row.period),
Verb::Prolong => format!("Prolong 1 month ({})", plan::gib_words(row.size_bytes)),
Verb::Renew => format!("Renew 12 months ({})", plan::gib_words(row.size_bytes)),
Verb::Upgrade => format!("Upgrade to {}", plan::label(row.size_bytes, row.period)),
Verb::Bump => format!(
"Grow to {}, until {}",
plan::gib_words(row.size_bytes),
iso_date(row.until_unix_ms)
),
}
}
// ── sign-up by the room rule (round 5.3.1) ───────────────────────────────
//
// The owner on test2 2026-10-08: room is ONE bar, at sign-up too — "1 month"
// at 10 GiB only (a provider disk never shrinks, 2026-10-05) and "12 months"
// from 10 to 1000 GiB, each priced by the room rule at the market's price, and
// the checkout charging exactly what was shown. A NEW account's plans are the
// declared [pricing.<product>.plans] a tenant with no plan may START with
// (`plan::rows(…, None, …)`), priced now; each is sold under a PRICED id —
// `<product>/<plan>@<minor><CUR>` — so the amount the checkout charged is in
// the id the vendor hands back, and the paid signup is checked against it.
/// The separator between a declared plan and its price in a priced sign-up id.
pub const PRICED_AT: char = '@';
/// Pure: the priced id of `row_id` of `product` at `price`.
pub fn priced_id(product: &str, row_id: &str, price: &Money) -> String {
format!(
"{product}/{row_id}{PRICED_AT}{}{}",
price.minor,
price.currency.code()
)
}
/// Pure: `(product, plan, price)` out of a priced id; `None` for any other id.
pub fn parse_priced_id(id: &str) -> Option<(String, String, Money)> {
let (head, priced) = id.rsplit_once(PRICED_AT)?;
let (product, plan) = head.split_once('/')?;
let digits = priced.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let currency = Currency::from_code(&priced[digits.len()..])?;
let minor: u64 = digits.parse().ok()?;
(!product.is_empty() && !plan.is_empty()).then(|| {
(
product.to_owned(),
plan.to_owned(),
Money { minor, currency },
)
})
}
/// One room plan a new account may start with, priced now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignupRoom {
pub id: String,
pub name: String,
pub plan: crate::config::SignupPlan,
}
/// Pure: a declared plan's sign-up terms at `price` (30 days for a month, 365 for a year).
pub fn signup_terms(size_bytes: u64, period: Period, price: Money) -> crate::config::SignupPlan {
crate::config::SignupPlan {
amount_minor: price.minor,
currency: price.currency,
cap_bytes: size_bytes,
period_days: match period {
Period::Month => 30,
Period::Year => 365,
},
}
}
/// **The room plans a NEW account may start with, priced now in `market`'s
/// currency** — the 1-month plan(s) the rule allows, then the 12-month ones by
/// size. `None` when this deployment prices no plans for `product` or the
/// provider's price is still being read (the caller keeps its own table).
pub fn signup_rooms(s: &Arc<AppState>, product: &str, market: Market) -> Option<Vec<SignupRoom>> {
let has_plans = s
.plans
.products
.read()
.unwrap_or_else(|p| p.into_inner())
.get(product)
.is_some_and(|c| !c.plans.is_empty());
if !has_plans {
return None;
}
let basis = basis_for_window(s, product)?;
rooms_on(s, product, market, &basis)
}
/// [`signup_rooms`] on the basis already read (the START of a sign-up: the page
/// read the prices moments before; a basis no longer held is "ask again").
pub fn signup_rooms_cached(s: &AppState, product: &str, market: Market) -> Option<Vec<SignupRoom>> {
let basis = s.plans.cached(product)?;
rooms_on(s, product, market, &basis)
}
fn rooms_on(
s: &AppState,
product: &str,
market: Market,
basis: &CostBasis,
) -> Option<Vec<SignupRoom>> {
let cfg = s
.plans
.products
.read()
.unwrap_or_else(|p| p.into_inner())
.get(product)
.cloned()?;
if cfg.plans.is_empty() {
return None;
}
let basis = *basis;
let now = s.now();
let n = accounts(s, product, &cfg.rule);
let to = market.currency();
let mut out = Vec::new();
for row in plan::rows(&cfg.rule, None, &cfg.plans, now) {
if row.verb != Verb::Start || row.refused.is_some() {
continue;
}
match price_row(&cfg, &basis, n, to, &row, None, now) {
Ok(p) => out.push(SignupRoom {
id: priced_id(product, &row.id, &p.price),
name: plan::label(row.size_bytes, row.period),
plan: signup_terms(row.size_bytes, row.period, p.price),
}),
Err(why) => eprintln!(
"monetize-server: sign-up plan {:?} of {product} not offered in {market:?}: {why}",
row.id
),
}
}
(!out.is_empty()).then_some(out)
}
/// **The terms a priced sign-up id stands for**, read against THIS
/// deployment's declared plans: the size and the period from
/// `[pricing.<product>.plans.<plan>]`, the price from the id (what the checkout
/// charged, and what the vendor must have taken). `None` when the id names no
/// declared plan a new account may start with.
pub fn priced_signup_plan(s: &AppState, id: &str) -> Option<crate::config::SignupPlan> {
let (product, plan_id, price) = parse_priced_id(id)?;
let cfg = s
.plans
.products
.read()
.unwrap_or_else(|p| p.into_inner())
.get(&product)
.cloned()?;
let spec = cfg.plans.get(&plan_id)?;
if spec.period == Period::Month && spec.size_bytes > cfg.rule.month_max_bytes {
return None;
}
Some(signup_terms(spec.size_bytes, spec.period, price))
}
/// **The price a priced id's plan has NOW** in `market`, for the start of a
/// sign-up — `Err` names why it cannot be sold, `Ok(true)` when the id's price
/// is today's, `Ok(false)` when the price moved since the page read it.
pub fn priced_id_is_current(s: &AppState, id: &str, market: Market) -> Result<bool, String> {
let (product, _, price) =
parse_priced_id(id).ok_or_else(|| format!("{id:?} is not a priced plan"))?;
let rooms = signup_rooms_cached(s, &product, market)
.ok_or_else(|| "the room's price is being read; ask again in a moment".to_owned())?;
let base = id.rsplit_once(PRICED_AT).map(|(h, _)| h).unwrap_or(id);
let now = rooms
.iter()
.find(|r| r.id.rsplit_once(PRICED_AT).map(|(h, _)| h) == Some(base))
.ok_or_else(|| format!("{base:?} is not sold to a new account here"))?;
Ok(now.plan.amount_minor == price.minor && now.plan.currency == price.currency)
}
// ── the quote a purchase runs ───────────────────────────────────────────
/// **The quote a selected row becomes** — the ordinary quote (`quote_order`
/// for a raise, `Transaction::renewal` for a prolong or renewal), with the
/// rule's price, the plan's period and the [`PlanTerms`] laid on it.
///
/// The price is the one the WINDOW showed: priced off the cached basis. With
/// `fresh` — the BUY — the provider's price is then read NOW and the floor
/// re-checked against it: a shown price that no longer leaves cost + 10 %
/// after the fee is refused ([`Refusal::UnderFloor`]) and nothing is
/// charged; the fresh basis is kept, so the window shows today's price.
pub fn quote(
s: &AppState,
product: monetize_product::Served<'_>,
cfg: &PricingConfig,
sel: &Selected,
market: Market,
payer: Option<String>,
fresh: bool,
) -> Result<Quote, Status> {
// A row the rule refuses: the BUY is refused before anything is priced;
// a QUOTE answers it as every refused quote is answered — `can_serve =
// false` with the reason and the customer's words — so an operator's
// "what would this come to" reads the rule rather than an error.
let rule_says_no = sel.row.refused.as_ref().map(|words| Refusal::PlanRefused {
tenant: sel.tenant.clone(),
why: format!("the plan rule's limit — {words}"),
words: words.clone(),
});
if let (true, Some(r)) = (fresh, &rule_says_no) {
return Err(refused(r));
}
let read_now = || {
read_basis(s, product.id()).map_err(|e| {
Status::unavailable(format!("the provider's price could not be read: {e}"))
})
};
let shown = match s.plans.cached(product.id()) {
Some(b) => b,
None => read_now()?,
};
let n = accounts(s, product.id(), &cfg.rule);
let to = market.currency();
let now = s.now();
let priced = price_row(cfg, &shown, n, to, &sel.row, sel.current.as_ref(), now)
.map_err(Status::failed_precondition)?;
let under = if fresh {
let today = read_now()?;
let at_today = price_row(cfg, &today, n, to, &sel.row, sel.current.as_ref(), now)
.map_err(Status::failed_precondition)?;
let check = Priced {
cost_month_milli: at_today.cost_month_milli,
..priced.clone()
};
(!clears_floor(cfg, &check)).then_some(at_today.cost_month_milli)
} else {
None
};
let mut q = build(
s,
product,
sel,
market,
payer,
Price {
basis: &shown,
priced,
under_floor_at: under,
},
fresh,
)?;
if let Some(r) = rule_says_no {
q.serve = Err(r);
}
Ok(q)
}
/// **A row's price, as quoted**: the cost basis it was read from, what it
/// came to, and — for a buy — today's cost when that no longer clears the
/// floor.
struct Price<'a> {
basis: &'a CostBasis,
priced: Priced,
under_floor_at: Option<u128>,
}
fn build(
s: &AppState,
product: monetize_product::Served<'_>,
sel: &Selected,
market: Market,
payer: Option<String>,
price: Price<'_>,
buying: bool,
) -> Result<Quote, Status> {
let Price {
basis,
priced,
under_floor_at,
} = price;
let tenant = sel.tenant.clone();
// A row that raises nothing — a prolong, a renewal, and an UPGRADE to a
// 12-month plan of the size already held (MEASURED estate-box 2026-10-05:
// "10 GiB · 12 months" for a 10 GiB month was refused as "not an
// increase") — is a renewal of the caps held, for the row's period, to
// the row's end; anything else is an order for the row's caps.
let raises = !sel.row.verb.keeps_caps() && sel.target != sel.held;
let mut q = if !raises {
let terms = s.terms.read().unwrap_or_else(|e| e.into_inner()).clone();
let host = terms.host(product.id());
let mut t = terms.terms(product.id(), market, &host);
t.policy.period = sel.row.period;
s.transaction(product, monetize::no_vendor())
.renewal(&TenantId(tenant.clone()), &t, payer)
.map_err(|e| match e {
TxError::Refused(r) => refused(&r),
TxError::Ledger(l) => crate::services::ledger_err(l),
other => Status::internal(other.to_string()),
})?
} else {
let q = crate::services::purchase::quote_order(
s,
product,
&tenant,
sel.target.clone(),
market,
0,
payer,
)
.map_err(crate::services::purchase::quote_status)?;
if let Err(r) = &q.serve {
eprintln!(
"monetize-server: plan {} refused for {}/{}: {r}",
sel.row.id,
product.id(),
tenant
);
if buying {
return Err(refused(r));
}
}
q
};
if let Some(cost_now) = under_floor_at {
eprintln!(
"monetize-server: plan {} for {}/{} at {} is UNDER THE FLOOR at the provider's price read now (cost {} milli/month, was {}; N {}); refused, nothing charged",
sel.row.id,
product.id(),
tenant,
priced.price.in_words(),
cost_now,
priced.cost_month_milli,
priced.accounts
);
return Err(refused(&Refusal::UnderFloor {
tenant,
price: priced.price,
}));
}
let months = priced
.covered_ms
.div_ceil(monetize::pricing::MONTH_MS)
.max(1);
let cost_minor = u64::try_from(priced.cost_month_milli.div_ceil(1000)).unwrap_or(u64::MAX);
q.purchase.price = priced.price;
q.purchase.cost = Money {
minor: cost_minor.saturating_mul(months),
currency: basis.currency,
};
q.purchase.policy.period = sel.row.period;
q.purchase.plan = Some(PlanTerms {
verb: sel.row.verb,
period: sel.row.period,
size_bytes: sel.row.size_bytes,
month_price: priced.month_price,
accounts: priced.accounts,
cost_month: Money {
minor: cost_minor,
currency: basis.currency,
},
// A bump keeps the plan's end; an upgrade runs a year from TODAY even
// when it is sold as a renewal of the caps held (whose period would
// otherwise start at the current end).
until_unix_ms: matches!(sel.row.verb, Verb::Bump | Verb::Upgrade)
.then_some(sel.row.until_unix_ms),
credit: priced.credit,
});
Ok(q)
}
// ── the window ──────────────────────────────────────────────────────────
/// **THE SHOP WINDOW for a tenant of a product that sells plans**: the
/// rows, priced off the cached basis, and the plan's gauge. `reading` when no
/// basis has been read yet (one is being read; the console asks again).
pub fn window(
s: &Arc<AppState>,
product: monetize_product::Served<'_>,
cfg: &PricingConfig,
tenant: &str,
market: Market,
) -> Result<pb::ListBundlesResponse, Status> {
let Some(basis) = basis_for_window(s, product.id()) else {
eprintln!("monetize-server: the plans of {} for {tenant} wait on the provider's price (being read); answered `reading`", product.id());
return Ok(pb::ListBundlesResponse {
bundles: Vec::new(),
reading: true,
plan: None,
});
};
let now = s.now();
let n = accounts(s, product.id(), &cfg.rule);
let to = market.currency();
let cur = current(s, product.id(), tenant)?;
let held = s
.ledger
.entitlement(product.id(), tenant)
.map_err(crate::services::ledger_err)?
.map(|f| f.caps)
.unwrap_or_default();
let current = cur.as_ref().map(|(c, _)| c.clone());
let mut bundles = Vec::new();
for row in plan::rows(&cfg.rule, current.as_ref(), &cfg.plans, now) {
let priced = match price_row(cfg, &basis, n, to, &row, current.as_ref(), now) {
Ok(p) => p,
Err(why) => {
eprintln!(
"monetize-server: plan {:?} not offered for {}/{tenant} in {market:?}: {why}",
row.id,
product.id()
);
continue;
}
};
let (target, delta) = if row.verb.keeps_caps() {
(BTreeMap::new(), BTreeMap::new())
} else {
let mut t = held.clone();
t.insert(SIZE_METER.to_owned(), row.size_bytes);
let d: BTreeMap<String, u64> = [(
SIZE_METER.to_owned(),
row.size_bytes
.saturating_sub(held.get(SIZE_METER).copied().unwrap_or(0)),
)]
.into();
(t, d)
};
let (can_serve, reason) = match &row.refused {
// The OFFER's words on load (cov-10): never an order's.
Some(words) => (false, plan::words::on_offer(words)),
None if !row.verb.keeps_caps() && delta.values().any(|v| *v > 0) => {
match crate::services::purchase::quote_order(
s,
product,
tenant,
target.clone(),
market,
0,
None,
) {
Ok(q) => {
match &q.serve {
Ok(()) => (true, String::new()),
// The OFFER's words — larger than the room left here,
// on hand and growable — never the order's refusal:
// nobody has ordered it (owner, test2 2026-10-05, the
// 500 GiB plan's "We cannot raise this account …
// Nothing has been charged" on a panel just opened).
// The buy is re-checked when it is placed.
Err(r) => {
eprintln!("monetize-server: plan {:?} cannot be served for {}/{tenant}: {r}", row.id, product.id());
(false, r.offer_words())
}
}
}
Err(TxError::Refused(r)) => (false, r.offer_words()),
Err(e) => return Err(crate::services::purchase::quote_status(e)),
}
}
None => (true, String::new()),
};
bundles.push(pb::Bundle {
id: row.id.clone(),
label: label(&row),
price: priced.price.in_words(),
// The period the PRICE is for: a month, a year — and nothing for
// an upgrade (the year less a credit) or a bump (to the plan's
// end), whose price is one charge and not a rate; the label says
// what they run to.
period: match row.verb {
Verb::Upgrade | Verb::Bump => String::new(),
_ => row.period.name().to_owned(),
},
delta,
can_serve,
reason,
action: row.verb.name().to_owned(),
target,
});
}
let status = status(s, product.id(), cfg, &basis, to, cur.as_ref(), now);
Ok(pb::ListBundlesResponse {
bundles,
reading: false,
plan: Some(status),
})
}
/// **The gauge and the banner.**
fn status(
s: &AppState,
product: &str,
cfg: &PricingConfig,
basis: &CostBasis,
to: Currency,
cur: Option<&(Current, monetize_product::EntitlementFact)>,
now: u64,
) -> pb::PlanStatus {
let Some((c, fact)) = cur else {
return pb::PlanStatus {
state: "none".into(),
..Default::default()
};
};
let terms = s.terms.read().unwrap_or_else(|e| e.into_inner()).clone();
let end = c.paid_until_unix_ms;
let left = plan::days_left(end, now);
let state = match c.state {
State::Paid => "paid",
State::Grace => "grace",
State::Suspended => "read_only",
State::Retention => "retention",
State::Free => "none",
};
let ends_words = match c.state {
State::Paid => format!(
"Paid until {} — {left} day{} left",
iso_date(end),
if left == 1 { "" } else { "s" }
),
_ => format!("The paid time ended on {}", iso_date(end)),
};
let action = if c.period == Period::Month {
plan::PROLONG_ID
} else {
plan::RENEW_ID
};
let delete_on = s
.nonpay
.lapse(product, &fact.tenant.0, end)
.and_then(|l| l.delete_on_unix_ms)
.or_else(|| {
terms
.nonpay
.deletes()
.then(|| end + u64::from(terms.nonpay.delete_after_days) * DAY_MS)
});
let grace_end = end + u64::from(terms.policy.grace_days) * DAY_MS;
let deleted = delete_on
.map(|d| {
format!(
" Unless it is paid, everything in it is deleted on {}.",
iso_date(d)
)
})
.unwrap_or_default();
let reminders = terms.nonpay.reminder_days();
let (banner, tone) = match c.state {
State::Paid if reminders.iter().any(|d| u64::from(*d) * DAY_MS >= end.saturating_sub(now)) => (
format!(
"Your paid time ends on {} — in {left} day{}. {} to keep writing after that.",
iso_date(end),
if left == 1 { "" } else { "s" },
if c.period == Period::Month { "Prolong 1 month" } else { "Renew for 12 months" }
),
"warn",
),
State::Grace => (
format!(
"Your paid time ended on {}. Pay by {} to keep pushing — after that the account is read-only.{deleted}",
iso_date(end),
iso_date(grace_end)
),
"bad",
),
State::Suspended => (
format!(
"This account is read-only since {}: clone and fetch work, pushes are refused, and nothing in it is lost yet.{deleted} Paying restores it at once.",
iso_date(grace_end)
),
"bad",
),
_ => (String::new(), ""),
};
// A 12-month plan's size field: the per-GiB yearly rate, to the end.
let (bump_words, bump_min_bytes) =
if c.period == Period::Year && c.state == State::Paid && end > now {
let rate = u64::try_from(cfg.rule.yearly_gib_month_milli(basis).div_ceil(1000))
.unwrap_or(u64::MAX);
match convert(cfg, rate, to) {
Some(m) => (
format!(
"Any bigger size: {} per GiB per month, until {}",
m.in_words(),
iso_date(end)
),
c.size_bytes + GIB,
),
None => (String::new(), 0),
}
} else {
(String::new(), 0)
};
let progress = s
.ledger
.attempt(&fact.plan)
.ok()
.flatten()
.filter(|a| a.purchase.plan.is_some())
.map(|_| crate::services::purchase::room_progress(s, product, &fact.plan));
let (progress, progress_reference) = match progress {
Some(p) if p.state != "done" => (Some(p), fact.plan.clone()),
_ => (None, String::new()),
};
pb::PlanStatus {
state: state.into(),
period: c.period.name().into(),
label: plan::label(c.size_bytes, c.period),
cap_bytes: c.size_bytes,
paid_until_unix_ms: end,
days_left: left,
days_total: plan::days_total(c.period, end, now),
ends_words,
price_words: c
.month_price
.map(|m| format!("{} per month", m.in_words()))
.unwrap_or_default(),
banner: banner.clone(),
banner_action: if banner.is_empty() {
String::new()
} else {
action.to_owned()
},
banner_tone: tone.into(),
bump_words,
bump_min_bytes,
progress,
progress_reference,
}
}
#[cfg(test)]
mod tests;