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

nordisk/monetize

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