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>,
}