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/new_estate.rs

//! **THE NEW-ESTATE SLOT** (owner 2026-10-29, lane OVW-CLEAN) — the Overview's one empty row:
//! the operator types a name and picks a product, and this box makes the estate and orders it.
//!
//! The server decides everything the slot does not say, and refuses by name:
//!
//! * **A declared site** (`[estates.<name>]`, fed or added in the application): nothing is made;
//!   it is ordered as it is declared — its product must be the one picked, and it must be one of
//!   the operator's own sites.
//! * **A new name**: the estate is copied from a TEMPLATE, a declared operator site of the same
//!   product that lays from media of its own ([`template_of`]: a terminable one first, then the
//!   one billing through the product's own route, then by label). The new site takes the
//!   template's first admin, Stripe account, mode, terms and media; it is terminable; its domain
//!   is its name in the template's zone ([`domain_for`]: `test4-gunnar-rs` → `test4.gunnar.rs`,
//!   the rule a declared site's label already follows). It is added to the box's SITE BOOK
//!   (`SiteBook.PutSite`: judged by the box's own loader, kept, audited, taken live).
//! * **Then the ONE site order** ([`super::site::start_site_back_to`], `EstateOrder.OrderSite`):
//!   the same Stripe step, the same lay. There is no second buy flow.
//!
//! The slot never lays over an estate that exists (installed or being laid,
//! [`super::standing`]); a name whose estate ended is free again.

use super::standing;
use crate::config::EstateConfig;
use crate::site_book::{SitePut, SiteTerms};
use crate::state::AppState;

/// How long the slot's call looks whether the box took a site it added (PID 1's look, two
/// seconds, then the server's config watcher, two seconds) before a queued job owns the order
/// (OVW-5): the call is never held longer.
pub const TAKEN_AT_ONCE: std::time::Duration = std::time::Duration::from_secs(5);
/// How long the queued job waits for the box to take it before it says so by name (an ALERT
/// line PID 1 mails to the operator).
pub const TAKEN_WITHIN: std::time::Duration = std::time::Duration::from_secs(120);
/// How often the job looks.
const TAKEN_RELOOK: std::time::Duration = std::time::Duration::from_millis(250);
/// The jera job kind of a late order.
pub const LATE_JOB: &str = "monetize_new_estate_order";

/// **An order the slot's call left to a queued job**: the site is added, the box has not taken it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LateOrder {
    pub name: String,
    pub email: String,
    pub size: String,
    pub return_url: String,
}

/// Pure: what the slot's call answers when the order is the job's.
pub fn late_words(name: &str, domain: &str, template: &str) -> String {
    format!("{name} is made as {domain} (its admin, account, terms and media as {template}'s); the box takes it within moments and then it is ordered: its payment page is on the Sites page")
}

/// **Queue the order of `o`** on the slot's lane (OVW-5): answered at once; the job waits for
/// the box to take the site, then places the ONE site order. The same name while its job is
/// queued or running is that job.
pub fn queue_late_order(state: &std::sync::Arc<AppState>, o: &LateOrder) -> Result<(), String> {
    let lane = lane(state)?;
    let spec = jera::jobs::chain::ChainSpec::new(
        LATE_JOB,
        format!("new-estate/{}", o.name),
        "new-estate",
        0,
        "monetize",
        serde_json::to_value(o).map_err(|e| e.to_string())?,
    )
    .with_target(format!(
        "order the new estate {} once the box takes it",
        o.name
    ))
    .with_priority(jera::jobs::chain::Priority::Normal);
    lane.submit(spec).job_id().map(|_| ()).ok_or_else(|| {
        format!(
            "REFUSED [new-estate-queue] the slot's lane refused the order of {}",
            o.name
        )
    })
}

fn lane(
    state: &std::sync::Arc<AppState>,
) -> Result<std::sync::Arc<jera::jobs::chain::ChainQueue>, String> {
    if let Some(q) = state.estates.new_estate_lane.get() {
        return Ok(q.clone());
    }
    let rt = tokio::runtime::Handle::try_current()
        .map_err(|_| "no runtime to run the New-estate lane on".to_string())?;
    let sink = crate::jobs::lane_sink("estate order: the New-estate slot");
    let queue = jera::jobs::chain::ChainQueue::new(sink);
    let q = state
        .estates
        .new_estate_lane
        .get_or_init(|| queue.clone())
        .clone();
    if std::sync::Arc::ptr_eq(&q, &queue) {
        let weak = std::sync::Arc::downgrade(state);
        // A32-14, stays a task: the New-estate lane's jera worker.
        rt.spawn(queue.clone().run(move |spec| {
            let Some(st) = weak.upgrade() else {
                anyhow::bail!("the server stopped")
            };
            let o: LateOrder = serde_json::from_value(spec.payload.clone())?;
            match order_when_taken(&st, &o, TAKEN_WITHIN) {
                Ok(line) => {
                    eprintln!("monetize-server: {line}");
                    Ok(serde_json::json!({ "estate": o.name }))
                }
                Err(why) => {
                    eprintln!(
                        "monetize-server: ALERT estate-orders.new-estate {}: {why}",
                        o.name
                    );
                    anyhow::bail!("{why}")
                }
            }
        }));
    }
    Ok(q)
}

/// **The job's work**: wait (at most `within`) for the box to take `o.name`, then place the ONE
/// site order. Answers the line it says; `Err` names why nothing was ordered.
pub fn order_when_taken(
    state: &AppState,
    o: &LateOrder,
    within: std::time::Duration,
) -> Result<String, String> {
    let t0 = std::time::Instant::now();
    while !taken(state, &o.name) {
        if t0.elapsed() >= within {
            return Err(format!("REFUSED [new-estate-not-taken-yet] {} is in the site book, and the box has not taken it within {} s: start it again from the slot by its name (nothing was ordered or held)", o.name, within.as_secs()));
        }
        std::thread::sleep(TAKEN_RELOOK);
    }
    let sizes = super::site::sizes_of_on(state, Some(&o.name), &o.size)?;
    let run = super::site::start_site_back_to(state, &o.name, &o.email, &sizes, "", &o.return_url)
        .map_err(|e| e.to_string())?;
    Ok(format!("new estate {}: taken by the box and ordered as {}: pay {} at its payment page (the Sites page)", o.name, run.reference, run.amount.in_words()))
}

/// **Why the slot refused** — each the operator reads whole.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NewRefusal {
    /// The ask is malformed (a name, a product, an address).
    Bad(String),
    /// The name (or its domain) belongs to an estate that exists.
    Taken(String),
    /// This box cannot make it (no template, a customer's site, no site book).
    NotHere(String),
}

impl std::fmt::Display for NewRefusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NewRefusal::Bad(w) | NewRefusal::Taken(w) | NewRefusal::NotHere(w) => f.write_str(w),
        }
    }
}

/// What the slot does with a name it accepted.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Prepared {
    /// A declared site: ordered as it is.
    Declared { domain: String },
    /// A new site: this put first (copied from `template`), then the order.
    Create { put: Box<SitePut>, template: String },
}

/// Pure: **why `name` cannot be an estate's name**, or `None` — a site label (lowercase letters,
/// digits, inner hyphens, at most 63), with letters or digits enough to be named at the cloud.
pub fn name_refusal(name: &str) -> Option<String> {
    let ok = !name.is_empty()
        && name.len() <= 63
        && !name.starts_with('-')
        && !name.ends_with('-')
        && name
            .bytes()
            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
    if !ok {
        return Some(format!("REFUSED [new-estate-name] {name:?} is not an estate's name: lowercase letters, digits and inner hyphens, at most 63 (`test4-example-com`)"));
    }
    let company = super::site::company_of(name);
    monetize_cloud::lay::company_name_refusal(&company).map(|why| format!("REFUSED [new-estate-name] {name:?} cannot be laid: as the cloud names an estate it is {company:?}, and {why}"))
}

/// Pure: **the zone a site's domain is in** — its last two labels (`test2.gunnar.rs` →
/// `gunnar.rs`); `None` for a name with fewer.
pub fn zone_of(domain: &str) -> Option<String> {
    let labels: Vec<&str> = domain.trim().trim_end_matches('.').split('.').collect();
    (labels.len() >= 2 && labels.iter().all(|l| !l.is_empty()))
        .then(|| labels[labels.len() - 2..].join("."))
}

/// Pure: **a new estate's domain**, from its name in `zone` — the rule a declared site's label
/// follows (its domain with hyphens for dots): the zone's own label is the zone
/// (`gunnar-rs` → `gunnar.rs`), a name that ends in it is a host under it (`test4-gunnar-rs` →
/// `test4.gunnar.rs`, `my-test-gunnar-rs` → `my-test.gunnar.rs`), and any other name is a host
/// under the zone as it is (`test4` → `test4.gunnar.rs`).
pub fn domain_for(name: &str, zone: &str) -> String {
    let own = zone.replace('.', "-");
    if name == own {
        return zone.to_string();
    }
    match name.strip_suffix(&format!("-{own}")) {
        Some(host) if !host.is_empty() => format!("{host}.{zone}"),
        _ => format!("{name}.{zone}"),
    }
}

/// Pure: a declared site's orders lay from media of its own.
fn has_media(e: &EstateConfig) -> bool {
    !e.orders.media.is_empty()
        || !e.orders.media_objects.is_empty()
        || !e.orders.templates.is_empty()
}

/// Pure: **the declared site a new estate of `product` is copied from**, or `None`: an operator
/// site of that product, with a domain and media of its own — a terminable one first (a site the
/// operator may end: what a new estate is), then the one billing through the product's own route
/// (`route`), then by label.
pub fn template_of(
    declared: &[(String, EstateConfig)],
    product: &str,
    route: Option<&str>,
) -> Option<(String, EstateConfig)> {
    let mut c: Vec<&(String, EstateConfig)> = declared
        .iter()
        .filter(|(_, e)| {
            e.product == product
                && e.operator_site
                && !e.owner.trim().is_empty()
                && zone_of(&e.site).is_some()
                && has_media(e)
        })
        .collect();
    c.sort_by_key(|(label, e)| {
        (
            !e.terminable,
            route != Some(e.vendor.as_str()),
            label.clone(),
        )
    });
    c.first().map(|(l, e)| (l.clone(), e.clone()))
}

/// **The products a new estate can be made of here**: each that has a template.
pub fn products_offered(state: &AppState) -> Vec<String> {
    let declared = state.registry.declared_estates();
    let mut products: Vec<String> = declared
        .iter()
        .map(|(_, e)| e.product.clone())
        .filter(|p| !p.is_empty())
        .collect();
    products.sort();
    products.dedup();
    products.retain(|p| template_of(&declared, p, state.registry.route_of(p).as_deref()).is_some());
    products
}

/// Pure: **the site book's put for a new estate** copied from `template`.
pub fn put_for(
    name: &str,
    domain: &str,
    template_label: &str,
    template: &EstateConfig,
    live: bool,
    actor: &str,
) -> SitePut {
    let o = &template.orders;
    SitePut {
        estate: name.to_string(),
        create: true,
        domain: domain.to_string(),
        product: template.product.clone(),
        first_admin: template.owner.trim().to_string(),
        operator_site: true,
        terminable: true,
        vendor: template.vendor.clone(),
        live,
        terms: SiteTerms {
            term_days: o.term_days.unwrap_or(0),
            term_months: o.term_months.unwrap_or(0),
            grace_hours: o.grace_hours.map(u32::from).unwrap_or(0),
            margin_bp: o.margin_bp.unwrap_or(0),
        },
        media_from: template_label.to_string(),
        actor: actor.trim().to_string(),
        ..Default::default()
    }
}

/// **What the slot does with `name` of `product`**, decided before anything is kept or held:
/// a declared site is ordered as it is; a new name is put in the site book first. Refused by
/// name: a bad name or address, a product with no template, a declared site of another product
/// or a customer's, an estate that exists, a domain another site has.
pub fn prepare(
    state: &AppState,
    name: &str,
    product: &str,
    email: &str,
    actor: &str,
) -> Result<Prepared, NewRefusal> {
    let (name, product, email) = (name.trim(), product.trim(), email.trim());
    if let Some(why) = name_refusal(name) {
        return Err(NewRefusal::Bad(why));
    }
    if email.is_empty() || !email.contains('@') || email.chars().any(char::is_whitespace) {
        return Err(NewRefusal::Bad(
            "REFUSED [new-estate-email] an estate needs the address its \"ready\" mail goes to"
                .into(),
        ));
    }
    let declared = state.registry.declared_estates();
    if let Some((_, e)) = declared.iter().find(|(label, _)| label == name) {
        if e.product != product {
            return Err(NewRefusal::Bad(format!(
                "REFUSED [new-estate-other-product] {name} is a declared {} site, not {product}",
                e.product
            )));
        }
        if !e.operator_site {
            return Err(NewRefusal::NotHere(format!("REFUSED [new-estate-customer-site] {name} is a customer's site: only the operator's own sites are laid from the slot")));
        }
        return Ok(Prepared::Declared {
            domain: e.site.trim().to_string(),
        });
    }
    let offered = products_offered(state);
    let Some((label, template)) = template_of(
        &declared,
        product,
        state.registry.route_of(product).as_deref(),
    ) else {
        return Err(NewRefusal::Bad(format!(
            "REFUSED [new-estate-product] {product:?} has no declared site of its own here to copy (an operator site with a domain and media of its own); the slot makes {}",
            if offered.is_empty() { "nothing on this box".to_string() } else { offered.join(", ") }
        )));
    };
    let zone = zone_of(&template.site).unwrap_or_default();
    let domain = domain_for(name, &zone);
    if let Some((other, _)) = declared
        .iter()
        .find(|(_, e)| e.site.trim().eq_ignore_ascii_case(&domain))
    {
        return Err(NewRefusal::Taken(format!("REFUSED [new-estate-domain-taken] {domain} is already {other}'s domain: start {other} by its name, or pick another name")));
    }
    if let Some(line) = standing::overview(state)
        .estates
        .into_iter()
        .find(|l| l.name == name)
    {
        return Err(NewRefusal::Taken(format!(
            "REFUSED [new-estate-taken] {name} exists ({}): it is not laid over",
            line.words
        )));
    }
    if state.registry.linked().site_book.is_none() {
        return Err(NewRefusal::NotHere(format!(
            "REFUSED [new-estate-no-book] {}",
            crate::site_book::NO_BOOK
        )));
    }
    let live = state
        .registry
        .find_vendor(&template.vendor)
        .and_then(|v| v.livemode())
        == Some(true);
    Ok(Prepared::Create {
        put: Box::new(put_for(name, &domain, &label, &template, live, actor)),
        template: label,
    })
}

/// Whether the box has taken `name` as one of the operator's sites (its config watcher applied it).
pub fn taken(state: &AppState, name: &str) -> bool {
    state.registry.operator_admin_of(name).is_some() && state.registry.site_of(name).is_some()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn site(
        product: &str,
        vendor: &str,
        domain: &str,
        terminable: bool,
        media: bool,
    ) -> EstateConfig {
        let mut e = EstateConfig {
            product: product.into(),
            vendor: vendor.into(),
            owner: "ignalina".into(),
            site: domain.into(),
            operator_site: true,
            terminable,
            ..Default::default()
        };
        if media {
            e.orders
                .media
                .insert("dev".into(), "objsto/release-media".into());
        }
        e.orders.term_days = Some(1);
        e.orders.grace_hours = Some(1);
        e.orders.margin_bp = Some(2500);
        e
    }

    /// ★ **The name is a site label the cloud can name** — refused by name, never trimmed into one.
    #[test]
    fn a_name_is_a_site_label_and_a_bad_one_is_refused_by_name() {
        for ok in ["test4-gunnar-rs", "lab4", "a1", "my-test-gunnar-rs"] {
            assert_eq!(name_refusal(ok), None, "{ok}");
        }
        // The cloud's own rule too: 16 letters and digits at most, `test<n>` its own.
        for bad in [
            "",
            "Test4",
            "-x",
            "x-",
            "a/b",
            "a.b",
            "a b",
            &"x".repeat(64),
            "test4",
            "4lab",
            "abcdefghijklmnopq-rs",
        ] {
            let why = name_refusal(bad).unwrap_or_else(|| panic!("{bad:?} is taken"));
            assert!(why.starts_with("REFUSED [new-estate-name]"), "{why}");
        }
    }

    /// ★ **The domain derives from the name** in the template's zone — the rule a declared
    /// site's label already follows (test2-gunnar-rs is test2.gunnar.rs).
    #[test]
    fn the_domain_is_the_name_in_the_templates_zone() {
        assert_eq!(zone_of("test2.gunnar.rs").as_deref(), Some("gunnar.rs"));
        assert_eq!(zone_of("holger.rs").as_deref(), Some("holger.rs"));
        assert_eq!(zone_of("p1-home.gunnar.rs.").as_deref(), Some("gunnar.rs"));
        assert_eq!(zone_of("localhost"), None);
        assert_eq!(zone_of(""), None);
        assert_eq!(
            domain_for("test4-gunnar-rs", "gunnar.rs"),
            "test4.gunnar.rs"
        );
        assert_eq!(
            domain_for("test2-holger-rs", "holger.rs"),
            "test2.holger.rs"
        );
        assert_eq!(
            domain_for("my-test-gunnar-rs", "gunnar.rs"),
            "my-test.gunnar.rs"
        );
        assert_eq!(domain_for("gunnar-rs", "gunnar.rs"), "gunnar.rs");
        assert_eq!(domain_for("test4", "gunnar.rs"), "test4.gunnar.rs");
        assert_eq!(
            domain_for("test4-holger-rs", "gunnar.rs"),
            "test4-holger-rs.gunnar.rs",
            "a name in another zone is a host in this one, never that zone"
        );
    }

    /// ★ **The template**: an operator site of the product with a domain and media of its own;
    /// a terminable one first, then the product's own route, then by label. None: not offered.
    #[test]
    fn the_template_is_a_terminable_operator_site_of_the_product_with_media() {
        let prod = vec![
            (
                "gunnar-rs".to_string(),
                site("gunnar", "stripe/gunnar", "gunnar.rs", false, true),
            ),
            (
                "holger-rs".to_string(),
                site("holger", "stripe/holger", "holger.rs", false, true),
            ),
            (
                "test2-gunnar-rs".to_string(),
                site(
                    "gunnar",
                    "stripe/gunnar-test2",
                    "test2.gunnar.rs",
                    true,
                    true,
                ),
            ),
            (
                "test2-holger-rs".to_string(),
                site(
                    "holger",
                    "stripe/gunnar-test2",
                    "test2.holger.rs",
                    true,
                    true,
                ),
            ),
        ];
        assert_eq!(
            template_of(&prod, "gunnar", Some("stripe/gunnar"))
                .map(|t| t.0)
                .as_deref(),
            Some("test2-gunnar-rs"),
            "the terminable test site, over the protected home"
        );
        assert_eq!(
            template_of(&prod, "holger", Some("stripe/holger"))
                .map(|t| t.0)
                .as_deref(),
            Some("test2-holger-rs")
        );
        assert_eq!(
            template_of(&prod, "lake", None),
            None,
            "no site of the product"
        );
        // test2's box: two terminable Holger sites; the one on the product's own route wins.
        let t2 = vec![
            (
                "holger-p1-check".to_string(),
                site(
                    "holger",
                    "stripe/gunnar-test2",
                    "p1-check.holger.rs",
                    true,
                    true,
                ),
            ),
            (
                "holger-r34f".to_string(),
                site(
                    "holger",
                    "stripe/holger-test2",
                    "r34f.holger.rs",
                    true,
                    true,
                ),
            ),
        ];
        assert_eq!(
            template_of(&t2, "holger", Some("stripe/holger-test2"))
                .map(|t| t.0)
                .as_deref(),
            Some("holger-r34f")
        );
        // A site with no media of its own, a customer's site, and one with no domain are no template.
        let mut customer = site("gunnar", "stripe/kunde", "kunde.se", true, true);
        customer.operator_site = false;
        let bare = vec![
            (
                "bare".to_string(),
                site("gunnar", "stripe/x", "bare.gunnar.rs", true, false),
            ),
            ("kunde-se".to_string(), customer),
            (
                "nodomain".to_string(),
                site("gunnar", "stripe/y", "", true, true),
            ),
        ];
        assert_eq!(template_of(&bare, "gunnar", None), None);
    }

    /// ★ **The put copies the template** and makes the new site the operator's, terminable,
    /// laid from the template's media.
    #[test]
    fn the_put_copies_the_templates_admin_account_terms_and_media() {
        let t = site(
            "gunnar",
            "stripe/gunnar-test2",
            "test2.gunnar.rs",
            true,
            true,
        );
        let p = put_for(
            "test4-gunnar-rs",
            "test4.gunnar.rs",
            "test2-gunnar-rs",
            &t,
            true,
            "console:op-1",
        );
        assert_eq!(
            (
                p.estate.as_str(),
                p.domain.as_str(),
                p.product.as_str(),
                p.first_admin.as_str(),
                p.vendor.as_str()
            ),
            (
                "test4-gunnar-rs",
                "test4.gunnar.rs",
                "gunnar",
                "ignalina",
                "stripe/gunnar-test2"
            )
        );
        assert!(p.create && p.operator_site && p.terminable && p.live);
        assert_eq!(
            (
                p.terms.term_days,
                p.terms.term_months,
                p.terms.grace_hours,
                p.terms.margin_bp
            ),
            (1, 0, 1, 2500)
        );
        assert_eq!(
            (p.media_from.as_str(), p.actor.as_str()),
            ("test2-gunnar-rs", "console:op-1")
        );
        assert!(
            p.vendor_secret_key.is_empty() && p.vendor_webhook_secret.is_empty(),
            "no secret: the account is the template's, held"
        );
    }
}