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

nordisk/monetize

crates/monetize-ui-web/src/model.rs

//! The JSON shapes `monetize-ui-server` sends the console — and NOTHING that
//! needs a browser, so [`crate::fmt`] can decide over them on the host.
//!
//! These used to live in `pages/mod.rs`, which is `wasm32`-only. A decision
//! like "which tenant is closest to its cap" or "may this trend be rendered as a
//! date" takes one of these shapes as input, and a test of that decision must
//! run under `cargo test` on the host. So the shapes live here, compiled for
//! every target, and the pages import them from here.
//!
//! Every field is what the BFF wrote (`monetize-ui-server/src/api.rs`), field
//! for field. `#[serde(default)]` marks the ones a server older than the field
//! would omit; the console then renders their absence honestly rather than
//! failing to parse the page.

use std::collections::BTreeMap;

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct MeterInfo {
    pub name: String,
    pub meaning: String,
    /// `enforced` | `measured` | `provisioned` | `unknown` — whether a cap on
    /// this meter is a WALL the tenant hits or only a line item. Two caps that
    /// look identical as numbers are not the same promise, and the console says
    /// which. Defaulted for a server older than the field.
    #[serde(default)]
    pub enforcement: String,
    /// `tenant` | `product` | `unknown` — WHERE the number is meaningful. A
    /// `product` meter belongs on the product screen once and in no per-tenant
    /// list, and is never summed across tenants.
    #[serde(default)]
    pub scope: String,
    /// **The price-list unit a cap on this meter is priced by** —
    /// `disk_gib_month` | `cpu_core_month` | `ram_gib_month` — or empty for a
    /// count that costs nothing to promise. A meter is BILLED iff the product's
    /// list has a non-zero number under this key (`fmt::billing_word`).
    #[serde(default)]
    pub unit: String,
}

/// **What a resource unit costs the customer per month in one market** — the
/// deployment's price list for one product (`[prices.<product>.<MARKET>]`).
/// No list for a product is FREE in every market; a unit absent here is zero,
/// and zero is free. The open-source deployment has none at all.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct PriceListJson {
    #[serde(default)]
    pub product: String,
    /// `SE` | `FI`.
    #[serde(default)]
    pub market: String,
    /// `SEK` | `EUR`.
    #[serde(default)]
    pub currency: String,
    /// Minor units per unit-month, keyed by [`MeterInfo::unit`].
    #[serde(default)]
    pub units: BTreeMap<String, u64>,
}

/// **The one policy**: the billing period and the ladder's day counts. Empty
/// `period` from a monetize too old to say.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct PolicyJson {
    #[serde(default)]
    pub period: String,
    #[serde(default)]
    pub grace_days: u32,
    #[serde(default)]
    pub retention_days: u32,
}

/// **What an order would come to, before any money moves** — `GET /api/quote`.
/// Every money figure in minor units with its currency beside it; the iron in
/// the same words BACKED BY uses; the refusal verbatim when it cannot be served.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct QuoteJson {
    #[serde(default)]
    pub reference: String,
    /// What the customer pays for the delta over one period. 0 is free.
    #[serde(default)]
    pub price_minor: u64,
    #[serde(default)]
    pub currency: String,
    /// What the provider charges us for the delta's iron. 0 on the box's own iron.
    #[serde(default)]
    pub cost_minor: u64,
    #[serde(default)]
    pub cost_currency: String,
    #[serde(default)]
    pub current: BTreeMap<String, u64>,
    #[serde(default)]
    pub caps: BTreeMap<String, u64>,
    /// What is actually SOLD: the raise over what the tenant holds.
    #[serde(default)]
    pub delta: BTreeMap<String, u64>,
    #[serde(default)]
    pub resources: Vec<HoldingJson>,
    #[serde(default)]
    pub can_serve: bool,
    #[serde(default)]
    pub reason: String,
    /// `month` | `year`.
    #[serde(default)]
    pub period: String,
}

/// **The dot on `docs/v2/07-console.md` screen 1.** The server's last observation, never a call
/// made while the page loads. `unknown` is NOT down: nobody has asked yet.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct ReachabilityJson {
    /// `unknown` | `up` | `down`.
    #[serde(default)]
    pub state: String,
    /// 0 = never asked.
    #[serde(default)]
    pub checked_at_unix_ms: u64,
    /// 0 = never.
    #[serde(default)]
    pub last_up_unix_ms: u64,
    /// The plugin's own words when `state` is `down`.
    #[serde(default)]
    pub detail: String,
}

/// **A rate, including the arm that has no rate.** `known == false` is "not
/// enough history", not a rate of zero — `per_week` is `None` there precisely
/// so nothing can render a zero by forgetting to check.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TrendJson {
    /// The meter the rate is OF. On the surface, never inferred.
    pub meter: String,
    pub known: bool,
    /// Units per week, signed. `None` when `known` is false.
    #[serde(default)]
    pub per_week: Option<f64>,
    /// `firm` | `approximate` | `bursty`; empty when unknown.
    #[serde(default)]
    pub confidence: String,
    /// `""` for a firm figure, `"~"` otherwise — the rate's own marker.
    #[serde(default)]
    pub marker: String,
    /// **False forbids rendering a date at all.** A bursty series projected to
    /// a day is a confident wrong day somebody plans a re-image around.
    #[serde(default)]
    pub may_project: bool,
    #[serde(default)]
    pub largest_step_share: f64,
    #[serde(default)]
    pub readings: u64,
    #[serde(default)]
    pub span_ms: u64,
    #[serde(default)]
    pub needed_span_ms: u64,
    #[serde(default)]
    pub window_ms: u64,
    /// Fleet only. `unmeasured_tenants > 0` makes `per_week` a floor.
    #[serde(default)]
    pub measured_tenants: u64,
    #[serde(default)]
    pub unmeasured_tenants: u64,
}

/// One per-tenant meter's fleet total and its oversell number. `oversell` is
/// read BEFORE `oversell_bytes`: `unmeasured` and `not_storage` carry no bytes.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct FleetMeterJson {
    pub meter: String,
    pub tenants: u64,
    /// The sum of the latest reading, over tenants that have one.
    pub used: u64,
    /// Above zero makes `used` a floor.
    pub tenants_without_reading: u64,
    pub allowance: u64,
    pub tenants_without_cap: u64,
    /// `oversold` | `fits` | `unmeasured` | `not_storage`.
    pub oversell: String,
    pub oversell_bytes: u64,
    pub oversell_reason: String,
    /// `used + servable`. **`None` when the product could not measure** — never
    /// an invented denominator.
    #[serde(default)]
    pub capacity: Option<u64>,
    /// Any number here is a floor rather than a total; the console says so.
    pub is_a_floor: bool,
}

/// **Can the store ever hold more?** `state` is what the badge keys on,
/// `detail` is the product's own remediation sentence the badge holds.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct GrowthJson {
    /// `sealed` | `operator_only` | `unknown`, or `""` when the product could
    /// not be measured at all — which is not `unknown`: nobody asked.
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub detail: String,
}

/// **The fleet**: what a product's tenants hold, what they were promised, what
/// the product says it can serve, and whether that can ever grow.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct FleetJson {
    /// One entry per PER-TENANT meter; a `scope: product` meter is absent.
    #[serde(default)]
    pub meters: Vec<FleetMeterJson>,
    pub servable_measured: bool,
    pub servable_bytes: u64,
    pub total_bytes: u64,
    pub servable_reason: String,
    /// 0 = never asked.
    pub servable_at_unix_ms: u64,
    #[serde(default)]
    pub growth: GrowthJson,
    /// **What we actually own out there** — the server's `CloudInventory`
    /// reconciled against the ledger. `None` when the server sent no block at
    /// all; see [`crate::fmt::cloud_panel_state`] for the three states the
    /// panel can be in.
    #[serde(default)]
    pub cloud: Option<CloudJson>,
}

/// One thing the provider says exists with our label on it (`OwnedResource`
/// in `usage.proto`). The same shape lists `owned` and `orphans`: the
/// difference between them is which list it is in, not what it is.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct CloudOwnedJson {
    #[serde(default)]
    pub provider: String,
    #[serde(default)]
    pub id: String,
    /// `disk` | `server`.
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub gib: u64,
    #[serde(default)]
    pub zone: String,
    /// The server a disk is attached to; empty when detached or not reported.
    #[serde(default)]
    pub attached_to: String,
    /// The `monetize_ref` label — the order it was bought for. Empty on an
    /// orphan the provider lists without one.
    #[serde(default)]
    pub reference: String,
    #[serde(default)]
    pub tenant: String,
    /// `monthly_minor` means something only when this is true; an unpriced
    /// row is not free.
    #[serde(default)]
    pub priced: bool,
    #[serde(default)]
    pub monthly_minor: u64,
    /// `upcloud` for real iron, `sandbox` for play iron. A tenant on fake iron
    /// must be distinguishable forever (`docs/v2/06-data-model.md` §6.1 rule 2).
    #[serde(default)]
    pub provenance: String,
    /// The estate it belongs to (MAP-SEG 1): its fleet name; empty when no ONE estate's.
    #[serde(default)]
    pub estate: String,
    /// The provider's own name for it, where that is how it is known (an orphan backup's
    /// title, MAP-3); empty otherwise.
    #[serde(default)]
    pub title: String,
}

/// One provider-side BACKUP of one of the account's disks (`BackupResource`, MAP-3): under
/// the estate of that disk, never an owned disk, never an orphan while the disk stands.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct CloudBackupJson {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub gib: u64,
    #[serde(default)]
    pub zone: String,
    #[serde(default)]
    pub estate: String,
    #[serde(default)]
    pub reference: String,
    /// The server's words: `backup of <server>, <date>`.
    #[serde(default)]
    pub words: String,
}

/// In this product's ledger, not at the provider: sold capacity that is not
/// there (`MissingResource` in `usage.proto`).
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct CloudMissingJson {
    #[serde(default)]
    pub provider: String,
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub reference: String,
    #[serde(default)]
    pub tenant: String,
    /// The resource fingerprint: `disk:gunnar-se-sto1:10`.
    #[serde(default)]
    pub resource: String,
    /// The estate it was sold for (MAP-SEG 1).
    #[serde(default)]
    pub estate: String,
}

/// `CloudInventory`, field for field. `available: false` with `error` when
/// the provider could not be listed — every list is then empty because nobody
/// asked, not because nothing is there. `monthly_minor` is over the PRICED
/// rows of `owned` only, in the provider's currency minor units; `unpriced > 0`
/// makes it a floor.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct CloudJson {
    #[serde(default)]
    pub provider: String,
    #[serde(default)]
    pub available: bool,
    #[serde(default)]
    pub error: String,
    #[serde(default)]
    pub owned: Vec<CloudOwnedJson>,
    #[serde(default)]
    pub orphans: Vec<CloudOwnedJson>,
    #[serde(default)]
    pub missing: Vec<CloudMissingJson>,
    /// Resources in the account with no monetize label: not ours.
    #[serde(default)]
    pub foreign_count: u64,
    /// Over `owned` only.
    #[serde(default)]
    pub total_bytes: u64,
    #[serde(default)]
    pub monthly_minor: u64,
    #[serde(default)]
    pub unpriced: u64,
    /// The ISO code `monthly_minor` is in (`EUR`, `SEK`), from the provider's
    /// price source. Empty from a server older than the field — the amount is
    /// then shown alone, never with a guessed unit.
    #[serde(default)]
    pub currency: String,
    /// The provider's backups of the account's disks (MAP-3), counted apart from `owned`.
    #[serde(default)]
    pub backups: Vec<CloudBackupJson>,
    /// Over `backups` only.
    #[serde(default)]
    pub backup_bytes: u64,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ProductCard {
    pub id: String,
    pub meters: Vec<MeterInfo>,
    pub tenants: usize,
    pub by_state: BTreeMap<String, usize>,
    #[serde(default)]
    pub reachable: ReachabilityJson,
    /// The fleet rate of the product's headline meter, or `None` when the
    /// product declares no wall to lead with.
    #[serde(default)]
    pub trend: Option<TrendJson>,
    #[serde(default)]
    pub fleet: FleetJson,
}

/// **The spend against the cloud's ceiling** — `docs/v2/07-console.md` screen 1's last line,
/// the number that stops everything. `spent_minor` is the ledger's fold, a
/// LIFETIME total. Each limit is `None` when it is not
/// set: an unset limit is not a limit of zero (zero means refuse every
/// purchase), and both `None` is the dangerous state `fmt::spend_line` says in
/// words.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct SpendJson {
    #[serde(default)]
    pub provider: String,
    #[serde(default)]
    pub currency: String,
    #[serde(default)]
    pub spent_minor: u64,
    #[serde(default)]
    pub spent_this_month_minor: Option<u64>,
    #[serde(default)]
    pub ceiling_total_minor: Option<u64>,
    #[serde(default)]
    pub ceiling_monthly_minor: Option<u64>,
}

/// `spend` as the BFF sends it now (a list) or as it sent it before (one
/// object, or `null`). The bundle and the BFF ship together, but a page open
/// across a deploy should not blank on the first reload.
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<SpendJson>, D::Error> {
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum Shape {
        Many(Vec<SpendJson>),
        One(SpendJson),
    }
    Ok(match Option::<Shape>::deserialize(d)? {
        None => Vec::new(),
        Some(Shape::Many(v)) => v,
        Some(Shape::One(s)) => vec![s],
    })
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ProductsJson {
    pub products: Vec<ProductCard>,
    pub vendors: Vec<String>,
    pub clouds: Vec<String>,
    /// One per cloud linked, in the server's order; empty from a box with no
    /// cloud: no line. A BFF from before 2026-09-14 sent ONE object (or null),
    /// and that still reads, as a list of one (or none).
    #[serde(default, deserialize_with = "one_or_many")]
    pub spend: Vec<SpendJson>,
    /// The cloud a purchase provisions on; empty when none is (or from a BFF
    /// older than the field), and then no line is marked.
    #[serde(default)]
    pub default_cloud: String,
    /// Every price list the deployment has. Empty is the open-source shape: free.
    #[serde(default)]
    pub prices: Vec<PriceListJson>,
    #[serde(default)]
    pub policy: PolicyJson,
    /// product id → the host a bought disk attaches to.
    #[serde(default)]
    pub hosts: BTreeMap<String, String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TenantRow {
    pub tenant: String,
    /// The ORDER the tenant's fact came from (the ledger reference); empty
    /// for a hand-set or absent fact.
    #[serde(default)]
    pub order: String,
    pub state: String,
    pub paid_until_unix_ms: Option<u64>,
    pub source: String,
    /// The BFF fetched monetize's verifying key and CHECKED the fact's Ed25519
    /// signature. It does not mean "the signature field is populated".
    pub signed: bool,
    pub meters: BTreeMap<String, u64>,
    pub measured_at_unix_ms: Option<u64>,
    /// The caps the tenant's signed fact names — what they are ALLOWED — so a
    /// list of tenants can be sorted by who is closest to their cap without a
    /// round trip per row. Empty = the product's own defaults, which monetize
    /// does not know: uncapped as far as this console can say.
    #[serde(default)]
    pub caps: BTreeMap<String, u64>,
}

/// **One attempt as one line** — `docs/v2/07-console.md` rule 6, outcomes not steps.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct SaleJson {
    /// The ledger key a disclosure opens the steps with.
    pub reference: String,
    pub product: String,
    pub tenant: String,
    /// Minor units. Never a float.
    pub price_minor: u64,
    pub currency: String,
    /// **What the provider charges us** for what this sale bought, as quoted
    /// on the same ledger row — so the margin is a fold, never a field.
    #[serde(default)]
    pub cost_minor: u64,
    #[serde(default)]
    pub cost_currency: String,
    /// **What the payment vendor kept.** `None` is "nobody reported one", and it
    /// is not zero: a sale at price 0 never reached a vendor, `invoice` has none,
    /// `klarna` settles in a portal. The net is not a field — it is
    /// `price_minor − fee_minor`, folded where it is shown.
    #[serde(default)]
    pub fee_minor: Option<u64>,
    #[serde(default)]
    pub fee_currency: String,
    /// **The VAT this order carries** (minor units); `None` when the attempt
    /// charged nothing, or the server is older than the field.
    #[serde(default)]
    pub tax_minor: Option<u64>,
    #[serde(default)]
    pub tax_currency: String,
    /// The figure is the vendor's own (Stripe's `amount_tax`), not the fold.
    #[serde(default)]
    pub tax_from_vendor: bool,
    /// The rate, basis points: the vendor's for the buyer's country when it
    /// said, else the one recorded at sale time.
    #[serde(default)]
    pub vat_basis_points: u32,
    /// The buyer's country as the vendor returned it for this payment; empty
    /// when it did not say (or the server is older than the field).
    #[serde(default)]
    pub customer_country: String,
    /// What the tenant is allowed after the sale — the full caps.
    #[serde(default)]
    pub caps: BTreeMap<String, u64>,
    /// **What was SOLD** — the delta over what they held. `Sold bob 90 GiB`.
    #[serde(default)]
    pub delta: BTreeMap<String, u64>,
    pub started_unix_ms: u64,
    /// The attempt's newest row: what the list is ordered by.
    pub at_unix_ms: u64,
    pub steps: u32,
    /// Money was actually captured at some point.
    pub drawn: bool,
    /// `granted` | `refunded` | `declined` | `open`. **`declined` is not a
    /// refund** — nothing was locked.
    pub outcome: String,
    pub reason: String,
    /// The only row that is actually a task.
    pub needs_you: bool,
}

/// **One tenant nobody has pushed to or fetched from** — a row of the fleet
/// page's SILENT list, longest quiet first.
///
/// `quiet_days` is `Option` and NOT a defaulted `0`, because the wire sends
/// `null` for a tenant that has NEVER been seen and that is a different fact
/// from one seen today. A `0` here would put "quiet 0 d" beside a customer
/// nobody has ever used — the most reassuring possible rendering of the least
/// reassuring row on the page. [`crate::fmt::quiet_line`] is the one reader of
/// the distinction.
///
/// `quiet_ms` is what the SERVER sorted by (`u64::MAX` for never), kept so the
/// console can say the order came from the wire rather than re-derive it.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct SilentTenantJson {
    #[serde(default)]
    pub tenant: String,
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub quiet_ms: u64,
    /// `None` = never seen inside the product's kept window.
    #[serde(default)]
    pub quiet_days: Option<u64>,
}

/// **One tenant that met a door** — a row of the fleet page's FRICTION list,
/// most refused first. `refused` is the fold of a DAILY counter over the
/// window, done by the server (`monetize_product::flow::window_total`); the
/// REASONS are not here on purpose, and the panel says where they are.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct FrictionTenantJson {
    #[serde(default)]
    pub tenant: String,
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub refused: u64,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TenantsJson {
    pub product: String,
    pub meters: Vec<MeterInfo>,
    pub tenants: Vec<TenantRow>,
    #[serde(default)]
    pub trend: Option<TrendJson>,
    #[serde(default)]
    pub fleet: FleetJson,
    #[serde(default)]
    pub sales: Vec<SaleJson>,
    /// The threshold the server applied, in days — on the wire because it is
    /// ON THE SCREEN ("no push and no fetch for 14 days"), and a caption the
    /// console invented could disagree with the list under it.
    #[serde(default)]
    pub silent_after_days: u64,
    /// Longest quiet first. Empty from a server older than the field, which
    /// draws the empty state rather than nothing.
    #[serde(default)]
    pub silent: Vec<SilentTenantJson>,
    /// **The tenants whose product reports NEITHER `last_*_unix_ms` meter**, by
    /// name — the ones nobody measured, deliberately kept out of `silent`.
    ///
    /// A separate field and not a flag on a [`SilentTenantJson`], because these
    /// are not rows of that list: they carry no `quiet_ms` to sort by and no
    /// `quiet_days` to draw, and a `Quiet::NoData` in `silent` would rank
    /// `u64::MAX` and sit above every measured finding. Empty from a server
    /// older than the field, which is the same as having nothing to report.
    ///
    /// [`crate::fmt::no_flow_data_line`] is the one reader.
    #[serde(default)]
    pub no_flow_data: Vec<String>,
    /// The window the refusals were counted over, in days. Same reason.
    #[serde(default)]
    pub friction_days: u64,
    /// Most refused first.
    #[serde(default)]
    pub friction: Vec<FrictionTenantJson>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct FactJson {
    pub tenant: String,
    /// The ORDER this verdict came from; an operator's label or empty for a
    /// hand-set fact.
    #[serde(default)]
    pub order: String,
    pub state: String,
    pub paid_until_unix_ms: Option<u64>,
    pub caps: BTreeMap<String, u64>,
    pub source: String,
    /// Verified, not merely present — see [`TenantRow::signed`].
    pub signed: bool,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RowJson {
    pub reference: String,
    pub seq: u32,
    pub at_unix_ms: u64,
    pub kind: String,
    pub json: String,
}

/// One thing the tenant is standing on. Shaped by the BFF (`api::HoldingJson`)
/// so the console and anything else reading that API describe a disk the same
/// way.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct HoldingJson {
    pub reference: String,
    /// `disk` | `server` | `compute` | `unknown`.
    pub kind: String,
    /// What it is, in words: `10 GiB on gunnar-se-sto1`.
    pub detail: String,
    /// `true` when monetize BOUGHT it and a purge would give it back; `false`
    /// when the pool already covered it — real capacity monetize does not own.
    pub provisioned: bool,
    /// `provider:id`, empty when on hand.
    pub id: String,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TenantDetailJson {
    pub product: String,
    pub tenant: String,
    pub meters: Vec<MeterInfo>,
    pub row: Option<TenantRow>,
    pub fact: Option<FactJson>,
    pub rows: Vec<RowJson>,
    pub references: Vec<String>,
    /// **This product's price lists**, one per market — what the order form
    /// prices a delta by, and what the USING words are read off. Empty is free.
    #[serde(default)]
    pub prices: Vec<PriceListJson>,
    #[serde(default)]
    pub policy: PolicyJson,
    /// The payment vendors THIS monetize has linked, so the order form offers
    /// only what a purchase could actually use.
    #[serde(default)]
    pub vendors: Vec<String>,
    /// **What the tenant is standing on** — the iron under the usage bars.
    #[serde(default)]
    pub holdings: Vec<HoldingJson>,
    /// This tenant's rate on the product's headline meter.
    #[serde(default)]
    pub trend: Option<TrendJson>,
    /// **The product has not acknowledged `fact`.** Absent when it has.
    #[serde(default)]
    pub pending_push: Option<PendingPushJson>,
}

/// A stored fact the product has not acknowledged: pushed again every poll.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct PendingPushJson {
    pub since_unix_ms: u64,
    pub attempts: u32,
    pub last_error: String,
}

/// **One UTC day of FLOW** — what MOVED that day, as against what is held.
///
/// `meters` is a map and not named fields for [`TenantRow::meters`]'s reason:
/// the names are the PRODUCT's, and a struct here would be one product's
/// vocabulary hard-coded into a shape the next product has to fit. gunnar's
/// day carries `bytes_in`, `bytes_out`, `pushes`, `fetches`, `lfs_bytes_in`,
/// `lfs_bytes_out`, `refused`, one `refused_*` key per reason that ACTUALLY
/// occurred, `auth_failures`, `distinct_principals` and the two
/// `last_*_unix_ms` instants.
///
/// **An absent meter is absent, never zero.** A reason that did not occur has
/// no key at all, and a meter the product stopped counting is not a day of
/// none — `fmt::day_bars` keeps the two apart, and nothing here defaults a
/// missing number into a measured one.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct ActivityDayJson {
    #[serde(default)]
    pub day_unix_ms: u64,
    #[serde(default)]
    pub meters: BTreeMap<String, u64>,
}

/// `GET /api/tenants/{product}/{tenant}/activity` — the tenant's last
/// `days_asked` days, day by day, straight from the product.
///
/// `retained_days` is what makes an EMPTY `days` readable: *nothing moved* and
/// *this box does not remember that far back* are the same empty list without
/// it. **0 means the product would not say**, which is a third answer and not
/// a memory of no days — see [`crate::fmt::activity_empty_line`].
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct ActivityJson {
    #[serde(default)]
    pub product: String,
    #[serde(default)]
    pub tenant: String,
    /// The window the panel's caption names.
    #[serde(default)]
    pub days_asked: u64,
    /// 0 = the product would not say how far back it remembers.
    #[serde(default)]
    pub retained_days: u64,
    /// Oldest first, as the product sent them. A day nobody measured is
    /// ABSENT from this list rather than present with zeroes.
    #[serde(default)]
    pub days: Vec<ActivityDayJson>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Attempt {
    pub reference: String,
    pub rows: Vec<RowJson>,
}

/// `GET /api/ledger`. Leads with `sales`; `recent` is the step-by-step
/// disclosure behind each line, `unfinished` the open attempts by reference.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LedgerJson {
    #[serde(default)]
    pub sales: Vec<SaleJson>,
    pub unfinished: Vec<Attempt>,
    pub recent: Vec<RowJson>,
}

/// **One PAID SIGNUP** — somebody who bought a plan on a vendor's hosted page
/// before they had an account here at all.
///
/// It is not a [`SaleJson`]: there is no ledger reference and no tenant, and
/// half of these are refusals that never became a sale. What the operator needs
/// off this row is "did the money turn into a person, and if not, why not".
///
/// **The contact address rides with the account**: the operator's console is
/// where somebody has to find whom to write to about it.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct SignupJson {
    /// The vendor instance the money went through (`stripe/gunnar-test2`).
    pub vendor: String,
    /// The vendor account's mode for this payment: live, test, or not said.
    #[serde(default)]
    pub livemode: Option<bool>,
    /// monetize found this payment by reading the vendor's own payments, not
    /// from the vendor's message at checkout.
    #[serde(default)]
    pub found_at_vendor: bool,
    /// The vendor's own handle for the checkout session (`cs_…`) — what an
    /// operator pastes into the vendor's dashboard to find the payment.
    pub session: String,
    /// `pending` | `created` | `refused` | `unknown`.
    pub state: String,
    /// The name the customer ASKED for, or the one they got. A refused row
    /// keeps the asked-for name: "the name was rejected" is unreadable without it.
    #[serde(default)]
    pub username: String,
    /// The buyer's contact address as the signup record holds it; empty when
    /// neither the console nor the vendor recorded one (or an older monetize).
    #[serde(default)]
    pub email: String,
    #[serde(default)]
    pub namespace: String,
    /// REFUSED only, monetize's own words. Shown verbatim — it is the answer to
    /// the customer, not a log line.
    #[serde(default)]
    pub reason: String,
    #[serde(default)]
    pub product: String,
    #[serde(default)]
    pub plan: String,
    #[serde(default)]
    pub amount_minor: u64,
    #[serde(default)]
    pub currency: String,
    #[serde(default)]
    pub paid_at_unix_ms: u64,
    /// When the webhook landed at monetize, which is what the list is ordered by.
    #[serde(default)]
    pub recorded_at_unix_ms: u64,
    #[serde(default)]
    pub decided_at_unix_ms: u64,
}

/// `GET /api/signups`.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct SignupsJson {
    #[serde(default)]
    pub signups: Vec<SignupJson>,
}

// ── Refused callbacks: a delivery monetize threw away ─────────────────────

/// One refused vendor callback, as `/api/callback-refusals` lists them.
///
/// **UNTRUSTED unless [`RefusalJson::verified`].** Nobody has to hold a
/// credential to POST to monetize's webhook, so a row here can be caused by
/// anyone on the internet; `detail` is monetize's sentence and may quote the
/// caller's own numbers back inside it. It is rendered as TEXT — Leptos escapes
/// text nodes — and nothing on this console looks anything up from it.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct RefusalJson {
    /// When MONETIZE received it, by monetize's clock. The incident these are
    /// for was a disagreement about the time, so a caller's is never used.
    #[serde(default)]
    pub at_unix_ms: u64,
    /// `unverified` | `not-understood` | `handle-mismatch`, monetize's word for
    /// its own refusal arm.
    #[serde(default)]
    pub refusal: String,
    /// The LINKED vendor whose endpoint was hit. Not a string the caller chose:
    /// a path naming no linked vendor is a 404 monetize records nowhere.
    #[serde(default)]
    pub vendor: String,
    /// **Whether the vendor's signature stood behind the delivery.** The bit
    /// that says whose words `detail` holds, and the reason the panel can carry
    /// a stranger's sentence without presenting it as monetize's.
    #[serde(default)]
    pub verified: bool,
    /// monetize's refusal, bounded and stripped by monetize before it is kept.
    #[serde(default)]
    pub detail: String,
    #[serde(default)]
    pub body_bytes: u64,
    /// The nth refusal since monetize started. Far above the rows listed means
    /// the ring dropped the rest.
    #[serde(default)]
    pub refusal_seq: u64,
}

/// `GET /api/callback-refusals`.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct RefusalsJson {
    #[serde(default)]
    pub refusals: Vec<RefusalJson>,
    /// Every refusal since monetize started, the ones the ring dropped
    /// included. The number a flood of forged callbacks shows up in.
    #[serde(default)]
    pub total_since_start: u64,
    #[serde(default)]
    pub kept: u64,
    #[serde(default)]
    pub capacity: u64,
    #[serde(default)]
    pub first_at_unix_ms: u64,
    #[serde(default)]
    pub last_at_unix_ms: u64,
}

// ── Paid at the vendor: money monetize had no row for ─────────────────────

/// One payment the VENDOR says it took that monetize had no row for, as
/// `/api/paid-at-vendor` lists them.
///
/// The vendor's word, unlike a refusal: monetize asked over its own key and
/// the vendor answered about its own object. `state` says what monetize did
/// with it — `settled` (handed to the signup engine; the outcome is the signup
/// row with the same `session`) or `needs-operator` (`reason` says what was
/// missing). No e-mail is on it; a console is not a mailing list.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct PaidAtVendorJson {
    #[serde(default)]
    pub vendor: String,
    /// The vendor's own session id — what an operator pastes into the
    /// vendor's dashboard to find the money.
    #[serde(default)]
    pub session: String,
    /// The vendor's reference for the payment itself (`pi_…`).
    #[serde(default)]
    pub reference: String,
    /// When the money moved, by the vendor's clock.
    #[serde(default)]
    pub paid_at_unix_ms: u64,
    #[serde(default)]
    pub amount_minor: u64,
    #[serde(default)]
    pub currency: String,
    #[serde(default)]
    pub tax_minor: u64,
    #[serde(default)]
    pub livemode: bool,
    /// The labels the vendor carried (`product`, `plan`, `username`,
    /// `reference`), verbatim.
    #[serde(default)]
    pub metadata: BTreeMap<String, String>,
    /// When monetize first saw it. What the list is ordered by.
    #[serde(default)]
    pub first_seen_unix_ms: u64,
    /// `settled` | `needs-operator`, monetize's own word.
    #[serde(default)]
    pub state: String,
    /// `needs-operator` only: monetize's sentence naming what was missing.
    #[serde(default)]
    pub reason: String,
    #[serde(default)]
    pub decided_at_unix_ms: u64,
}

/// `GET /api/paid-at-vendor`.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct PaidAtVendorListJson {
    #[serde(default)]
    pub cases: Vec<PaidAtVendorJson>,
    #[serde(default)]
    pub total: u64,
    /// Rows a person still has to act on. The health line's number.
    #[serde(default)]
    pub needs_operator: u64,
    #[serde(default)]
    pub settled: u64,
    /// When monetize last finished asking a vendor; 0 when it never has.
    #[serde(default)]
    pub last_reconcile_unix_ms: u64,
}

// ── The TAX page ──────────────────────────────────────────────────────

/// One reporting period, as `/api/tax/periods` lists them. `label` is the
/// period's name AND its key — the report is asked for by this string.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TaxPeriodJson {
    pub label: String,
    /// `monthly` | `quarterly` | `yearly`.
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub from_unix_ms: u64,
    /// EXCLUSIVE.
    #[serde(default)]
    pub to_unix_ms: u64,
}

/// `/api/tax/periods?year=…`.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TaxPeriodsJson {
    /// ISO 3166-1 alpha-2, or `—` when no jurisdiction is linked.
    #[serde(default)]
    pub jurisdiction: String,
    #[serde(default)]
    pub periods: Vec<TaxPeriodJson>,
    /// The net sales the period rule was applied to.
    #[serde(default)]
    pub turnover_minor: u64,
    #[serde(default)]
    pub currency: String,
    /// Which year `turnover_minor` covers — normally the one before the year
    /// being filed. On the wire because which year a rule looks at is the
    /// jurisdiction's business, not the console's.
    #[serde(default)]
    pub turnover_year: u32,
}

/// One box on the form. **`amount_minor` absent means `—`**: nobody computed
/// it. It is `Option` and not a defaulted `0` for exactly that reason — zero is
/// a real answer a form may demand.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TaxLineJson {
    pub box_id: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub amount_minor: Option<i64>,
    #[serde(default)]
    pub currency: String,
}

/// `/api/tax/report?year=…&period=…`.
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
pub struct TaxReportJson {
    #[serde(default)]
    pub jurisdiction: String,
    #[serde(default)]
    pub period: Option<TaxPeriodJson>,
    #[serde(default)]
    pub lines: Vec<TaxLineJson>,
    /// What the report could NOT do. Prose, shown under the table.
    #[serde(default)]
    pub notes: Vec<String>,
    /// The same lines as CSV, rendered by monetize's one writer — so the file
    /// an accountant gets and the table on the page cannot differ.
    #[serde(default)]
    pub csv: String,
}

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

    /// `spend` reads as the BFF sends it since 2026-09-14 (a list, one per
    /// cloud) and as it sent it before (one object, or null), so a page open
    /// across the deploy does not fail its next reload.
    #[test]
    fn spend_reads_as_a_list_and_as_the_one_object_it_used_to_be() {
        let base =
            |spend: &str| format!(r#"{{"products":[],"vendors":[],"clouds":[],"spend":{spend}}}"#);
        let many: ProductsJson =
            serde_json::from_str(&base(r#"[{"provider":"sandbox"},{"provider":"upcloud"}]"#))
                .unwrap();
        assert_eq!(
            many.spend
                .iter()
                .map(|s| s.provider.as_str())
                .collect::<Vec<_>>(),
            ["sandbox", "upcloud"]
        );
        let one: ProductsJson = serde_json::from_str(&base(r#"{"provider":"sandbox"}"#)).unwrap();
        assert_eq!(one.spend.len(), 1);
        let none: ProductsJson = serde_json::from_str(&base("null")).unwrap();
        assert!(none.spend.is_empty() && none.default_cloud.is_empty());
    }
}

/// **One offer from the shop window** — `/api/bundles`, which mirrors
/// `Purchase.Bundles`.
///
/// `price_words` is monetize's own rendering and is shown verbatim: the price
/// is decided and phrased in one place, and a console that re-renders minor
/// units is a second opinion about money. `can_serve` is the product's answer
/// (`can_absorb`) at the moment the window was read, and `reason` is the
/// customer's sentence for a no.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct BundleJson {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub price_words: String,
    #[serde(default)]
    pub period: String,
    #[serde(default)]
    pub delta: std::collections::BTreeMap<String, u64>,
    #[serde(default)]
    pub can_serve: bool,
    #[serde(default)]
    pub reason: String,
}

#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct BundlesJson {
    #[serde(default)]
    pub bundles: Vec<BundleJson>,
}

/// One estate this console administers (`/api/estates`): each keeps its own
/// monetize on its vm1, reached at `control` (`https://monetize.<apex>`).
/// `name`/`control` are `None` on a console of one estate.
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct EstateJson {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub control: Option<String>,
    #[serde(default)]
    pub serving: bool,
    #[serde(default)]
    pub products: Vec<String>,
    /// `None` when monetize could not be asked — which is not zero.
    #[serde(default)]
    pub paid_at_vendor_open: Option<u64>,
    #[serde(default)]
    pub error: Option<String>,
}

#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct EstatesJson {
    /// The estate this console's pages are showing now.
    #[serde(default)]
    pub current: Option<String>,
    #[serde(default)]
    pub estates: Vec<EstateJson>,
}