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

//! **The account that buys and owns a private Gunnar is PROVEN** (one shop,
//! owner 2026-10-07).
//!
//! A customer is a gunnar.rs ACCOUNT. The shop's own console asks as the
//! signed-in person: it has the shop's OWN Gunnar mint an `estate` actor
//! ticket ([`monetize_embed::PURPOSE_ESTATE`], `Billing.MintActorTicket`) for
//! that account — a member of an account is refused one there — and hands
//! the bytes over with the call. The host checks it ([`crate::site_ticket::may_act`],
//! A32-13): under the appliance key the shop holds for its own Gunnar, for that
//! account, for that verb, fresh, and unspent (the host's replay ring). So:
//!
//! * `EstateOrder.Start` holds for the account the ticket proves — the run's
//!   `account` is no longer a typed string;
//! * `EstateOrder.Ladder{company}` and `EstateOrder.ChangePlan` answer only
//!   the run's own account ([`owner_of`]), in ONE sentence whatever the cause,
//!   so no estate is told apart from another by its refusal.
//!
//! A SOLD estate's own Gunnar proves nothing here: the shop does not hold its
//! key. (The separate owner's door keyed to a sold estate's appliance key,
//! private ownership (c), is gone.)

use crate::estate_order::EstateRun;
use crate::state::AppState;

/// The one answer when the account is not proven, the product called as `w` says.
pub fn not_proven(w: &super::lifecycle::ProductWords) -> String {
    let home = if w.account_home.is_empty() {
        String::new()
    } else {
        format!("{} ", w.account_home)
    };
    format!("a private {} is bought, read and changed by its {home}account: sign in to that account and ask again", w.title)
}

/// Why an account was not proven.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unproven {
    /// The ticket does not prove the account: [`NOT_PROVEN`] to the caller.
    Refused,
    /// The shop's Gunnar could not be asked; ask again.
    Unavailable(String),
}

/// **`account`'s `estate` ticket, checked** — from `site` (the calling estate, ST-3) under
/// that site's own main's key ([`crate::site_ticket::may_act`]) for the product that site
/// sells; with no site, by the shop's gunnar product as before. `account` is the account's
/// name on its site (plain, or `@<site>/<name>`). What went wrong goes to the operator's log
/// (never the ticket).
pub fn proven(
    state: &AppState,
    site: Option<&str>,
    account: &str,
    ticket: &[u8],
) -> Result<(), Unproven> {
    let refused = |why: &str| {
        eprintln!("monetize-server: an estate call for account {account:?} was refused: {why}");
        Unproven::Refused
    };
    if account.trim().is_empty() {
        return Err(refused("no account"));
    }
    if ticket.is_empty() {
        return Err(refused("no ticket"));
    }
    let product_id = match site.and_then(|e| state.registry.product_of_estate(e)) {
        Some(p) => p,
        None => state
            .sold_product()
            .map_err(Unproven::Unavailable)?
            .to_owned(),
    };
    let Some(product) = state.registry.find_product(&product_id) else {
        return Err(Unproven::Unavailable(format!(
            "this shop serves no {product_id} product, so it cannot prove an account"
        )));
    };
    let none = std::collections::BTreeMap::new();
    match crate::site_ticket::may_act(
        state,
        product,
        site,
        account,
        monetize_embed::PURPOSE_ESTATE,
        &none,
        ticket,
    ) {
        Ok(monetize_product::ActorVerdict::Allowed) => Ok(()),
        Ok(monetize_product::ActorVerdict::Refused(why)) => Err(refused(&why)),
        Err(monetize_product::ProductError::Unavailable(why)) => Err(Unproven::Unavailable(why)),
        Err(monetize_product::ProductError::Refused(why)) => Err(refused(&why)),
    }
}

/// **The account's name on its own site**: `account` as given, or `@<site>/<name>` of the
/// calling site spelt in full; another site's account is `Err` (refused, one sentence).
pub fn plain_account(site: Option<&str>, account: &str) -> Result<String, Unproven> {
    match (
        site.map(str::trim).filter(|e| !e.is_empty()),
        monetize_product::TenantId(account.to_owned()).estate(),
    ) {
        (_, None) => Ok(account.to_owned()),
        (Some(site), Some((e, name))) if e == site => Ok(name.to_owned()),
        _ => Err(Unproven::Refused),
    }
}

/// One room line of an account (`EstateOrder.Purchases`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Room {
    pub state: String,
    pub paid_until_ms: Option<u64>,
    pub cap_bytes: u64,
}

/// Pure: **`account`'s room here**, from the facts this shop holds — none for
/// an account at the shop without room (its fact is Free with
/// [`crate::signup::NO_ROOM_CAP`]: it holds no room, so there is no line).
pub fn rooms_of(
    facts: &[monetize_product::EntitlementFact],
    account: &str,
    cap_meter: &str,
) -> Vec<Room> {
    facts
        .iter()
        .filter(|f| f.tenant.0 == account)
        // An account at the shop without room: its fact holds no room here.
        .filter(|f| {
            !(f.state == monetize_product::State::Free
                && f.caps.get(cap_meter).copied() == Some(crate::signup::NO_ROOM_CAP))
        })
        .map(|f| Room {
            state: format!("{:?}", f.state).to_ascii_lowercase(),
            paid_until_ms: f.paid_until_unix_ms,
            cap_bytes: f.caps.get(cap_meter).copied().unwrap_or(0),
        })
        .collect()
}

/// Pure: **whether `account` is an account at this shop without room here** —
/// it holds the shop's account fact (Free, [`crate::signup::NO_ROOM_CAP`]).
pub fn is_account_here(
    facts: &[monetize_product::EntitlementFact],
    account: &str,
    cap_meter: &str,
) -> bool {
    facts.iter().any(|f| {
        f.tenant.0 == account
            && f.state == monetize_product::State::Free
            && f.caps.get(cap_meter).copied() == Some(crate::signup::NO_ROOM_CAP)
    })
}

/// **The order whose account the ticket proves**: `company`'s run, when the
/// ticket is its account's — on the run's OWN site, under that site's key (ST-3/ST-5), and
/// only for a call from that site; [`Unproven::Refused`] for an order nobody placed too, and
/// for another site's order, so a stranger learns nothing about which estates exist.
pub fn owner_of(
    state: &AppState,
    site: Option<&str>,
    company: &str,
    ticket: &[u8],
) -> Result<EstateRun, Unproven> {
    let Some(run) = state.estates.get(company) else {
        eprintln!("monetize-server: an estate call for {company:?} was refused: no such order");
        return Err(Unproven::Refused);
    };
    let bought_on = crate::estate_order::site_of_run(state, &run);
    if let Some(caller) = site.map(str::trim).filter(|e| !e.is_empty()) {
        if bought_on.as_deref() != Some(caller) {
            eprintln!("monetize-server: an estate call for {company:?} from {caller} was refused: it was bought on {bought_on:?}");
            return Err(Unproven::Refused);
        }
    }
    proven(state, bought_on.as_deref(), &run.account, ticket)?;
    Ok(run)
}

/// ★ **The account a visitor's order makes once it is PAID** (pay once, owner 2026-10-22): a
/// private estate bought by a visitor with no account here meets the vendor ONCE — the
/// estate's payment — and the account is made at that payment, as a paid signup's is: the
/// signup sink makes it on the order's site (a pulled product's main makes it when it next
/// calls monetize), its record is kept under [`crate::signup::ACCOUNT_VENDOR`] with `session`,
/// and its sign-in link (`welcome_url` with the session) is mailed to `email`.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct NewAccount {
    /// The product the account is made for, and its account plan (no price, no room).
    pub product: String,
    pub plan: String,
    pub currency: monetize_pay::Currency,
    /// The name the account is to have (the run's `account`) and where its link goes.
    pub username: String,
    pub email: String,
    /// The console's welcome page, `{session}` in it.
    pub welcome_url: String,
    /// The shop's own session for the account ([`crate::signup::account_session`]): the key
    /// of its record, and so what makes the payment's account ONE account.
    pub session: String,
    /// The estate the order came from (the caller's bearer), and its site's host.
    #[serde(default)]
    pub estate: Option<String>,
    #[serde(default)]
    pub site: Option<String>,
    /// Where the vendor's page sends the payer back (the ONE shop's own status page, lane
    /// G12); empty: the vendor's own return.
    #[serde(default)]
    pub back_to: String,
    /// Where the vendor's page sends a payer who CANCELS (A10 row 6: the site's own
    /// `/signup?plan=private`); empty: the way back, [`Self::back_to`].
    #[serde(default)]
    pub cancel_to: String,
    /// ★ A33-2/A33-4: the vendor's TEST payment method that pays the hold server-side (the test
    /// button's press, a test key only); empty: the visitor pays at the vendor's page. Data, the
    /// only thing a test press's order differs in.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub test_payer: String,
}