crates/monetize-server/src/estate_order/ladder.rs
//! **The size ladder** (the sign-up page, owner 2026-10-07): ONE − / + for a
//! whole private Gunnar, stepping from Mini to Gigantic.
//!
//! The page holds no size and no price of its own (gunnar-ui is a thin
//! layer): the steps are `[estate_orders.ladder]`'s, each priced NOW exactly
//! as `EstateOrder.Quote` prices an estate — the provider's cost for the three
//! machines and their disks, + the margin, the term prepaid — and said as
//! words. A step the provider cannot price (a plan it does not sell, the spend
//! cap) is answered with its reason; the steps around it are still priced.
//!
//! For an ORDER, the ladder also says where it stands: the step it is laid at,
//! and the floor — a data disk never shrinks below what is laid, so no step
//! that holds less data is offered to it.
//!
//! ```toml
//! [[estate_orders.ladder]]
//! name = "Mini"
//! front_plan = "STARTER-2xCPU-4GB"
//! main_plan = "STARTER-4xCPU-8GB"
//! main_data_gib = 10 # per data disk; main has four
//! ```
//!
//! The twin is not on a step: it follows main, as on every order — an eighth
//! of it, never under 4 GB (Mini's twin is `STARTER-1xCPU-4GB`).
use monetize_pay::Money;
use super::{phase, EstateRun, EstateSizes, Quote, StartRefusal};
use crate::state::AppState;
/// One step: the operator's name for it and what it lays.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LadderStep {
pub name: String,
pub front_plan: String,
pub main_plan: String,
/// GB per data disk on main (four of them): 10 to 4096, in tens.
pub main_data_gib: u64,
}
impl LadderStep {
pub fn new(name: &str, front_plan: &str, main_plan: &str, main_data_gib: u64) -> LadderStep {
LadderStep {
name: name.into(),
front_plan: front_plan.into(),
main_plan: main_plan.into(),
main_data_gib,
}
}
pub fn sizes(&self) -> EstateSizes {
EstateSizes {
front_plan: self.front_plan.clone(),
main_plan: self.main_plan.clone(),
main_data_gib: self.main_data_gib,
..Default::default()
}
}
}
/// **The ladder an `[estate_orders]` that names none sells.** Mini is the
/// shape an order that names no size is laid at (`Shape::default`); each step
/// after it more main and more data, the front a step up from Small and again
/// from Large. UpCloud's own plan names (the STARTER line, then General
/// Purpose); a step whose plan the provider does not list is answered with
/// that reason.
///
/// **Mini** (owner, 2026-10-13: cheaper without breaking) is a 2-core 4 GB
/// front and a 4-core 8 GB main — it was 4/8 and 4/16 — with the twin the
/// rule gives every main that small, one core and 4 GB. An estate laid at the
/// old Mini stands on no step: it keeps its plans and its price until its
/// buyer changes its plan.
pub fn default_ladder() -> Vec<LadderStep> {
vec![
LadderStep::new("Mini", "STARTER-2xCPU-4GB", "STARTER-4xCPU-8GB", 10),
LadderStep::new("Small", "STARTER-4xCPU-8GB", "8xCPU-32GB", 30),
LadderStep::new("Medium", "STARTER-4xCPU-8GB", "12xCPU-48GB", 60),
LadderStep::new("Large", "6xCPU-16GB", "16xCPU-64GB", 120),
LadderStep::new("Huge", "6xCPU-16GB", "20xCPU-96GB", 250),
LadderStep::new("Gigantic", "8xCPU-32GB", "20xCPU-128GB", 500),
]
}
/// One step, priced (or why not).
#[derive(Clone, Debug)]
pub struct PricedStep {
pub step: LadderStep,
/// The estate this step lays, priced now; `None` when it cannot be.
pub quote: Option<Quote>,
/// Why it is not sold now, as one sentence; empty when it is.
pub reason: String,
}
impl PricedStep {
pub fn available(&self) -> bool {
self.quote.is_some() && self.reason.is_empty()
}
}
/// The ladder as answered.
#[derive(Clone, Debug)]
pub struct Ladder {
pub steps: Vec<PricedStep>,
/// Where a page starts: 0, or the laid step of an order.
pub start: usize,
/// The step an order is laid at (`None`: a new estate, or laid at a size
/// that is no step).
pub current: Option<usize>,
/// The lowest step an order may be sized to.
pub floor: usize,
pub apex: String,
pub term_months: u32,
}
/// How long [`sells_now`]'s answer is kept, ms: the console keeps it as long
/// (`gunnar-ui` `SELLS_FRESH`), and a price list does not change by the minute.
pub const SELLS_FRESH_MS: u64 = 60_000;
/// **Whether a new private estate can be bought here NOW** (`EstateOrder.Sells`;
/// test2, 2026-10-08): `[estate_orders]` is set AND at least one step of the
/// new-estate ladder is priced as [`ladder`] prices it — available. test2's
/// `[estate_orders]` was set over a cloud that cannot lay an estate, so every
/// step was answered "the upcloud cloud cannot lay an estate…" and the sign-up
/// page offered a private Gunnar nobody could buy. Stops at the first step that
/// is priced; one the provider cannot price is passed over, as on the page.
/// The answer is kept [`SELLS_FRESH_MS`] (`EstateOrders::sells_seen`): pricing
/// a step reads the provider's plan and price lists, and the console waits two
/// seconds for this answer — a slow first reading is ready for its next ask.
pub fn sells_now(state: &AppState) -> bool {
match sells_kept(state) {
Some((sells, true)) => sells,
_ => sells_refresh(state),
}
}
/// **The kept answer, whatever its age**: `(answer, fresh)`; `None` when
/// none was ever priced. A caller that must not wait (`EstateOrder.Sells`
/// while a lay holds the cloud guest's one lock for minutes, A32) answers the
/// kept one and has it refreshed beside ([`sells_refresh`]).
pub fn sells_kept(state: &AppState) -> Option<(bool, bool)> {
state.estates.cfg.as_ref()?;
let now = state.now();
let kept = *state
.estates
.sells_seen
.lock()
.unwrap_or_else(|p| p.into_inner());
kept.map(|(sells, at)| (sells, now.saturating_sub(at) < SELLS_FRESH_MS))
}
/// **Price the ladder's steps now** and keep the answer ([`sells_now`]).
pub fn sells_refresh(state: &AppState) -> bool {
let Some(cfg) = state.estates.cfg.as_ref() else {
return false;
};
let now = state.now();
// **The Sells rule** (2026-10-08): a shop with no install path sells nothing, however its
// steps price.
let sells = state.estates.installer.installs()
&& state.estates.writes_anywhere()
&& cfg
.ladder
.iter()
.any(|step| super::quote(state, "", &step.sizes()).is_ok());
*state
.estates
.sells_seen
.lock()
.unwrap_or_else(|p| p.into_inner()) = Some((sells, now));
sells
}
/// **The ladder, priced now** — for a new estate (`company` empty) or for an
/// order (`company` names it).
pub fn ladder(state: &AppState, company: &str) -> Result<Ladder, StartRefusal> {
ladder_on(state, None, company)
}
/// The ladder's steps priced on `terms` — the steps are the site's ([`super::terms_on`]: the
/// shop's, or another product's own, lane L18); `base` names the apex when `terms` has none.
fn priced(
state: &AppState,
base: &super::EstateOrderConfig,
terms: &super::EstateOrderConfig,
run: Option<EstateRun>,
company: &str,
) -> Result<Ladder, StartRefusal> {
let cfg = terms;
let laid = run.as_ref().map(|r| r.quote.sizes.clone());
let mut steps: Vec<PricedStep> = cfg
.ladder
.iter()
.map(
|step| match super::quote_on(state, terms, company, &step.sizes()) {
Ok(q) => PricedStep {
step: step.clone(),
quote: Some(q),
reason: String::new(),
},
Err(e) => {
eprintln!(
"monetize-server: estate order ladder: the {} step is not offered: {e}",
step.name
);
PricedStep {
step: step.clone(),
quote: None,
reason: e.to_string(),
}
}
},
)
.collect();
let (mut current, mut floor) = (None, 0);
if let (Some(now), Some(run)) = (laid.as_ref(), run.as_ref()) {
current = cfg.ladder.iter().position(|s| s.sizes() == *now);
// **A data disk never shrinks**: no step under what is laid.
floor = cfg
.ladder
.iter()
.position(|s| s.main_data_gib >= now.main_data_gib)
.unwrap_or(cfg.ladder.len());
let host = terms.host_of(&run.company);
for s in steps.iter_mut().take(floor) {
if s.reason.is_empty() {
s.reason = format!(
"{host} keeps 4 × {} GB of data, and data never shrinks: the {} size holds 4 × {} GB",
now.main_data_gib, s.step.name, s.step.main_data_gib
);
}
}
}
let start = current.unwrap_or(floor).min(steps.len().saturating_sub(1));
if steps.is_empty() {
floor = 0;
}
// ★ ST-5: named on the site it is (or was) bought on.
let apex = if terms.apex.trim().is_empty() {
base.apex.clone()
} else {
terms.apex.clone()
};
// A term of days has no months to say (the words say the days).
let term_months = if terms.term_days > 0 {
0
} else {
terms.term_months
};
Ok(Ladder {
steps,
start,
current,
floor,
apex,
term_months,
})
}
/// **The ladder the calling site's page reads** (private buy flow, 2026-10-12: test2's page
/// read the shop's "6 months prepaid" at 20 % while its Start held one day at 25 %): every
/// step priced on the terms that site's Start holds — [`super::terms_on`] for a new estate
/// bought on `site`, the run's own site's for an order ([`super::run_terms`]). A call from no
/// site, or from a site with no terms of its own, reads the shop's. A site this shop does not
/// sell on reads the shop's ladder; its Start is refused by name.
pub fn ladder_on(
state: &AppState,
site: Option<&str>,
company: &str,
) -> Result<Ladder, StartRefusal> {
let base = state.estates.cfg.as_ref().ok_or_else(|| {
StartRefusal::NotSold(
"this monetize sells no private estates ([estate_orders] is absent)".into(),
)
})?;
let company = company.trim();
let run: Option<EstateRun> = if company.is_empty() {
None
} else {
Some(
state
.estates
.get(company)
.filter(|r| !phase::is_gone(&r.phase))
.ok_or_else(|| StartRefusal::NotFound(format!("no order for {company:?}")))?,
)
};
let terms = match run.as_ref() {
Some(r) => super::run_terms(state, r).unwrap_or_else(|| base.clone()),
None => super::terms_on(state, base, site).unwrap_or_else(|why| {
eprintln!(
"monetize-server: estate order ladder: {} — the shop's terms are shown",
super::log_safe(&why)
);
base.clone()
}),
};
priced(state, base, &terms, run, company)
}
/// **The whole setup as words**: every machine's cores and memory summed,
/// and main's data set — `7 cores · 16 GB RAM · 40 GB data`.
pub fn resources_words(q: &Quote) -> String {
let cores: u64 = q.machines.iter().map(|m| u64::from(m.cores)).sum();
let mib: u64 = q.machines.iter().map(|m| m.memory_mib).sum();
let data: u64 = q
.machines
.iter()
.find(|m| m.role == "main")
.map(|m| m.disks_gib.iter().skip(1).sum())
.unwrap_or(0);
format!("{cores} cores · {} GB RAM · {data} GB data", gb(mib))
}
/// `108.00 EUR a month`.
pub fn monthly_words(q: &Quote) -> String {
format!("{} a month", q.monthly.in_words())
}
/// ★ ACCT-4 (2026-10-29): **what a term charges, as words** — the price a purchase row shows.
/// A customer's term of months keeps its month price ([`monthly_words`]); a HELD price (the
/// operator's own buy, A33-5: `held`) and a term of days say what the term charges, for that
/// term: `0.50 EUR a day`, `0.50 EUR every 6 months`, `13.20 EUR every 3 days`.
pub fn charge_words(q: &Quote, held: bool) -> String {
if !held && q.term_days == 0 {
return monthly_words(q);
}
let charged = q.total.in_words();
match (q.term_days, q.term_months) {
(1, _) => format!("{charged} a day"),
(0, 1) => format!("{charged} a month"),
(days, months) => format!("{charged} every {}", super::price::term_words(months, days)),
}
}
/// `6 months prepaid: 648.00 EUR`, `1 day prepaid: 4.40 EUR` — what Start holds, for the
/// term it holds it for ([`super::price::term_words`]).
pub fn total_words(q: &Quote) -> String {
format!(
"{} prepaid: {}",
super::price::term_words(q.term_months, q.term_days),
words(q.total)
)
}
fn words(m: Money) -> String {
m.in_words()
}
/// MiB as whole GB (rounded), the way the provider names its plans.
fn gb(mib: u64) -> u64 {
(mib + 512) / 1024
}
#[cfg(test)]
mod tests {
use super::*;
/// UpCloud's plans the default ladder names, with the small ones a twin could be laid
/// at: `(name, cores, GB)`.
fn upcloud() -> Vec<monetize_cloud::lay::PlanSpec> {
[
("STARTER-1xCPU-1GB", 1, 1),
("STARTER-1xCPU-2GB", 1, 2),
("STARTER-1xCPU-4GB", 1, 4),
("STARTER-2xCPU-4GB", 2, 4),
("STARTER-4xCPU-8GB", 4, 8),
("STARTER-4xCPU-16GB", 4, 16),
("1xCPU-1GB", 1, 1),
("1xCPU-2GB", 1, 2),
("2xCPU-4GB", 2, 4),
("4xCPU-8GB", 4, 8),
("6xCPU-16GB", 6, 16),
("8xCPU-32GB", 8, 32),
("12xCPU-48GB", 12, 48),
("16xCPU-64GB", 16, 64),
("20xCPU-96GB", 20, 96),
("20xCPU-128GB", 20, 128),
]
.into_iter()
.map(
|(name, cores, gb): (&str, u32, u64)| monetize_cloud::lay::PlanSpec {
name: name.into(),
cores,
memory_mib: gb * 1024,
// The STARTER line is the cheaper one for the same size, as at the provider.
price: monetize_cloud::Price {
minor_per_month: (u64::from(cores) * 300 + gb * 150)
* if name.starts_with("STARTER-") { 2 } else { 3 },
currency: monetize_pay::Currency::Eur,
},
},
)
.collect()
}
/// ★ **Mini** (owner, 2026-10-13): front `STARTER-2xCPU-4GB`, main `STARTER-4xCPU-8GB`,
/// and the twin the rule gives that main, `STARTER-1xCPU-4GB` — and the default shape
/// (an order that names no size) is that same Mini.
#[test]
fn the_default_mini_is_front_2x4_main_4x8_twin_1x4() {
let cfg = super::super::EstateOrderConfig::default();
let mini = &default_ladder()[0];
assert_eq!(
(
mini.name.as_str(),
mini.front_plan.as_str(),
mini.main_plan.as_str(),
mini.main_data_gib
),
("Mini", "STARTER-2xCPU-4GB", "STARTER-4xCPU-8GB", 10)
);
let (sizes, front, main, twin) =
super::super::price::resolve(&cfg, &upcloud(), &mini.sizes()).expect("Mini resolves");
assert_eq!(
(front.name.as_str(), main.name.as_str(), twin.name.as_str()),
(
"STARTER-2xCPU-4GB",
"STARTER-4xCPU-8GB",
"STARTER-1xCPU-4GB"
)
);
let unnamed =
super::super::price::resolve(&cfg, &upcloud(), &super::super::EstateSizes::default())
.expect("the default order resolves");
assert_eq!(
(unnamed.0, unnamed.3.name.as_str()),
(sizes, "STARTER-1xCPU-4GB"),
"an order that names no size is Mini"
);
assert_eq!(
cfg.shape.twin.plan, "STARTER-1xCPU-4GB",
"the shape's own twin is no 2 GB box either"
);
}
/// ★ **No step lays a twin under 4 GB** — the steps past Mini keep their sizes.
#[test]
fn no_step_of_the_default_ladder_lays_a_twin_under_4_gb() {
let cfg = super::super::EstateOrderConfig::default();
let mut twins = Vec::new();
for step in default_ladder() {
let (_, _, main, twin) = super::super::price::resolve(&cfg, &upcloud(), &step.sizes())
.unwrap_or_else(|e| panic!("{}: {e}", step.name));
assert!(
twin.memory_mib >= 4 * 1024,
"{}: a {} main gets a {} twin ({} MiB)",
step.name,
main.name,
twin.name,
twin.memory_mib
);
twins.push((
step.name.clone(),
step.front_plan.clone(),
step.main_plan.clone(),
twin.name.clone(),
));
}
let t = |a: &str, b: &str, c: &str, d: &str| {
(a.to_string(), b.to_string(), c.to_string(), d.to_string())
};
assert_eq!(
twins,
vec![
t(
"Mini",
"STARTER-2xCPU-4GB",
"STARTER-4xCPU-8GB",
"STARTER-1xCPU-4GB"
),
t(
"Small",
"STARTER-4xCPU-8GB",
"8xCPU-32GB",
"STARTER-1xCPU-4GB"
),
t(
"Medium",
"STARTER-4xCPU-8GB",
"12xCPU-48GB",
"STARTER-4xCPU-8GB"
),
t("Large", "6xCPU-16GB", "16xCPU-64GB", "STARTER-4xCPU-8GB"),
t("Huge", "6xCPU-16GB", "20xCPU-96GB", "STARTER-4xCPU-16GB"),
t(
"Gigantic",
"8xCPU-32GB",
"20xCPU-128GB",
"STARTER-4xCPU-16GB"
),
]
);
}
#[test]
fn memory_is_said_in_the_providers_gb() {
assert_eq!(gb(2048), 2);
assert_eq!(gb(16384 + 8192 + 2048), 26);
}
}