crates/monetize-ui-server/src/api.rs
//! The console's own HTTP API: monetize's answers, JOINED and shaped for a browser.
//!
//! Refusals keep monetize's words ([`Fail`]); the one thing this layer refuses
//! on its own is an order that caps a meter its product does not declare, and
//! it names the meter — the server refuses the same thing, so the two agree.
use std::collections::{BTreeMap, HashMap};
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use monetize_proto as pb;
use monetize_proto::query_ledger_request::By;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::app::AppState;
use crate::grpc::Fail;
pub struct ApiError(pub Fail);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.0.http_status(),
Json(json!({ "code": self.0.code, "message": self.0.message })),
)
.into_response()
}
}
impl From<tonic::Status> for ApiError {
fn from(s: tonic::Status) -> ApiError {
ApiError(s.into())
}
}
impl From<Fail> for ApiError {
fn from(f: Fail) -> ApiError {
ApiError(f)
}
}
type Res<T> = Result<Json<T>, ApiError>;
// ── Shapes ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MeterInfo {
/// `enforced` | `measured` | `provisioned` | `unknown` — whether a cap on
/// this meter is a wall the tenant hits. See `monetize_product::Enforcement`.
#[serde(default)]
pub enforcement: String,
/// `tenant` | `product` | `unknown` — WHERE the number is meaningful. The
/// second axis: `enforcement` says whether a cap is a wall, this says
/// whether one tenant's figure may be added to another's. A `product` meter
/// belongs on the product screen ONCE and in no per-tenant list.
#[serde(default)]
pub scope: String,
pub name: String,
pub meaning: 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 carries a non-zero number under this key in the tenant's
/// market; the console's `billed` / `not billed` words are read off that.
#[serde(default)]
pub unit: String,
}
fn meters(p: &pb::ProductInfo) -> Vec<MeterInfo> {
p.meters
.iter()
.map(|m| MeterInfo {
name: m.name.clone(),
meaning: m.meaning.clone(),
enforcement: enforcement_name(m.enforcement),
scope: scope_name(m.scope),
unit: m.unit.clone(),
})
.collect()
}
/// **What a resource unit costs the customer per month in one market**, as
/// the console reads it (`monetize::PriceList` on the wire). No list for a
/// product is free in every market; a unit absent from `units` is zero, and
/// zero is free.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PriceListJson {
pub product: String,
/// `SE` | `FI`.
pub market: String,
/// `SEK` | `EUR`.
pub currency: String,
/// Minor units per unit-month, keyed by `MeterInfo::unit`.
pub units: BTreeMap<String, u64>,
}
pub fn market_name(v: i32) -> &'static str {
match pb::Market::try_from(v) {
Ok(pb::Market::Fi) => "FI",
Ok(pb::Market::Dk) => "DK",
_ => "SE",
}
}
pub fn market_from_name(s: &str) -> Option<pb::Market> {
match s.to_ascii_uppercase().as_str() {
"SE" => Some(pb::Market::Se),
"FI" => Some(pb::Market::Fi),
"DK" => Some(pb::Market::Dk),
_ => None,
}
}
pub fn price_list_json(p: &pb::PriceList) -> PriceListJson {
PriceListJson {
product: p.product.clone(),
market: market_name(p.market).into(),
currency: currency_name(p.currency).into(),
units: p.units.clone(),
}
}
/// The deployment's one policy: the billing period and the ladder's days.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PolicyJson {
/// `month` | `year`.
pub period: String,
pub grace_days: u32,
pub retention_days: u32,
}
pub fn policy_json(p: Option<&pb::Policy>) -> PolicyJson {
match p {
Some(p) => PolicyJson {
period: p.period.clone(),
grace_days: p.grace_days,
retention_days: p.retention_days,
},
// A monetize too old to say: the console shows no period rather than a guessed one.
None => PolicyJson {
period: String::new(),
grace_days: 0,
retention_days: 0,
},
}
}
/// Where a meter's number is meaningful, as the console spells it.
///
/// An unrecognised value is `unknown` and NOT defaulted to `tenant`, for
/// `enforcement_name`'s reason with a sharper edge: defaulting to `tenant` would
/// invite a console to add up a figure the product never said was addable, which
/// is precisely the arithmetic-on-nothing this axis exists to stop.
fn scope_name(v: i32) -> String {
match pb::Scope::try_from(v) {
Ok(pb::Scope::Tenant) => "tenant".into(),
Ok(pb::Scope::Product) => "product".into(),
Ok(pb::Scope::Unspecified) | Err(_) => "unknown".into(),
}
}
/// The product's enforcement kind as the word the console shows beside a cap.
///
/// It matters because a cap that is a WALL and a cap that is a line item look
/// identical as a number, and the tenant cannot tell them apart from the
/// invoice. An unrecognised value is reported as unknown rather than defaulted
/// to "enforced", which would be the reassuring lie.
fn enforcement_name(v: i32) -> String {
match pb::Enforcement::try_from(v) {
Ok(pb::Enforcement::Enforced) => "enforced".into(),
Ok(pb::Enforcement::Measured) => "measured".into(),
Ok(pb::Enforcement::Provisioned) => "provisioned".into(),
Ok(pb::Enforcement::Unspecified) | Err(_) => "unknown".into(),
}
}
/// **The currency code beside every amount, from one writer.** `plan_json` and
/// `sale_json` both need it, and two copies of a three-arm match are two places
/// for a price to acquire the wrong unit.
///
/// An UNSPECIFIED currency reads as `SEK`, which is what this layer has always
/// done and is preserved here rather than quietly changed: on the wire a
/// `Money` always carries one (`monetize_pay::Currency` has no third arm), so the
/// fallback is only reachable from a malformed message. It is written down
/// because a default currency beside a real number is the shape of a bug, and
/// the next person should know it was a decision and not an oversight.
pub fn currency_name(v: i32) -> &'static str {
match pb::Currency::try_from(v) {
Ok(pb::Currency::Eur) => "EUR",
Ok(pb::Currency::Dkk) => "DKK",
_ => "SEK",
}
}
/// The ladder as the console spells it.
pub fn state_name(s: pb::State) -> &'static str {
match s {
pb::State::Unspecified => "unknown",
pb::State::Free => "free",
pb::State::Paid => "paid",
pb::State::Grace => "grace",
pb::State::Suspended => "suspended",
pb::State::Retention => "retention",
}
}
pub fn state_from_name(s: &str) -> Option<pb::State> {
Some(match s {
"free" => pb::State::Free,
"paid" => pb::State::Paid,
"grace" => pb::State::Grace,
"suspended" => pb::State::Suspended,
"retention" => pb::State::Retention,
_ => return None,
})
}
/// **Does this fact's signature actually verify against monetize's key?**
///
/// The console used to answer that with `!signature.is_empty()`, defended by a
/// true store invariant: monetize-server refuses to store a fact whose non-empty
/// signature does not verify, so a non-empty signature in the store IS a verified
/// one. True, and worthless as a badge — it reports THAT THE FIELD IS POPULATED.
/// Sixty-four bytes of anything turn it green, and every path that could put
/// wrong bytes there (a restore under a different key, a hand-edited store, a
/// future write path with a bug) is precisely what the badge exists to catch. A
/// green light that cannot go red is worse than no light, because an operator
/// believes it.
///
/// So the console fetches monetize's VERIFYING key (`Entitlement.PublicKey`) and
/// checks the Ed25519 signature itself, over the same canonical form the product
/// uses — `monetize_embed::signing`, linked, not re-derived, so the console and
/// every product can never disagree about what a signature covers.
pub struct Verifier(Option<monetize_embed::VerifyingKey>);
impl Verifier {
/// The key as monetize hands it over: 32 raw bytes. Anything else — wrong
/// length, not a point on the curve — yields a verifier that verifies
/// NOTHING, because the alternative is a console that shrugs and shows green.
pub fn from_bytes(key: &[u8]) -> Verifier {
Verifier(
<[u8; 32]>::try_from(key)
.ok()
.and_then(|b| monetize_embed::VerifyingKey::from_bytes(&b).ok()),
)
}
/// A verifier that holds no key and therefore verifies nothing. Used where a
/// caller has facts but no key — the answer there is "not verified", never
/// "signed".
pub fn none() -> Verifier {
Verifier(None)
}
pub fn verifies(&self, f: &pb::EntitlementFact) -> bool {
let Some(key) = &self.0 else { return false };
monetize_embed::signing::verify_fact(&fact_from_pb(f), key).is_ok()
}
}
/// The wire fact as the Rust type its signature is computed over. A field copy,
/// not a second canonical form: the bytes signed are still
/// `monetize_embed::signing::fact_message`'s and nobody else's.
fn fact_from_pb(f: &pb::EntitlementFact) -> monetize_embed::EntitlementFact {
monetize_embed::EntitlementFact {
tenant: monetize_embed::TenantId(f.tenant.clone()),
plan: f.plan.clone(),
state: match f.state() {
pb::State::Paid => monetize_embed::State::Paid,
pb::State::Grace => monetize_embed::State::Grace,
pb::State::Suspended => monetize_embed::State::Suspended,
pb::State::Retention => monetize_embed::State::Retention,
pb::State::Free | pb::State::Unspecified => monetize_embed::State::Free,
},
paid_until_unix_ms: f.paid_until_unix_ms,
caps: f.caps.clone(),
source: f.source.clone(),
signature: f.signature.clone(),
issued_unix_ms: f.issued_unix_ms,
issued_signature: f.issued_signature.clone(),
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FactJson {
pub tenant: String,
/// **The order this verdict came from** — the ledger reference for a
/// purchase, an operator's label or empty for a hand-set fact. The wire
/// field is still `plan` (it is in the signed form gunnar verifies); the
/// console never says that word.
pub order: String,
pub state: &'static str,
pub paid_until_unix_ms: Option<u64>,
pub caps: BTreeMap<String, u64>,
pub source: String,
/// **The signature was CHECKED against monetize's verifying key and passed.**
/// Not "the field is non-empty" — see [`Verifier`]. False covers every way
/// that check can fail, including monetize declining to name its key, because
/// "we could not verify" and "it does not verify" are the same thing to
/// anyone relying on the badge.
pub signed: bool,
}
pub fn fact_json(f: &pb::EntitlementFact, v: &Verifier) -> FactJson {
FactJson {
tenant: f.tenant.clone(),
order: f.plan.clone(),
state: state_name(f.state()),
paid_until_unix_ms: f.paid_until_unix_ms,
caps: f.caps.clone(),
source: f.source.clone(),
signed: v.verifies(f),
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TenantRow {
pub tenant: String,
/// The order the tenant's fact came from; empty for a hand-set or absent fact.
pub order: String,
pub state: &'static str,
pub paid_until_unix_ms: Option<u64>,
pub source: String,
/// The fact's signature verified against monetize's key. See [`Verifier`].
pub signed: bool,
pub meters: BTreeMap<String, u64>,
/// `None` until the poller has read the tenant once.
pub measured_at_unix_ms: Option<u64>,
/// **What the tenant is ALLOWED** — the caps their signed fact names — so
/// a list can be sorted by who is closest to their cap (`docs/v2/07-console.md` rule 5)
/// without a round trip per row. Empty when there is no fact or the fact
/// names none: the product's own default, which monetize does not know and
/// the console must not render as zero.
pub caps: BTreeMap<String, u64>,
}
/// One row per tenant, from the two lists monetize keeps apart. A tenant with
/// usage and no fact is Free (the product's own default); a fact with no usage
/// is a tenant the poller has not reached yet.
pub fn join_tenants(
usages: &[pb::UsageReport],
facts: &[pb::EntitlementFact],
v: &Verifier,
) -> Vec<TenantRow> {
let mut rows: BTreeMap<String, TenantRow> = BTreeMap::new();
let blank = |tenant: &str| TenantRow {
tenant: tenant.into(),
order: String::new(),
state: "free",
paid_until_unix_ms: None,
source: String::new(),
signed: false,
meters: BTreeMap::new(),
measured_at_unix_ms: None,
caps: BTreeMap::new(),
};
for f in facts {
let j = fact_json(f, v);
rows.insert(
f.tenant.clone(),
TenantRow {
order: j.order,
state: j.state,
paid_until_unix_ms: j.paid_until_unix_ms,
source: j.source,
signed: j.signed,
caps: j.caps,
..blank(&f.tenant)
},
);
}
for u in usages {
let row = rows
.entry(u.tenant.clone())
.or_insert_with(|| blank(&u.tenant));
row.meters = u.meters.iter().map(|m| (m.name.clone(), m.value)).collect();
row.measured_at_unix_ms = Some(u.measured_at_unix_ms);
}
rows.into_values().collect()
}
/// An order may only cap a meter its product declares. The message names the meter.
pub fn validate_caps(
product: &str,
declared: &[MeterInfo],
caps: &BTreeMap<String, u64>,
) -> Result<(), String> {
match caps
.keys()
.find(|k| !declared.iter().any(|m| &m.name == *k))
{
Some(m) => Err(format!(
"the order caps meter {m:?}, which product {product} does not declare"
)),
None => Ok(()),
}
}
/// **`caps=meter=value,meter=value` on a query string**, the way `/api/quote`
/// takes an order. Bytes are bytes; no unit suffix is parsed here — the
/// console does that arithmetic where the operator can see it.
pub fn parse_caps_query(s: &str) -> Result<BTreeMap<String, u64>, String> {
let mut out = BTreeMap::new();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = part
.split_once('=')
.ok_or_else(|| format!("caps entry {part:?} is not meter=value"))?;
let n: u64 = v
.trim()
.parse()
.map_err(|_| format!("caps entry {part:?}: {v:?} is not a whole number"))?;
out.insert(k.trim().to_owned(), n);
}
Ok(out)
}
/// **One thing a tenant is standing on**, as the console renders it.
///
/// `kind` and `detail` are computed HERE rather than in the browser, so the
/// console and anything else reading this API describe a disk the same way.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HoldingJson {
/// The purchase that granted it.
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 (`Ensured::OnHand`) — real
/// capacity the tenant has, that monetize does not own and must not return.
pub provisioned: bool,
/// `provider:id` when bought, empty when on hand.
pub id: String,
}
/// **What a cloud resource IS, in words** — the console's ONE vocabulary for
/// iron, used both for what a quote would buy ([`quote_json`]) and for what a
/// tenant is already standing on ([`holding_json`]). A second phrasing for the
/// second case would let the same disk read differently on two pages.
pub fn resource_words(r: Option<&pb::Resource>) -> (&'static str, String) {
use pb::resource::Kind;
match r.and_then(|r| r.kind.as_ref()) {
Some(Kind::Disk(d)) => ("disk", format!("{} GiB on {}", d.gib, d.server)),
Some(Kind::Server(s)) => (
"server",
format!(
"{} in {} ({})",
s.plan,
s.zone,
if s.role() == pb::ServerRole::Replica {
"replica"
} else {
"primary"
}
),
),
Some(Kind::Compute(c)) => ("compute", format!("{} on {}", c.plan, c.server)),
// A resource kind this console does not know is reported AS unknown.
// Rendering it as a disk would be a guess about iron somebody is paying
// for, which is the one place a guess is worst.
None => (
"unknown",
"a resource this console does not recognise".to_owned(),
),
}
}
pub fn holding_json(h: &pb::Holding) -> HoldingJson {
let (kind, detail) = resource_words(h.resource.as_ref());
HoldingJson {
reference: h.reference.clone(),
kind: kind.to_owned(),
detail,
provisioned: !h.id.is_empty(),
id: if h.id.is_empty() {
String::new()
} else {
format!("{}:{}", h.provider, h.id)
},
}
}
/// **The dot on `docs/v2/07-console.md` screen 1: does this product answer?**
///
/// It is the SERVER's last observation, taken by the usage poller, and it is
/// never a call made while this page loads. Two bounds, both needed: the page
/// reads a cache and so cannot hang at all, and the poller's own call carries a
/// per-call deadline (`GunnarProduct::CALL_TIMEOUT`) so a product that accepts
/// a connection and never answers cannot freeze the dot at its last value.
///
/// `checked_at_unix_ms` is on the row deliberately: a cached answer is only
/// honest if the reader can see how old it is.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ReachabilityJson {
/// `unknown` | `up` | `down`. **UNKNOWN IS NOT DOWN.** A server that has not
/// finished its first poll tick has asked nobody, and painting that as
/// either reachable or unreachable is a claim nobody made.
pub state: String,
/// When the state was established. 0 = never asked; the console says so
/// rather than showing a stale-looking dot.
pub checked_at_unix_ms: u64,
/// The last time this product answered at all, so a console can say how long
/// a down product has been down. 0 = never.
pub last_up_unix_ms: u64,
/// The plugin's own words when `state` is `down` — `docs/v2/07-console.md`'s `not
/// connected` row, with the reason behind it.
pub detail: String,
}
pub fn reachability_json(r: Option<&pb::Reachability>) -> ReachabilityJson {
match r {
Some(r) => ReachabilityJson {
state: r.state.clone(),
checked_at_unix_ms: r.checked_at_unix_ms,
last_up_unix_ms: r.last_up_unix_ms,
detail: r.detail.clone(),
},
// A monetize too old to report it has told us nothing, which is
// `unknown` — not `up`, which would be a green dot this console invented.
None => ReachabilityJson {
state: "unknown".into(),
checked_at_unix_ms: 0,
last_up_unix_ms: 0,
detail: "this monetize does not report product reachability".into(),
},
}
}
/// **A rate, shaped for the console — including the arm that has no rate.**
///
/// `known = false` is NOT a rate of zero. It is `monetize::trend`'s
/// "not enough history", and the console must render it as *not enough history
/// yet* rather than as a flat line. `per_week` is `None` in that arm precisely so
/// a template that forgets to check `known` renders nothing rather than a zero.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct TrendJson {
/// The meter the rate is OF. On the surface, never inferred by the reader.
pub meter: String,
/// False = not enough history. See the type doc.
pub known: bool,
/// The meter's units per week, signed. `None` when `known` is false —
/// absent, not zero.
pub per_week: Option<f64>,
/// `firm` | `approximate` | `bursty`, empty when `known` is false.
/// **`bursty` forbids rendering a projected date at all** (`docs/v2/07-console.md` rule 4).
pub confidence: String,
/// `""` for a firm figure, `"~"` otherwise — the marker `docs/v2/07-console.md` rule 4 asks a
/// projection to carry, computed once here so four screens cannot disagree.
pub marker: &'static str,
/// May the console turn this into "full in N weeks"? False for `bursty`.
pub may_project: bool,
pub largest_step_share: f64,
pub readings: u64,
pub span_ms: u64,
/// The span a weekly rate needs, so the console can say how much longer.
pub needed_span_ms: u64,
/// The window actually applied (`last 30 d` on the screen).
pub window_ms: u64,
/// FLEET only: tenants that could be measured, and tenants that could not.
/// **`unmeasured_tenants > 0` makes `per_week` a floor, not a total** — the
/// missing tenants are unknown and are never folded in as zero.
pub measured_tenants: u64,
pub unmeasured_tenants: u64,
}
pub fn trend_json(meter: &str, t: &pb::TrendResponse) -> TrendJson {
TrendJson {
meter: meter.to_owned(),
known: t.enough_history,
per_week: t.enough_history.then_some(t.per_week),
confidence: t.confidence.clone(),
marker: match t.confidence.as_str() {
"firm" => "",
_ => "~",
},
may_project: t.confidence == "firm" || t.confidence == "approximate",
largest_step_share: t.largest_step_share,
readings: t.readings,
span_ms: t.span_ms,
needed_span_ms: t.needed_span_ms,
window_ms: t.window_ms,
measured_tenants: t.measured_tenants,
unmeasured_tenants: t.unmeasured_tenants,
}
}
/// **One meter's fleet total and its oversell number**, shaped for the console.
///
/// `oversell` is read BEFORE `oversell_bytes`: `unmeasured` and `not_storage`
/// carry no bytes, and a reader that took the number alone would see a confident
/// zero where there is no measurement at all.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FleetMeterJson {
pub meter: String,
pub tenants: u64,
/// The sum of the latest reading, over tenants that have one.
pub used: u64,
/// Tenants with no reading of this meter — above zero makes `used` a floor.
pub tenants_without_reading: u64,
/// The sum of the caps the tenants' signed facts name.
pub allowance: u64,
/// Tenants whose fact names no cap here (the PRODUCT's own default, which
/// monetize does not know) — above zero makes `allowance` a floor.
pub tenants_without_cap: u64,
/// `oversold` | `fits` | `unmeasured` | `not_storage`.
pub oversell: String,
/// Bytes oversold, or bytes of headroom. Meaningless for the other two arms.
pub oversell_bytes: u64,
/// The product's own reason, when `oversell` is `unmeasured`.
pub oversell_reason: String,
/// `used + servable` — what this fleet could grow to. **`None` when the
/// product could not measure**: a bar with an invented denominator is worse
/// than no bar.
pub capacity: Option<u64>,
/// Any number here is a floor rather than a total. The console must say so.
pub is_a_floor: bool,
}
/// **The fleet: what a product's tenants hold, what they have been promised, and
/// what the product says it can serve.**
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FleetJson {
/// One entry per PER-TENANT meter. A `scope: product` meter is absent —
/// there is nothing to add up.
pub meters: Vec<FleetMeterJson>,
/// False covers both silences: a product that could not be asked, and one
/// that answered "I could not measure". `servable_reason` says which.
pub servable_measured: bool,
pub servable_bytes: u64,
pub total_bytes: u64,
pub servable_reason: String,
/// When the POLLER last took this answer. 0 = never asked. It is deliberately
/// not taken during this request: a page must not hang on the slowest box in
/// the estate.
pub servable_at_unix_ms: u64,
/// **Whether the store can ever hold more** — `docs/v2/07-console.md` screen 2's `⚠ cannot
/// grow`, and the paragraph behind it.
pub growth: GrowthJson,
/// **CLOUD: what we own, what it costs, orphans/missing** — the server's
/// `CloudInventory`, field for field (`usage.proto`). `available: false`
/// with `error` when the provider could not be listed; the lists are then
/// empty because nobody asked, not because nothing is there.
pub cloud: Value,
/// PROD-4: the read did not come within the page's bound; this is its answer this many
/// seconds old (the live one loads in on the next look).
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_secs: Option<u64>,
}
/// The growth fact as the console reads it. ONE field on the fleet, two words
/// in it: `state` is what the badge keys on, `detail` is what it holds.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GrowthJson {
/// `sealed` | `operator_only` | `unknown`, or `""` when the product could
/// not be measured at all — which is not `unknown`: nobody asked.
pub state: String,
/// The product's own remediation sentence, verbatim (gunnar's
/// `remediation()`). Empty with an empty `state`.
pub detail: String,
}
pub fn fleet_json(f: &pb::FleetResponse) -> FleetJson {
FleetJson {
stale_secs: None,
meters: f
.meters
.iter()
.map(|m| FleetMeterJson {
meter: m.meter.clone(),
tenants: m.tenants,
used: m.used,
tenants_without_reading: m.tenants_without_reading,
allowance: m.allowance,
tenants_without_cap: m.tenants_without_cap,
oversell: m.oversell.clone(),
oversell_bytes: m.oversell_bytes,
oversell_reason: m.oversell_reason.clone(),
capacity: m.capacity,
is_a_floor: m.is_a_floor,
})
.collect(),
servable_measured: f.servable_measured,
servable_bytes: f.servable_bytes,
total_bytes: f.total_bytes,
servable_reason: f.servable_reason.clone(),
servable_at_unix_ms: f.servable_at_unix_ms,
growth: GrowthJson {
state: f.growth.clone(),
detail: f.growth_detail.clone(),
},
cloud: match &f.cloud {
Some(c) => {
let owned = |o: &pb::OwnedResource| json!({ "provider": o.provider, "id": o.id, "kind": o.kind, "gib": o.gib, "zone": o.zone, "attached_to": o.attached_to, "reference": o.reference, "tenant": o.tenant, "priced": o.priced, "monthly_minor": o.monthly_minor, "provenance": o.provenance, "estate": o.estate, "resource": o.resource, "title": o.title });
json!({
"provider": c.provider, "available": c.available, "error": c.error,
"owned": c.owned.iter().map(owned).collect::<Vec<_>>(),
"orphans": c.orphans.iter().map(owned).collect::<Vec<_>>(),
"missing": c.missing.iter().map(|m| json!({ "provider": m.provider, "id": m.id, "reference": m.reference, "tenant": m.tenant, "resource": m.resource, "estate": m.estate })).collect::<Vec<_>>(),
"foreign_count": c.foreign_count, "total_bytes": c.total_bytes, "monthly_minor": c.monthly_minor, "unpriced": c.unpriced,
"currency": c.currency,
"account": c.account,
"backups": c.backups.iter().map(|b| json!({ "provider": b.provider, "id": b.id, "gib": b.gib, "zone": b.zone, "origin": b.origin, "server": b.server, "created": b.created, "title": b.title, "estate": b.estate, "reference": b.reference, "words": b.words })).collect::<Vec<_>>(),
"backup_bytes": c.backup_bytes,
})
}
None => {
json!({ "provider": "", "available": false, "error": "the server sent no cloud block", "owned": [], "orphans": [], "missing": [], "foreign_count": 0, "total_bytes": 0, "monthly_minor": 0, "unpriced": 0, "currency": "", "account": "", "backups": [], "backup_bytes": 0 })
}
},
}
}
async fn fleet_of(state: &AppState, product: &str) -> Result<FleetJson, ApiError> {
// PROD-4: the cloud is listed within the page's bound, else its last listing, aged.
let mut u = state.monetize.usage();
let p = product.to_owned();
let call = async move {
u.fleet(pb::FleetRequest { product: p })
.await
.map(|r| r.into_inner())
};
let (r, stale) =
crate::bounded::within(&state.monetize.clouds_seen, product, "the cloud", call).await?;
Ok(FleetJson {
stale_secs: stale,
..fleet_json(&r)
})
}
/// **The meter a product screen leads with**: the product's first `enforced`
/// meter — the first cap that is a WALL, which is the number an operator runs out
/// of. gunnar's is `pack_bytes`.
///
/// `None` when a product declares no wall at all (holger today, which polices
/// nothing). A product with no wall gets no headline rate rather than a rate of
/// whichever meter happened to sort first, because a rate whose meter nobody
/// chose is a number nobody can check.
pub fn primary_meter(p: &pb::ProductInfo) -> Option<String> {
p.meters
.iter()
.find(|m| enforcement_name(m.enforcement) == "enforced")
.map(|m| m.name.clone())
}
/// One rate from monetize, for `tenant` (empty = the product's fleet). `None`
/// when the product declares no meter to lead with — the console then shows no
/// trend line at all, which is honest, rather than a zero.
async fn trend_of(
state: &AppState,
product: &str,
tenant: &str,
meter: Option<String>,
) -> Result<Option<TrendJson>, ApiError> {
let Some(meter) = meter else { return Ok(None) };
let r = state
.monetize
.usage()
.trend(pb::TrendRequest {
product: product.to_owned(),
tenant: tenant.to_owned(),
meter: meter.clone(),
window_ms: 0,
})
.await?
.into_inner();
Ok(Some(trend_json(&meter, &r)))
}
/// **One attempt as one line** — `docs/v2/07-console.md` rule 6, outcomes not steps.
///
/// `Sold bob 10 GiB · 220.00 SEK ✓` is this struct; the six ledger rows are
/// still reachable behind the `reference`, which is what a disclosure opens.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SaleJson {
/// The ledger key — what a disclosure fetches the steps with.
pub reference: String,
pub product: String,
pub tenant: String,
/// Minor units, as everything about money in this system is. Never a float.
pub price_minor: u64,
pub currency: &'static str,
/// **What the provider charges us** for what this sale bought, as quoted
/// on the same ledger row — beside the price, so a margin is a fold.
pub cost_minor: u64,
pub cost_currency: &'static str,
/// **What the payment vendor kept.** `null` is "no vendor reported one",
/// which the console must not render as a free sale — see `SaleJson::fee` in
/// the engine. The net is not sent: it is `price_minor − fee_minor`.
pub fee_minor: Option<u64>,
pub fee_currency: &'static str,
/// **The VAT this order carries**, minor units; `null` when the attempt
/// charged nothing (declined). The vendor's figure when `tax_from_vendor`,
/// else the recorded rate on the net price.
pub tax_minor: Option<u64>,
pub tax_currency: &'static str,
pub tax_from_vendor: bool,
/// The rate, basis points (2500 = 25 %): the vendor's for the buyer's
/// country when it said, else the one recorded at sale time.
pub vat_basis_points: u32,
/// **The buyer's country** as the vendor returned it for this payment
/// (owner 2026-10-11: Stripe decides per country, each payment is a row).
/// Empty: the vendor did not say.
pub customer_country: String,
/// What the tenant is allowed after the sale — the full caps.
pub caps: BTreeMap<String, u64>,
/// **What was SOLD** — the delta over what they held. `Sold bob 90 GiB`.
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,
/// Ledger rows behind this line — the disclosure's badge.
pub steps: u32,
/// Money was actually captured at some point. Cuts across `outcome`: a
/// released reservation and an abandoned attempt are both `refunded` and only
/// the second moved money.
pub drawn: bool,
/// `granted` | `refunded` | `declined` | `open`. **`declined` is not a
/// refund** — nothing was locked, so rendering it as one invents a credit.
pub outcome: String,
pub reason: String,
/// **The only row that is actually a task.** `docs/v2/07-console.md` floats these to NEEDS
/// YOU; everything else is history.
pub needs_you: bool,
}
pub fn sale_json(s: &pb::Sale) -> SaleJson {
SaleJson {
reference: s.reference.clone(),
product: s.product.clone(),
tenant: s.tenant.clone(),
price_minor: s.price.as_ref().map(|m| m.minor).unwrap_or(0),
currency: currency_name(s.price.as_ref().map(|m| m.currency).unwrap_or(0)),
cost_minor: s.cost.as_ref().map(|m| m.minor).unwrap_or(0),
cost_currency: currency_name(s.cost.as_ref().map(|m| m.currency).unwrap_or(0)),
fee_minor: s.fee.as_ref().map(|m| m.minor),
fee_currency: currency_name(s.fee.as_ref().map(|m| m.currency).unwrap_or(0)),
tax_minor: s.tax.as_ref().map(|m| m.minor),
tax_currency: currency_name(s.tax.as_ref().map(|m| m.currency).unwrap_or(0)),
tax_from_vendor: s.tax_from_vendor,
vat_basis_points: s.vat_basis_points,
customer_country: s.customer_country.clone(),
caps: s.caps.clone(),
delta: s.delta.clone(),
started_unix_ms: s.started_unix_ms,
at_unix_ms: s.at_unix_ms,
steps: s.steps,
drawn: s.drawn,
outcome: s.outcome.clone(),
reason: s.reason.clone(),
needs_you: s.needs_you,
}
}
/// **The spend against one cloud's ceiling** — `docs/v2/07-console.md` screen 1's last lines.
/// Top level on `/api/products`, not per product: the ceiling is per cloud, so
/// the route carries a list with one entry per cloud linked.
/// The two limits are `Option` because an unset limit is NOT zero (zero means
/// refuse every purchase), and both `None` is the one dangerous state the
/// console must say in words.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SpendJson {
pub provider: String,
pub currency: String,
/// Committed EVER, per the ledger's fold — a lifetime total.
pub spent_minor: u64,
pub spent_this_month_minor: Option<u64>,
pub ceiling_total_minor: Option<u64>,
pub ceiling_monthly_minor: Option<u64>,
}
pub fn spend_json(s: &pb::CloudSpend) -> SpendJson {
SpendJson {
provider: s.provider.clone(),
currency: s.currency.clone(),
spent_minor: s.spent_minor,
spent_this_month_minor: s.spent_this_month_minor,
ceiling_total_minor: s.ceiling_total_minor,
ceiling_monthly_minor: s.ceiling_monthly_minor,
}
}
async fn sales_of(state: &AppState, product: &str, limit: u32) -> Result<Vec<SaleJson>, ApiError> {
let r = state
.monetize
.ledger()
.sales(pb::SalesRequest {
product: product.to_owned(),
limit,
})
.await?
.into_inner();
Ok(r.sales.iter().map(sale_json).collect())
}
/// How many folded attempts a "recently bought" list shows. `docs/v2/07-console.md` screen 2
/// shows two and a link; this is the disclosure's worth, not the page's.
pub const RECENT_SALES: u32 = 50;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RowJson {
pub reference: String,
pub seq: u32,
pub at_unix_ms: u64,
pub kind: String,
pub json: String,
}
fn row_json(r: &pb::LedgerRow) -> RowJson {
RowJson {
reference: r.reference.clone(),
seq: r.seq,
at_unix_ms: r.at_unix_ms,
kind: r.kind.clone(),
json: r.json.clone(),
}
}
// ── Handlers ──────────────────────────────────────────────────────────
/// **The site a money panel is for**: `?estate=<label>`. Absent: every row.
#[derive(serde::Deserialize, Default)]
pub struct SiteQuery {
#[serde(default)]
pub estate: String,
}
/// **The site's own vendor instance** (`[estates.<label>] vendor`, e.g.
/// `stripe/gunnar-test2`): `None` when no site was named (every row), and
/// `Some("")` for a site this monetize sells through no vendor for (no row).
///
/// Owner 2026-10-12: test2's Money page listed gunnar.rs's `stripe/gunnar`
/// sessions — the money panels took no site at all and listed the whole ONE.
pub async fn site_vendor(state: &AppState, estate: &str) -> Result<Option<String>, ApiError> {
if estate.is_empty() {
return Ok(None);
}
Ok(Some(
list_products(state)
.await?
.estate_vendors
.get(estate)
.cloned()
.unwrap_or_default(),
))
}
/// Is a row of vendor instance `vendor` this site's? Every row when no site is named.
pub fn on_site(vendor: &str, site: &Option<String>) -> bool {
match site {
None => true,
Some(v) => !v.is_empty() && v == vendor,
}
}
async fn list_products(state: &AppState) -> Result<pb::ListProductsResponse, ApiError> {
Ok(state
.monetize
.products()
.list(pb::ListProductsRequest {})
.await?
.into_inner())
}
/// **monetize's verifying key, fetched fresh.**
///
/// Not cached, on purpose and consistently with the rest of this layer (see
/// `grpc`: nothing is cached, monetize's reads are local redb lookups). A cached
/// key is a key that keeps verifying facts after a rotation, and the whole point
/// of the badge is that it tells the truth about the CURRENT key.
///
/// A failure PROPAGATES rather than degrading to `Verifier::none()`. If monetize
/// cannot say which key it signs with, the console cannot honestly render
/// signature status, and quietly painting every fact "not verified" would be the
/// same reassuring-lie mistake as the old badge with the colours swapped.
///
/// **Per product (G8)**: each product's facts are signed with that product's
/// key, so the badge asks for the key of the product it is painting.
async fn verifier(state: &AppState, product: &str) -> Result<Verifier, ApiError> {
let key = state
.monetize
.entitlement()
.public_key(pb::PublicKeyRequest {
product: product.to_string(),
})
.await?
.into_inner();
Ok(Verifier::from_bytes(&key.public_key))
}
async fn product_info(state: &AppState, id: &str) -> Result<pb::ProductInfo, ApiError> {
list_products(state)
.await?
.products
.into_iter()
.find(|p| p.id == id)
.ok_or_else(|| {
ApiError(Fail {
code: "notfound".into(),
message: format!("product {id:?} is not served by monetize"),
})
})
}
/// `GET /api/health` — is monetize answering, and with what.
pub async fn health(State(state): State<AppState>) -> Json<Value> {
match list_products(&state).await {
Ok(r) => Json(json!({
"serving": true,
"products": r.products.iter().map(|p| p.id.clone()).collect::<Vec<_>>(),
"vendors": r.vendors,
"clouds": r.clouds,
"console": crate::VERSION_LINE,
// Money at the vendor that nobody has placed. On the health line
// because it is the one number that must be seen without opening
// Activity; `null` when monetize could not be asked, which is not
// zero.
"paid_at_vendor_open": paid_at_vendor_open(&state).await,
})),
Err(e) => {
Json(json!({ "serving": false, "error": e.0.message, "console": crate::VERSION_LINE }))
}
}
}
/// **`GET /api/estates`** — every estate the ONE monetize's FLEET knows
/// ([`crate::estates::read`], one call to the ONE): name, product, the boxes
/// heard calling home, `serving` when one called within the guardian's
/// bound, and the pull model's words when none did. Nobody is dialled: an
/// estate has no control to dial (ONE monetize, owner 2026-10-10).
pub async fn estates(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let fleet = crate::estates::read(&state.monetize).await?;
Ok(Json(json!({ "estates": fleet })))
}
/// `GET /api/products` — one card per product with tenant counts by state.
pub async fn products(State(state): State<AppState>) -> Res<Value> {
let listed = list_products(&state).await?;
// RA3-1: every card's fleet read AT ONCE — each is bounded by the page rule (2 s), and read
// one after another N products waited N bounds on a stuck cloud listing.
let mut fleets =
futures_util::future::join_all(listed.products.iter().map(|p| fleet_of(&state, &p.id)))
.await
.into_iter();
let mut cards = Vec::new();
for p in &listed.products {
let verifier = verifier(&state, &p.id).await?;
let usages = state
.monetize
.usage()
.read_all(pb::ReadAllUsageRequest {
product: p.id.clone(),
})
.await?
.into_inner()
.usages;
let facts = state
.monetize
.entitlement()
.list(pb::ListEntitlementsRequest {
product: p.id.clone(),
})
.await?
.into_inner()
.facts;
let rows = join_tenants(&usages, &facts, &verifier);
let mut by_state: BTreeMap<&'static str, usize> = BTreeMap::new();
for r in &rows {
*by_state.entry(r.state).or_default() += 1;
}
// The fleet rate for this product's headline meter. One extra call per
// product, on the SERVER's stored series — the console never folds it
// itself (`docs/v2/06-data-model.md` §6.1: no measurement taken by the console).
let trend = trend_of(&state, &p.id, "", primary_meter(p)).await?;
// `184 / 300 GiB` on `docs/v2/07-console.md` screen 1, and the oversell behind it.
// A card prints the account's total; its rows are the operator's Iron page's (MAP-SEG 1).
let mut fleet = fleets.next().expect("one fleet read per product")?;
fleet.cloud = crate::iron::card_cloud(&fleet.cloud);
cards.push(json!({
"id": p.id, "meters": meters(p), "tenants": rows.len(), "by_state": by_state,
"trend": trend, "fleet": fleet,
// The dot. `docs/v2/07-console.md` screen 1.
"reachable": reachability_json(p.reachable.as_ref()),
// **And whether the PAID DOOR is there**, which the dot cannot say:
// a community appliance is perfectly reachable and cannot enforce a
// single entitlement. `unproven` is carried through as itself —
// folding it into either answer would make the claim the probe
// exists to test.
"metered_door": json!({
"state": p.metered_door.as_ref().map(|d| d.state.clone()).unwrap_or_else(|| "unproven".into()),
"checked_at_unix_ms": p.metered_door.as_ref().map(|d| d.checked_at_unix_ms).unwrap_or(0),
"detail": p.metered_door.as_ref().map(|d| d.detail.clone()).unwrap_or_else(|| "this monetize does not report a metered door".into()),
}),
}));
}
// The spend lines: EVERY cloud's, never only the first. Until 2026-09-14
// this took `spend.first()`, and with `sandbox` and `upcloud` both linked
// the page showed the sandbox's 10 EUR ceiling while the cloud that spends
// real money was not on the screen at all. Empty on a box without a cloud,
// and the console then draws no line rather than a cap of nothing.
// `default_cloud` is the cloud a sale provisions on, empty when none is.
let spend: Vec<SpendJson> = listed.spend.iter().map(spend_json).collect();
Ok(Json(json!({
"products": cards, "vendors": listed.vendors, "clouds": listed.clouds, "spend": spend, "default_cloud": listed.default_cloud,
// THE TERMS: what a unit costs the customer per product per market (no
// list = free), the one policy, and where each product's disks attach.
"prices": listed.prices.iter().map(price_list_json).collect::<Vec<_>>(),
"policy": policy_json(listed.policy.as_ref()),
"hosts": listed.hosts,
})))
}
/// **How long a tenant must have been unseen before the console calls it
/// silent: 14 days.**
///
/// Two weeks, so an ordinary holiday is not an alarm and a customer who has
/// actually stopped is one. The same number gunnar's own dashboard band uses;
/// it is a constant and not a setting because a threshold an operator can tune
/// is a threshold two surfaces will disagree about.
pub const SILENT_AFTER_DAYS: u64 = 14;
/// **How far back the FRICTION list looks: 7 days.**
///
/// A week, because friction is a thing an operator acts on THIS week — a
/// customer who was refused twice last month and not since has a story that
/// has already ended, and putting them on a task list is how a task list comes
/// to be ignored.
pub const FRICTION_DAYS: u64 = 7;
/// `?estate=` on [`tenants`]: the estate whose page asks (MAP-SEG 1).
#[derive(Debug, Default, Deserialize)]
pub struct EstateQuery {
#[serde(default)]
pub estate: String,
}
/// `GET /api/products/{id}/tenants[?estate=<estate>]` — ONE estate's part of the product
/// (MAP-SEG 1, owner 2026-10-26: the Map of test2-gunnar-rs listed every estate's iron).
/// Its tenants ([`crate::estates::tenant_on`]) and their sales, and its iron
/// ([`crate::iron::estate_cloud`]); no `estate`: the rows of no ONE estate. The account's
/// iron, whole, is the operator's `GET /api/iron`.
pub async fn tenants(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<EstateQuery>,
) -> Res<Value> {
let p = product_info(&state, &id).await?;
let usages = state
.monetize
.usage()
.read_all(pb::ReadAllUsageRequest {
product: id.clone(),
})
.await?
.into_inner()
.usages;
let facts = state
.monetize
.entitlement()
.list(pb::ListEntitlementsRequest {
product: id.clone(),
})
.await?
.into_inner()
.facts;
let trend = trend_of(&state, &id, "", primary_meter(&p)).await?;
let mut fleet = fleet_of(&state, &id).await?;
// `RECENTLY BOUGHT` on `docs/v2/07-console.md` screen 2 — outcomes, one line per attempt.
let mut sales = sales_of(&state, &id, RECENT_SALES).await?;
let mut rows = join_tenants(&usages, &facts, &verifier(&state, &id).await?);
let mut own = false;
if !q.estate.trim().is_empty() {
let one = fleet_estate(&state, q.estate.trim()).await?;
let sold = book(&state).await?.sold().contains(&one.name);
own = !sold;
rows.retain(|r| crate::estates::tenant_on(&one, sold, &r.tenant));
sales.retain(|s| crate::estates::tenant_on(&one, sold, &s.tenant));
}
fleet.cloud = crate::iron::estate_cloud_of(&fleet.cloud, q.estate.trim(), own);
let (silent, no_flow_data) = silent_tenants(&rows, now_ms());
let friction = friction_tenants(&state, &id, &rows).await?;
Ok(Json(json!({
"product": id, "meters": meters(&p), "tenants": rows,
"trend": trend, "fleet": fleet, "sales": sales,
// **The two lists that no stock meter could ever produce.**
//
// SILENT is exact and costs nothing: it is read off the two
// `last_*_unix_ms` meters the poller already stored, and gunnar computes
// those over its WHOLE kept window, so a tenant with a today of zeroes
// and a push six weeks ago is six weeks quiet rather than silent
// forever.
//
// FRICTION is folded from the readings THIS SERVER holds
// (`monetize::flow`), never by asking the product once per tenant — a
// fleet page that made N calls would hang on the slowest box in the
// estate, which is the failure the reachability dot exists to avoid.
// The consequence is deliberate and is named on the screen: the fleet
// page says WHO and HOW MANY, and the reason breakdown lives one click
// away on the tenant's Activity panel, where the day rows carry it.
"silent_after_days": SILENT_AFTER_DAYS,
"silent": silent,
// **The tenants NOBODY MEASURED, by name, kept out of `silent`.**
//
// A product that does not report the two `last_*_unix_ms` meters says
// nothing about whether its tenants are being used. Folding that into
// SILENT would publish `never seen` about a customer on the strength of
// a gap in a plugin — and `quiet_for_ms` would rank it `u64::MAX`, at
// the TOP of the page, above every measured finding.
//
// Not dropped either, which would make a whole product's tenants
// disappear from the page with no explanation. They are named, so the
// operator reads the true sentence — *we do not know about these* —
// instead of a comfortable one or a frightening one.
"no_flow_data": no_flow_data,
"friction_days": FRICTION_DAYS,
"friction": friction,
})))
}
/// **Tenants nobody has pushed to or fetched from for [`SILENT_AFTER_DAYS`]**,
/// longest first.
///
/// `quiet_ms` is `u64::MAX` for a tenant that has NEVER been seen, which is how
/// it sorts above one last seen a year ago; `quiet_days` is `null` for the same
/// tenant, because "never" has no number of days and a console must not be
/// handed one to render as a date. `monetize_product::flow::Quiet` is the one writer of
/// that distinction.
///
/// # A tenant nobody MEASURED is not a silent tenant
///
/// Returns TWO lists, and the second is the point: the tenants whose product
/// reports **neither** `last_*_unix_ms` meter, named and kept OUT of the first.
///
/// This function used to say the opposite, in a comment that read *"A tenant the
/// poller has never read carries no `last_*` meter at all and therefore reads as
/// never seen — which is right"*. It was not right. `flow::last_seen` folded an
/// absent meter into a meter reading `0` with `.unwrap_or(0)`, so a product that
/// does not count flow at all — an older gunnar, the `Admin.StorageInfo`
/// fallback path, a tenant the poller has never read — arrived here as
/// `Quiet::Never`, was rendered `never seen`, and was sorted to the **top** of
/// the page by `quiet_for_ms`'s `u64::MAX`. The most urgent-looking row on the
/// fleet page was a claim about a customer made from a gap in a plugin.
///
/// The tenant page had already settled it the other way — it draws *"this
/// product does not report flow"* rather than a last-seen line when neither key
/// is present. Two views of one fact disagreed. `flow::Quiet::NoData` now
/// carries the distinction in the type, `at_least_days` is false for it, and
/// this list cannot contain one by construction.
///
/// A tenant whose meters ARE reported and read `0` is still `Quiet::Never` and
/// still belongs here: that is a measurement, and "nothing has ever moved
/// through this account" is exactly what the list is for.
///
/// `quiet_ms` is `u64::MAX` for such a tenant, which is how it sorts above one
/// last seen a year ago; `quiet_days` is `null` for it, because "never" has no
/// number of days and a console must not be handed one to render as a date.
/// `monetize_product::flow::Quiet` is the one writer of both distinctions.
fn silent_tenants(rows: &[TenantRow], now_unix_ms: u64) -> (Vec<Value>, Vec<String>) {
let usage_of = |row: &TenantRow| monetize_product::Usage {
meters: row.meters.clone(),
measured_at_unix_ms: row.measured_at_unix_ms.unwrap_or(0),
};
// The tenants nobody measured, in a stable order so the line on the page
// does not reshuffle between polls.
let mut no_flow_data: Vec<String> = rows
.iter()
.filter(|row| monetize_product::flow::last_seen(&usage_of(row)).is_none())
.map(|row| row.tenant.clone())
.collect();
no_flow_data.sort_unstable();
let mut quiet: Vec<(u64, Option<u64>, &TenantRow)> = rows
.iter()
.map(|row| (monetize_product::flow::last_seen(&usage_of(row)), row))
.map(|(seen, row)| {
(
monetize_product::flow::quiet_for(seen, now_unix_ms),
seen,
row,
)
})
// `NoData` is false here, so an unmeasured tenant cannot reach the list.
.filter(|(quiet, _, _)| quiet.at_least_days(SILENT_AFTER_DAYS))
.map(|(quiet, seen, row)| {
(
monetize_product::flow::quiet_for_ms(seen, now_unix_ms),
quiet.days(),
row,
)
})
.collect();
quiet.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.2.tenant.cmp(&b.2.tenant)));
let silent = quiet
.into_iter()
.map(|(quiet_ms, days, row)| {
json!({ "tenant": row.tenant, "state": row.state, "quiet_ms": quiet_ms, "quiet_days": days })
})
.collect();
(silent, no_flow_data)
}
/// **Tenants that met a door in the last [`FRICTION_DAYS`] days**, most refused
/// first.
///
/// Folded from `Usage.History` — the readings this server already stored — and
/// never by asking the product per tenant. `refused_today` is a DAILY counter
/// and the fold is `monetize_product::flow::window_total`, which takes each UTC day's
/// peak rather than summing readings: a tenant refused once at nine in the
/// morning appears in every one of that day's remaining polls, and a naive sum
/// would report a number proportional to the poll rate.
///
/// A tenant whose history could not be read is left OUT rather than reported as
/// frictionless: this is a list of accounts to act on, and a silent omission is
/// better than a claim nobody measured. The read failure is already visible on
/// the page as the product's reachability.
async fn friction_tenants(
state: &AppState,
product: &str,
rows: &[TenantRow],
) -> Result<Vec<Value>, ApiError> {
let now = now_ms();
let since = now.saturating_sub(FRICTION_DAYS * 24 * 60 * 60 * 1000);
let mut out: Vec<(u64, Value)> = Vec::new();
for row in rows {
let Ok(answer) = state
.monetize
.usage()
.history(pb::UsageHistoryRequest {
product: product.to_owned(),
tenant: row.tenant.clone(),
since_unix_ms: since,
limit: 0,
})
.await
else {
continue;
};
let readings: Vec<monetize_product::Usage> = answer
.into_inner()
.readings
.iter()
.map(|u| monetize_product::Usage {
meters: u.meters.iter().map(|m| (m.name.clone(), m.value)).collect(),
measured_at_unix_ms: u.measured_at_unix_ms,
})
.collect();
let refused = monetize_product::flow::window_total(&readings, "refused_today", since, now);
if refused == 0 {
continue;
}
out.push((
refused,
json!({ "tenant": row.tenant, "state": row.state, "refused": refused }),
));
}
out.sort_by_key(|(refused, _)| std::cmp::Reverse(*refused));
Ok(out.into_iter().map(|(_, v)| v).collect())
}
/// **How many days of activity the console asks for: 30.**
///
/// The window `docs/v2/07-console.md` quotes beside every rate, and the one the tenant page's
/// Activity panel draws. A constant rather than a query parameter because the
/// window is ON THE SCREEN beside the bars — "last 30 days" — and a window the
/// caller could change would be a caption that could be wrong.
pub const ACTIVITY_DAYS: u64 = 30;
/// `GET /api/tenants/{product}/{tenant}/activity` — **what the tenant has been
/// DOING, day by day.**
///
/// A passthrough to the product, through monetize-server's own passthrough.
/// Nothing is folded, nothing is cached and nothing is stored: the product is
/// the only side that counts flow, and a copy on this side would be a second
/// series to go stale.
///
/// **A refusal is a refusal and never an empty series.** The panel renders an
/// empty `days` as *nothing moved in this window*; a product that is merely
/// unreachable must not be able to say that about a working customer. So the
/// error is passed up, and the browser draws the failure box with the product's
/// own words in it.
///
/// `retained_days` rides along so the panel can tell *nothing happened* from
/// *this box does not remember that far back* — the two readings of an empty
/// series, which look identical without it.
pub async fn activity(
State(state): State<AppState>,
Path((product, tenant)): Path<(String, String)>,
) -> Res<Value> {
product_info(&state, &product).await?;
let now = now_ms();
let out = state
.monetize
.usage()
.activity(pb::ActivityRequest {
product: product.clone(),
tenant: tenant.clone(),
since_unix_ms: now.saturating_sub(ACTIVITY_DAYS * 24 * 60 * 60 * 1000),
until_unix_ms: now,
})
.await?
.into_inner();
Ok(Json(json!({
"product": product,
"tenant": tenant,
"days_asked": ACTIVITY_DAYS,
"retained_days": out.retained_days,
"days": out.days.iter().map(activity_day_json).collect::<Vec<_>>(),
})))
}
/// This process's wall clock, in Unix milliseconds — only ever used to say how
/// far back to ask the product for. The DAYS themselves are the product's, and
/// nothing here re-derives one.
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// One day of flow, as the console reads it: the day, and the product's own
/// meters as a plain object.
///
/// A map and not a struct of named fields, exactly as `TenantRow::meters` is:
/// the names are the PRODUCT's, this server serves several products, and a
/// struct here would be gunnar's vocabulary hard-coded into a shape holger has
/// to fit into. A meter that is absent was not counted and is absent here too.
fn activity_day_json(day: &pb::ActivityDay) -> Value {
let meters: BTreeMap<&str, u64> = day
.meters
.iter()
.map(|m| (m.name.as_str(), m.value))
.collect();
json!({ "day_unix_ms": day.day_unix_ms, "meters": meters })
}
/// `GET /api/tenants/{product}/{tenant}` — the latest reading, the fact, the
/// ledger rows of its references, what the tenant stands on, and the product's
/// price lists for the order form.
///
/// The LATEST reading, because that is what this page draws bars from. It is not
/// all monetize has: `Usage.History` (`usage.proto`) answers a window of
/// readings per tenant. This comment used to say there was no history RPC, which
/// was true for a few hours and then was not, and a sentence like that is worse
/// than none — it is read as a reason not to look.
pub async fn tenant(
State(state): State<AppState>,
Path((product, tenant)): Path<(String, String)>,
) -> Res<Value> {
let listed = list_products(&state).await?;
let p = listed
.products
.iter()
.find(|x| x.id == product)
.cloned()
.ok_or_else(|| {
ApiError(Fail {
code: "notfound".into(),
message: format!("product {product:?} is not served by monetize"),
})
})?;
let usage = state
.monetize
.usage()
.read(pb::ReadUsageRequest {
product: product.clone(),
tenant: tenant.clone(),
})
.await?
.into_inner()
.usage;
let got = state
.monetize
.entitlement()
.get(pb::GetEntitlementRequest {
product: product.clone(),
tenant: tenant.clone(),
})
.await?
.into_inner();
let (fact, pending_push) = (got.fact, got.pending_push);
let ledger = state
.monetize
.ledger()
.query(pb::QueryLedgerRequest {
by: Some(By::Tenant(pb::TenantQuery {
product: product.clone(),
tenant: tenant.clone(),
})),
})
.await?
.into_inner();
// WHAT THE TENANT IS STANDING ON. The usage bars say how much of a quota is
// used; this says what iron is under the quota — and the two together are
// what an operator needs before deciding to sell someone more.
let holdings = state
.monetize
.entitlement()
.held(pb::HeldRequest {
product: product.clone(),
tenant: tenant.clone(),
})
.await?
.into_inner()
.holdings;
let verifier = verifier(&state, &product).await?;
let row = join_tenants(usage.as_slice(), fact.as_slice(), &verifier).pop();
// `docs/v2/07-console.md` screen 3's `1.4 GiB/wk` line, folded on the server over the series
// the poller stored.
let trend = trend_of(&state, &product, &tenant, primary_meter(&p)).await?;
Ok(Json(json!({
"product": product, "tenant": tenant, "meters": meters(&p), "trend": trend,
"row": row, "fact": fact.as_ref().map(|f| fact_json(f, &verifier)),
// THE PRODUCT HAS NOT ACKNOWLEDGED THE FACT ABOVE: the poller is still
// pushing it, or the product refused it and an operator must decide.
"pending_push": pending_push.as_ref().map(|p| json!({
"since_unix_ms": p.since_unix_ms, "attempts": p.attempts, "last_error": p.last_error,
})),
"rows": ledger.rows.iter().map(row_json).collect::<Vec<_>>(), "references": ledger.references,
"holdings": holdings.iter().map(holding_json).collect::<Vec<_>>(),
// THIS product's price lists, one per market, and the policy: what the
// order form prices a delta by, and what the USING words are read off.
"prices": listed.prices.iter().filter(|l| l.product == product).map(price_list_json).collect::<Vec<_>>(),
"policy": policy_json(listed.policy.as_ref()),
// The vendors THIS server has linked, so the order form offers only what
// a purchase could actually use. A console that listed `stripe` on a
// server built without it would send an operator into a NotFound.
"vendors": listed.vendors,
})))
}
/// `GET /api/quote?product=&tenant=&caps=meter=value,…&market=SE` — **what an
/// order would come to, before any money moves.** The price from the list
/// ("free" when 0), the cost the provider would charge us, the delta over what
/// the tenant holds, the iron it buys, and whether it can be served with the
/// refusal in monetize's own words when not. Pure: monetize reserves nothing
/// and writes nothing. A read, so a viewer's.
pub async fn quote(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Res<QuoteJson> {
let product = q.get("product").cloned().unwrap_or_default();
let tenant = q.get("tenant").cloned().unwrap_or_default();
let caps = parse_caps_query(q.get("caps").map(String::as_str).unwrap_or(""))
.map_err(|m| ApiError(Fail::invalid(m)))?;
// `renew=1` asks for a RENEWAL — another period of what the tenant already
// holds — instead of an order. It names no caps and no start: monetize
// refuses a renewal that carries either, rather than ignoring them, because
// a request that says both things is not one whose meaning we get to pick.
let renew = matches!(q.get("renew").map(String::as_str), Some("1" | "true"));
let bundle = q.get("bundle").cloned().unwrap_or_default();
let p = product_info(&state, &product).await?;
validate_caps(&product, &meters(&p), &caps).map_err(|m| ApiError(Fail::invalid(m)))?;
let market = match q
.get("market")
.map(String::as_str)
.filter(|m| !m.is_empty())
{
Some(m) => market_from_name(m)
.ok_or_else(|| ApiError(Fail::invalid(format!("market {m:?} is not SE or FI"))))?
as i32,
None => 0,
};
let out = state
.monetize
.purchase()
// No actor ticket, ever: this console is the OPERATOR's, authenticated
// by monetize's bearer, and the tenant path is the product's own
// console asking on a customer's behalf. An operator does not need the
// appliance's permission to renew somebody, and an empty ticket is
// exactly the path this console has always taken.
// **`bundle=<id>` quotes ONE ROW OF THE SHOP WINDOW**, by the id
// `/api/bundles` handed out, resolved by monetize into exactly the caps
// a typed order would have named. It exists so that "would you sell me
// this?" can be asked of the PURCHASE path — the one the Buy button
// takes — and not only of the window that advertises it. Two doors, one
// answer, and the day they disagree is the day a customer is shown a
// product that cannot be delivered. `caps` must be empty alongside it;
// monetize refuses a request that names both rather than letting one
// win silently.
.quote(pb::QuoteRequest {
product,
tenant,
caps,
market,
starts_unix_ms: 0,
renew,
actor_ticket: Vec::new(),
bundle,
})
.await?
.into_inner();
Ok(Json(quote_json(&out)))
}
/// `GET /api/bundles?product=&tenant=&market=` — **THE SHOP WINDOW, READ.**
///
/// The same `Purchase.Bundles` the customer's own console draws its offers
/// from, answered here so that an operator — and the live bench — can see what
/// this deployment is actually WILLING TO SELL this tenant, without a browser,
/// without a session on the appliance and without pressing anything.
///
/// **It spends nothing.** One gRPC read; monetize prices the list purely and
/// asks `can_serve` through the SAME `quote_order` a real order would run, and
/// neither half reserves, writes, dials a vendor or moves a byte. It is on the
/// viewer's floor for exactly that reason, beside `/api/quote`.
///
/// # Why this route exists at all (2026-09-20)
///
/// Because nothing outside a browser could read the shop. The defect that made
/// it necessary: a live appliance offered 100 GiB and 50 GiB at full price on a
/// box whose `/data` could serve 37.8 GiB, and monetize had ALREADY written
/// both refusals to its own log before the page was drawn. The verdict existed;
/// nothing automated could ask for it. `xtask bench live`'s only money row (F9)
/// orders a real growth for real SEK, so it is opt-in, fenced and had never
/// been run — and the free question, *what would you sell me right now*, had no
/// door to ask through.
///
/// Now it has one, and it costs 0 GiB and 0 SEK.
///
/// `can_serve` and `reason` are carried EXACTLY as monetize wrote them. This
/// console does not recompute the verdict, does not summarise the sentence and
/// does not drop the refused rows: an operator's window shows the whole
/// declared shop and says which of it can be delivered, which is the question
/// an operator has. Dropping the undeliverable rows is the CUSTOMER console's
/// job, on its own surface, where a dead card is a lie about a product.
pub async fn bundles(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Res<Value> {
let product = q.get("product").cloned().unwrap_or_default();
let tenant = q.get("tenant").cloned().unwrap_or_default();
let market = match q
.get("market")
.map(String::as_str)
.filter(|m| !m.is_empty())
{
Some(m) => market_from_name(m)
.ok_or_else(|| ApiError(Fail::invalid(format!("market {m:?} is not SE or FI"))))?
as i32,
None => 0,
};
let out = state
.monetize
.purchase()
// No actor ticket: the OPERATOR path, byte for byte as on `/api/quote`
// — this console is authenticated by monetize's own bearer and the
// ticket exists to stop a STRANGER reading a tenant's standing caps
// back out of a price.
.bundles(pb::ListBundlesRequest {
product,
tenant,
market,
actor_ticket: Vec::new(),
})
.await?
.into_inner();
let rows: Vec<Value> = out
.bundles
.iter()
.map(|b| {
json!({
"id": b.id,
"label": b.label,
// WORDS, never minor units — monetize's own rendering, the same
// string the customer's button carries.
"price_words": b.price,
"period": b.period,
"delta": b.delta,
"can_serve": b.can_serve,
"reason": b.reason,
})
})
.collect();
Ok(Json(json!({
"bundles": rows,
// The two counts, so a reader does not have to fold the list to learn
// the one thing that matters about a shop: how much of it is real.
"declared": rows.len(),
"deliverable": out.bundles.iter().filter(|b| b.can_serve).count(),
// Still being read at the cloud (`ListBundlesResponse.reading`): the
// empty list above is not an answer yet. Carried, not interpreted.
"reading": out.reading,
})))
}
/// **A quote, shaped for the console.** Every money figure in minor units with
/// its currency beside it; the iron in the same words the tenant page uses for
/// what a tenant already stands on.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct QuoteJson {
/// The ledger key the order would run under.
pub reference: String,
/// What the customer pays for the delta over one period. 0 is free.
pub price_minor: u64,
pub currency: &'static str,
/// What the provider charges us for the delta's iron over the period.
pub cost_minor: u64,
pub cost_currency: &'static str,
pub current: BTreeMap<String, u64>,
pub caps: BTreeMap<String, u64>,
pub delta: BTreeMap<String, u64>,
/// The iron the delta buys, in words: `90 GiB on gunnar-se-sto1`.
pub resources: Vec<HoldingJson>,
pub can_serve: bool,
/// monetize's refusal, verbatim, when `can_serve` is false.
pub reason: String,
/// `month` | `year`.
pub period: String,
}
pub fn quote_json(q: &pb::QuoteResponse) -> QuoteJson {
QuoteJson {
reference: q.reference.clone(),
price_minor: q.price.as_ref().map(|m| m.minor).unwrap_or(0),
currency: currency_name(q.price.as_ref().map(|m| m.currency).unwrap_or(0)),
cost_minor: q.cost.as_ref().map(|m| m.minor).unwrap_or(0),
cost_currency: currency_name(q.cost.as_ref().map(|m| m.currency).unwrap_or(0)),
current: q.current.clone(),
caps: q.caps.clone(),
delta: q.delta.clone(),
resources: q
.resources
.iter()
.map(|r| {
let (kind, detail) = resource_words(Some(r));
HoldingJson {
reference: q.reference.clone(),
kind: kind.to_owned(),
detail,
provisioned: false,
id: String::new(),
}
})
.collect(),
can_serve: q.can_serve,
reason: q.reason.clone(),
period: q.period.clone(),
}
}
/// `POST /api/purchase` — **place an order**: the console's only door to the
/// two-phase transaction monetize exists for.
///
/// An order is `{product, tenant, caps}`: what the tenant is to be ALLOWED. The
/// delta over what they hold is what monetize prices (from the list; free with
/// none) and buys — a tenant on 10 GiB who orders 100 is sold the 90 they lack
/// (MEASURED before the delta existed: 110 GiB held and billed where 100 was
/// asked for). A downgrade, a product that cannot deliver the caps, a box past
/// its ceiling, and a price nobody linked can take are monetize's refusals to
/// make, before any money moves, and they arrive here verbatim (`Fail` keeps
/// monetize's own words) — "gunnar's /data cannot grow" is the sentence the
/// operator has to read out to the customer.
///
/// Behind a fresh touch (`needs_step_up`: every POST under `/api/` is), which is right:
/// this one spends the tenant's money and Vetra's, in that order. What the BFF
/// adds is the shape a browser needs: `starts_unix_ms` omitted means "now", so
/// a console need not agree with the server about the clock; `vendor` omitted
/// means the only one linked that serves the market; `market` omitted means the
/// deployment's default.
pub async fn start_purchase(
State(state): State<AppState>,
Json(body): Json<StartPurchase>,
) -> Res<Value> {
let p = product_info(&state, &body.product).await?;
validate_caps(&body.product, &meters(&p), &body.caps)
.map_err(|m| ApiError(Fail::invalid(m)))?;
let product = body.product.clone();
let market = match body.market.as_deref().filter(|m| !m.is_empty()) {
Some(m) => market_from_name(m)
.ok_or_else(|| ApiError(Fail::invalid(format!("market {m:?} is not SE or FI"))))?
as i32,
None => 0,
};
let out = state
.monetize
.purchase()
.start(pb::StartPurchaseRequest {
product: body.product,
tenant: body.tenant,
caps: body.caps,
vendor: body.vendor.unwrap_or_default(),
payer: body.payer.unwrap_or_default(),
// 0 means "now" on the wire; the server owns the clock.
starts_unix_ms: body.starts_unix_ms.unwrap_or(0),
market,
renew: body.renew.unwrap_or(false),
// The operator path — see the quote above.
actor_ticket: Vec::new(),
bundle: body.bundle.clone().unwrap_or_default(),
users: None,
})
.await?
.into_inner();
let verifier = verifier(&state, &product).await?;
Ok(Json(purchase_json(&out, &verifier)))
}
/// `GET /api/purchase/{reference}` — where a started purchase stands.
///
/// The reference is `<product>/<tenant>/<YYYY-MM-DD>` and contains slashes, so
/// it arrives as the rest of the path rather than one segment.
pub async fn purchase_status(
State(state): State<AppState>,
Path(reference): Path<String>,
) -> Res<Value> {
let product = reference.split('/').next().unwrap_or_default().to_string();
let out = state
.monetize
.purchase()
.status(pb::PurchaseStatusRequest {
reference,
..Default::default()
})
.await?
.into_inner();
let verifier = verifier(&state, &product).await?;
Ok(Json(json!({
"state": purchase_state_name(out.state),
"reference": out.reference,
"fact": out.fact.as_ref().map(|f| fact_json(f, &verifier)),
"reason": out.reason,
// Still live, and only while the attempt is open: the operator who
// reopens a PENDING purchase because the customer says the link never
// arrived is the reason this is on the status read and not only on the
// reply to `Start`.
"redirect_url": out.redirect_url,
// The ledger rows behind the verdict, oldest first — the timeline the
// console already renders for a reference.
"rows": out.rows.iter().map(row_json).collect::<Vec<_>>(),
})))
}
/// What the console posts to place an order. Everything but the order itself
/// is optional, so the common call is three fields.
#[derive(Debug, serde::Deserialize)]
pub struct StartPurchase {
pub product: String,
pub tenant: String,
/// What the tenant is to be ALLOWED, per meter. A meter not named carries forward.
pub caps: BTreeMap<String, u64>,
#[serde(default)]
pub vendor: Option<String>,
#[serde(default)]
pub payer: Option<String>,
/// `SE` | `FI`; the deployment's default when omitted.
#[serde(default)]
pub market: Option<String>,
#[serde(default)]
pub starts_unix_ms: Option<u64>,
/// **Sell a RENEWAL** — another period of what the tenant already holds —
/// rather than an order. `caps` must then be empty and `starts_unix_ms`
/// absent; monetize refuses a renewal that carries either rather than
/// ignoring them.
///
/// This is the door an operator sells a renewal through today, and it is
/// the door the Retention refusal routes to: `Transaction::renewal` will
/// not sell a tenant the ladder has carried past retention, because an
/// operator may already have handed their iron back — so an operator sells
/// that one here, having looked.
#[serde(default)]
pub renew: Option<bool>,
/// **Buy a whole OFFER instead of naming numbers** — a bundle id from
/// `/api/bundles`, which is what the shop window already shows.
///
/// `Purchase.Start` has honoured this since the shop window existed
/// (`caps_of_bundle`, services/purchase.rs:660): the bundle's `cap_delta`
/// is added to what the tenant holds, so a caller never does the
/// arithmetic. This console sent `bundle: String::new()` unconditionally,
/// so the only way through it was the typed form — two boxes, a meter name
/// and a byte count. That is a debugging form, and an operator asked for a
/// BUTTON.
///
/// `caps` may be sent beside it and the server folds the two; sending
/// neither is what it refuses.
#[serde(default)]
pub bundle: Option<String>,
}
fn purchase_json(o: &pb::StartPurchaseResponse, v: &Verifier) -> Value {
json!({
"reference": o.reference,
"state": purchase_state_name(o.state),
"handle_vendor": o.handle_vendor,
"handle_id": o.handle_id,
// Empty for a vendor with nowhere to send the customer — an invoice is
// paid from the customer's own bank, and a PAID purchase is finished.
// Where it is non-empty it is the VENDOR's own URL when the vendor mints
// one per order, and the configured template otherwise.
"redirect_url": o.redirect_url,
"reason": o.reason,
// Present only when PAID: the signed verdict the product now holds, so
// the console can show the outcome without a second round trip.
"fact": o.fact.as_ref().map(|f| fact_json(f, v)),
})
}
/// The purchase state as the word the console shows. An unknown number is
/// reported AS unknown rather than defaulted to something reassuring — a
/// console that renders a state it does not understand as "PAID" is worse than
/// one that says it does not know.
fn purchase_state_name(v: i32) -> String {
match pb::PurchaseState::try_from(v) {
Ok(s) => format!("{s:?}").to_ascii_uppercase(),
Err(_) => format!("UNKNOWN({v})"),
}
}
/// `POST /api/purchase/abandon` — refund a stuck attempt and close it.
///
/// A separate path from `GET /api/purchase/{reference}` on purpose: this one
/// moves money BACK, and an operator should not be able to reach it by fumbling
/// a URL. Admin-floored like every mutation, and the reason is required.
pub async fn abandon_purchase(
State(state): State<AppState>,
Json(body): Json<AbandonPurchase>,
) -> Res<Value> {
let out = state
.monetize
.purchase()
.abandon(pb::AbandonPurchaseRequest {
reference: body.reference,
reason: body.reason,
..Default::default()
})
.await?
.into_inner();
let words = if out.released.is_empty() {
format!("{} abandoned; it held nothing at a provider", out.reference)
} else {
format!(
"{} abandoned; released {}",
out.reference,
out.released.join(", ")
)
};
Ok(Json(
json!({ "reference": out.reference, "released": out.released, "words": words }),
))
}
#[derive(Debug, serde::Deserialize)]
pub struct AbandonPurchase {
pub reference: String,
pub reason: String,
}
/// `POST /api/backup` — run `Ledger.Export` on monetize.
///
/// Behind a fresh touch (`needs_step_up`: every POST under `/api/` is), and it takes no
/// body: the destination is monetize's configured `backup_dir`, never a
/// caller's choice. See the proto — the export must run inside the service
/// because redb is single-process, so this route is the only way to take a
/// backup without stopping it.
pub async fn backup(State(state): State<AppState>) -> Res<Value> {
let out = state
.monetize
.ledger()
.export(pb::ExportBackupRequest {})
.await?
.into_inner();
Ok(Json(json!({
"dir": out.dir,
"ledger_rows": out.ledger_rows,
"usage_readings": out.usage_readings,
"signing_public_key": out.signing_public_key,
"contains_no_secrets": true,
})))
}
/// `GET /api/ledger` — the open attempts first (the rows an operator must see),
/// then the last [`RECENT_ROWS`] rows of the ledger, newest first. The query
/// flag is accepted for the address's sake; both halves are always returned.
///
/// **"Recent" is now monetize's answer, not this layer's reconstruction.** It
/// used to be built here out of the ENTITLEMENT table: list every product's
/// facts, keep those whose `source` reads `payment:<vendor>:<reference>`, fetch
/// each of those references' rows, sort, truncate. A fact is not a row, so the
/// page was wrong in three ways at once — a DECLINED or RELEASED attempt never
/// makes a fact and so never appeared, though it is the one an operator is
/// looking for; an `operator` or `ladder` fact names no reference and so hid the
/// rows of the reference that had actually been bought for that tenant; and on a
/// server where nothing holds a fact yet it said "no rows yet" over a ledger with
/// rows in it. It was also N+2 round trips to monetize per page load, where the
/// ledger's own time index answers in one.
pub async fn ledger(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Res<Value> {
let open = state
.monetize
.ledger()
.query(pb::QueryLedgerRequest {
by: Some(By::Unfinished(true)),
})
.await?
.into_inner();
let unfinished: Vec<Value> = open
.references
.iter()
.map(|r| json!({ "reference": r, "rows": open.rows.iter().filter(|x| &x.reference == r).map(row_json).collect::<Vec<_>>() }))
.collect();
let recent = state
.monetize
.ledger()
.query(pb::QueryLedgerRequest {
by: Some(By::Recent(RECENT_ROWS)),
})
.await?
.into_inner();
// The DEFAULT rendering `docs/v2/07-console.md` asks for: one line per attempt, the steps
// behind a disclosure. `recent` stays because the disclosure needs it, but
// it is no longer what the page leads with.
let sales = sales_of(
&state,
q.get("product").map(String::as_str).unwrap_or(""),
RECENT_SALES,
)
.await?;
Ok(Json(
json!({ "sales": sales, "unfinished": unfinished, "recent": recent.rows.iter().map(row_json).collect::<Vec<_>>() }),
))
}
/// How many ledger rows the Ledger page shows. monetize caps it lower if its own
/// ceiling is lower; asking for more than a page can be read is not a kindness.
pub const RECENT_ROWS: u32 = 200;
/// How many paid signups the Activity page lists. Smaller than [`RECENT_ROWS`]
/// because a signup is one line and an operator reads them, rather than
/// scrolling past them looking for a sale.
pub const RECENT_SIGNUPS: u32 = 100;
/// **`GET /api/signups`** — every paid signup the vendor webhooks brought in,
/// **refusals included**, newest first.
///
/// It is a separate call from `/api/ledger` and not a field on it, because a
/// signup is not a ledger attempt: it holds no reference, it has no tenant
/// until the sink answers, and half of them are refusals that never became a
/// sale at all. Joining them would make "what did this reference do" and "who
/// tried to sign up" one list ordered by two different clocks.
///
/// **The contact address IS here**, beside the account: this is the operator's
/// console behind the operator's login, and an account nobody can write to
/// cannot be run (measured 2026-09-15, the first paid signup). It is on
/// `SignupAdmin.List` only — never on `Signup.Status` or the event feed.
pub async fn signups(State(state): State<AppState>, Query(q): Query<SiteQuery>) -> Res<Value> {
let site = site_vendor(&state, &q.estate).await?;
let mut out = state
.monetize
.signups()
.list(pb::ListSignupsRequest {
limit: RECENT_SIGNUPS,
})
.await?
.into_inner();
out.signups.retain(|r| on_site(&r.vendor, &site));
// Which rows monetize found by READING the vendor's own payments (the
// reconcile pass) rather than from the vendor's message at checkout — so
// the console can say where each row comes from. Best effort: a monetize
// that cannot answer leaves every row "at checkout".
let found: std::collections::BTreeSet<(String, String)> = match state
.monetize
.reconcile()
.list_paid_at_vendor(pb::ListPaidAtVendorRequest { limit: 0 })
.await
{
Ok(r) => r
.into_inner()
.cases
.into_iter()
.map(|c| (c.vendor, c.session))
.collect(),
Err(_) => Default::default(),
};
let rows: Vec<Value> = out
.signups
.iter()
.map(|r| {
let mut v = signup_json(r);
v["found_at_vendor"] = json!(found.contains(&(r.vendor.clone(), r.session.clone())));
v
})
.collect();
Ok(Json(json!({ "signups": rows })))
}
/// How many paid-at-vendor cases the Activity page lists. A console page, not
/// an export; the counts beside it are the store's whole.
pub const RECENT_PAID_AT_VENDOR: u32 = 200;
/// **`GET /api/paid-at-vendor`** — the payments the VENDOR says it took that
/// monetize had no row for, newest first, with the counts the health line
/// carries.
///
/// The other half of the refusals panel. A refused callback is a delivery
/// monetize threw away; this is the money that was behind it, read back from
/// the vendor's own ledger by monetize's poller over the key it already holds
/// (`monetize_server::reconcile`). MEASURED 2026-09-14: an Apple Pay checkout
/// was paid at 11:10 UTC, every webhook for it was refused, and for hours the
/// only trace anywhere was a journald line. These rows are what that morning
/// would have shown.
///
/// Unlike the refusals, everything here is the VENDOR's word — monetize asked,
/// authenticated, and the vendor answered about its own object. A `settled`
/// row was handed to the same signup engine the webhook feeds (its outcome is
/// on the signups panel, same session id); a `needs-operator` row is one
/// monetize would not guess at, and `reason` says what was missing.
///
/// A read, like `/api/signups`: it carries no e-mail (monetize keeps it off
/// the wire), and "the customer was charged and monetize holds nothing" is
/// exactly what a viewer on duty must be able to see.
pub async fn paid_at_vendor(
State(state): State<AppState>,
Query(q): Query<SiteQuery>,
) -> Res<Value> {
let site = site_vendor(&state, &q.estate).await?;
let mut out = state
.monetize
.reconcile()
.list_paid_at_vendor(pb::ListPaidAtVendorRequest {
limit: RECENT_PAID_AT_VENDOR,
})
.await?
.into_inner();
if site.is_some() {
// A site's own rows, and its own counts.
out.cases.retain(|c| on_site(&c.vendor, &site));
out.total = out.cases.len() as u64;
out.needs_operator = out
.cases
.iter()
.filter(|c| c.state == "needs-operator")
.count() as u64;
out.settled = out.cases.iter().filter(|c| c.state == "settled").count() as u64;
}
Ok(Json(json!({
"cases": out.cases.iter().map(paid_at_vendor_json).collect::<Vec<_>>(),
"total": out.total,
"needs_operator": out.needs_operator,
"settled": out.settled,
"last_reconcile_unix_ms": out.last_reconcile_unix_ms.max(0),
})))
}
/// One paid-at-vendor case for the console. Passed through field for field;
/// the currency is upper-cased because that is how every other amount on this
/// console spells its code, and the vendor spells it `sek`.
pub fn paid_at_vendor_json(c: &pb::PaidAtVendorCase) -> Value {
json!({
"vendor": c.vendor,
"session": c.session,
"reference": c.reference,
"paid_at_unix_ms": c.paid_at_unix_ms.max(0),
"amount_minor": c.amount_minor,
"currency": c.currency.to_ascii_uppercase(),
"tax_minor": c.tax_minor,
"livemode": c.livemode,
"metadata": c.metadata,
"first_seen_unix_ms": c.first_seen_unix_ms.max(0),
// `settled` | `needs-operator`, monetize's own word; an unrecognised
// one is shown, never mapped to a friendlier one this console invented.
"state": c.state,
"reason": c.reason,
"decided_at_unix_ms": c.decided_at_unix_ms.max(0),
})
}
// ── The estate, drawn ─────────────────────────────────────────────────
/// **`GET /api/estate/{product}`** — the OLD product-addressed map: the ONE
/// monetize's board for `{product}`. LEGACY. It names no estate, because
/// the ONE monetize serves every estate; [`estate_map`] is the address.
pub async fn estate(
State(state): State<AppState>,
Path(product): Path<String>,
headers: http::HeaderMap,
) -> Result<Response, ApiError> {
draw_answer(&state, "", product, &headers, crate::bounded::PAGE_WITHIN).await
}
/// One drawn board, answered as [`svg_answer`] does, NAMING what it drew.
async fn draw_answer(
state: &AppState,
estate: &str,
product: String,
headers: &http::HeaderMap,
within: std::time::Duration,
) -> Result<Response, ApiError> {
// PROD-4: the map within the page's bound, else the last one drawn, aged.
let mut ea = state.monetize.estate();
let req = pb::DrawEstateRequest {
product: product.clone(),
estate: estate.to_owned(),
};
let (out, stale) = crate::bounded::within_for(
&state.monetize.draws_seen,
&format!("{product}/{estate}"),
"the map",
within,
async move { ea.draw(req).await.map(|r| r.into_inner()) },
)
.await?;
let stale = stale.map(|s| s.to_string()).unwrap_or_default();
let mut answer = svg_answer(
&out.svg,
headers
.get(http::header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok()),
);
// No count from a monetize older than the field: no header, never a 0.
let machines = out.machines.map(|n| n.to_string()).unwrap_or_default();
for (k, v) in [
("x-monetize-estate", estate.to_owned()),
("x-monetize-product", product),
("x-monetize-machines", machines),
("x-monetize-stale-secs", stale),
] {
if v.is_empty() {
continue;
}
if let Ok(v) = http::HeaderValue::from_str(&v) {
answer
.headers_mut()
.insert(http::HeaderName::from_static(k), v);
}
}
Ok(answer)
}
/// **What is left of a page's [`crate::bounded::PAGE_WITHIN`]** since `t0` (PROD-4): a page that
/// asks several reads in turn answers within ONE bound; a read past it gets a moment to answer
/// from what it kept.
fn left_of(t0: std::time::Instant) -> std::time::Duration {
crate::bounded::PAGE_WITHIN
.saturating_sub(t0.elapsed())
.max(std::time::Duration::from_millis(100))
}
/// The estate the fleet knows by `name`, refused by name when it knows none.
async fn fleet_estate(
state: &AppState,
name: &str,
) -> Result<crate::estates::FleetEstate, ApiError> {
Ok(fleet_estate_aged(state, name).await?.0)
}
/// [`fleet_estate`], with the age of the fleet state it was read from (PROD-4).
async fn fleet_estate_aged(
state: &AppState,
name: &str,
) -> Result<(crate::estates::FleetEstate, Option<u64>), ApiError> {
let (fleet, stale) = crate::estates::read_aged(&state.monetize).await?;
fleet
.into_iter()
.find(|e| e.name == name)
.map(|e| (e, stale))
.ok_or_else(|| {
ApiError(Fail {
code: "notfound".into(),
message: format!(
"the fleet knows no estate `{name}`: the ONE monetize has not declared it"
),
})
})
}
/// **`GET /api/estates/{estate}/map`** — that ESTATE's board, drawn by the
/// ONE monetize (`EstateAdmin.Draw`) for the product the fleet says the
/// estate serves. The estate is the path, and the answer NAMES what it drew
/// (`x-monetize-estate`, `x-monetize-product`, `x-monetize-machines`). An
/// estate the fleet does not know is 404. Nothing is asked of the estate.
///
/// Under `/api/`, behind `require_auth` like every read here;
/// four segments, so never the single-segment `*.svg` shape
/// `auth::is_public_asset` hands out. The caching decision is [`svg_answer`]'s.
pub async fn estate_map(
State(state): State<AppState>,
Path(estate): Path<String>,
headers: http::HeaderMap,
) -> Result<Response, ApiError> {
// PROD-4: the page's 2 s are the whole page's: the map gets what the fleet read left.
let t0 = std::time::Instant::now();
let one = fleet_estate(&state, &estate).await?;
draw_answer(&state, &one.name, one.product, &headers, left_of(t0)).await
}
/// **`GET /api/iron`** — the ACCOUNT's iron, whole: per product, every estate's owned rows
/// (each naming its estate), the orphans and the missing (MAP-SEG 1). The operator's alone
/// (an operator's, [`crate::auth::require_auth`]); an estate's own pages carry their own rows only.
///
/// MAP-3 (owner 2026-10-27): ONE entry per cloud ACCOUNT ([`crate::iron::accounts`]), naming
/// every product that lists it, its rows grouped by estate with each estate's backups — the
/// page draws what it is handed.
pub async fn iron(State(state): State<AppState>) -> Res<Value> {
let listed = list_products(&state).await?;
let mut products = Vec::new();
for p in &listed.products {
let fleet = fleet_of(&state, &p.id).await?;
products.push((p.id.clone(), fleet.cloud));
}
Ok(Json(
json!({ "accounts": crate::iron::accounts(&products) }),
))
}
/// **`GET /api/overview`** — the estates that EXIST (OVW-CLEAN, owner 2026-10-29), as the ONE
/// monetize classifies them in ONE call (`Estates.Overview`): the rows (installed, or being
/// laid), the quiet line (ended or never installed), and the New-estate slot's choices. Each
/// row the fleet knows then asks for its own [`estate_summary`], so a slow part holds one row
/// and nothing else. Nothing is decided here.
pub async fn overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let (o, stale) = crate::estates::overview(&state.monetize).await?;
Ok(Json(overview_json(&o, stale)))
}
/// Pure: **the Overview as the browser takes it**, field for field as monetize said it.
pub fn overview_json(o: &pb::EstatesOverviewResponse, stale_secs: Option<u64>) -> Value {
let line = |e: &pb::EstateStanding| json!({ "name": e.name, "product": e.product, "standing": e.standing, "words": e.words, "in_fleet": e.in_fleet, "domain": e.domain });
let slot = o.slot.clone().unwrap_or_default();
json!({
"estates": o.estates.iter().map(line).collect::<Vec<_>>(),
"ended": o.ended.iter().map(line).collect::<Vec<_>>(),
"slot": { "products": slot.products, "declared": slot.declared },
"stale_secs": stale_secs,
})
}
// ── THE ONE LEDGER'S ACCOUNTS (owner 2026-10-10) ───────────────────────────
//
// An account on the ONE ledger is named by its path: `gunnar/<t>` (the
// product's own estate), `gunnar/@<company>/<ns>` (a tenant of the sold estate
// <company>), `holger/<t>`. The Accounts page lists them (`/api/accounts`).
/// What the ONE ledger holds for an estate's summary, read in a few calls to
/// the ONE: every account, and the companies of the private instances sold.
struct Book {
accounts: Vec<pb::AccountSummary>,
instances: std::collections::BTreeSet<String>,
}
impl Book {
/// Every estate the ledger shows as sold: a private instance's company,
/// or an estate with `@<estate>/` tenants.
fn sold(&self) -> std::collections::BTreeSet<String> {
let mut s = self.instances.clone();
s.extend(
self.accounts
.iter()
.filter_map(|a| crate::estates::sold_estate_of(&a.tenant).map(str::to_owned)),
);
s
}
}
async fn book(state: &AppState) -> Result<Book, ApiError> {
let listed = state
.monetize
.accounts()
.list(pb::ListAccountsRequest {
product: String::new(),
limit: 0,
})
.await?
.into_inner();
let mut instances = std::collections::BTreeSet::new();
for a in listed.accounts.iter().filter(|a| a.instances > 0) {
let rows = state
.monetize
.accounts()
.rows(pb::AccountRowsRequest {
product: a.product.clone(),
tenant: a.tenant.clone(),
})
.await?
.into_inner()
.rows;
instances.extend(
rows.into_iter()
.filter_map(|r| r.instance)
.map(|i| i.company),
);
}
Ok(Book {
accounts: listed.accounts,
instances,
})
}
/// What counts as "this month" on the Overview: sales STARTED in the last 30
/// days.
pub const REVENUE_WINDOW_DAYS: u64 = 30;
/// **The month's money, split by each PAYMENT's own mode** — minor units per
/// currency (never summed across currencies, never a float).
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct Takings {
/// Real money: the payment said `livemode`.
pub live: BTreeMap<String, u64>,
/// Test money: the payment said so. Shown in brackets, never totalled.
pub test: BTreeMap<String, u64>,
/// The payment did not say (a vendor with no mode marker): neither.
pub unsaid: BTreeMap<String, u64>,
pub window_days: u64,
}
/// **Fold the money taken in the window**, by each sale's own `livemode`
/// (owner 2026-10-02: "derive this from each payment's Stripe livemode, never
/// from a label on the estate"). Money taken = a sale that DREW and was
/// GRANTED, started inside the window; its net price (VAT is not ours).
/// Declined, refunded and open attempts took nothing that stayed.
pub fn takings(sales: &[pb::Sale], now_ms: u64) -> Takings {
let since = now_ms.saturating_sub(REVENUE_WINDOW_DAYS * 24 * 60 * 60 * 1000);
let mut t = Takings {
window_days: REVENUE_WINDOW_DAYS,
..Takings::default()
};
for s in sales
.iter()
.filter(|s| s.drawn && s.outcome == "granted" && s.started_unix_ms >= since)
{
let Some(price) = &s.price else { continue };
if price.minor == 0 {
continue;
}
let bucket = match s.livemode {
Some(true) => &mut t.live,
Some(false) => &mut t.test,
None => &mut t.unsaid,
};
*bucket
.entry(currency_name(price.currency).to_owned())
.or_default() += price.minor;
}
t
}
/// **`GET /api/estates/{estate}/summary`** — ONE Overview row, read from the
/// ONE monetize and nothing else. It holds:
/// * the estate's product, as the fleet declared it;
/// * its machines, the boxes the fleet knows;
/// * `reached`, true when a box called home within the guardian's bound (else the pull model's words);
/// * its customers, the ONE ledger's accounts on it ([`crate::estates::tenant_on`]);
/// * the month's money from those accounts, split by each payment's own mode ([`takings`]);
/// * the vendor keys the ONE holds, and the money waiting at a vendor.
///
/// An estate the fleet does not know is 404. A ledger part that cannot be
/// read is `null`, which is not zero.
pub async fn estate_summary(
State(state): State<AppState>,
Path(estate): Path<String>,
) -> Result<Json<Value>, ApiError> {
let t0 = std::time::Instant::now();
let (one, stale) = fleet_estate_aged(&state, &estate).await?;
let ledger = async {
let b = book(&state).await.ok()?;
let sold = b.sold().contains(&one.name);
let mine = |product: &str, tenant: &str| {
product == one.product && crate::estates::tenant_on(&one, sold, tenant)
};
let customers = b
.accounts
.iter()
.filter(|a| mine(&a.product, &a.tenant))
.count();
let sales = state
.monetize
.ledger()
.sales(pb::SalesRequest {
product: one.product.clone(),
limit: 0,
})
.await
.ok()?
.into_inner()
.sales;
let sales: Vec<pb::Sale> = sales
.into_iter()
.filter(|s| mine(&s.product, &s.tenant))
.collect();
Some((customers, takings(&sales, now_ms()), sold))
};
let listed = async { list_products(&state).await.ok() };
// PROD-4: each part of a row is bounded; a part that does not answer is `null` on it.
let within = left_of(t0);
let (ledger, listed) = tokio::join!(
async { tokio::time::timeout(within, ledger).await.ok().flatten() },
async { tokio::time::timeout(within, listed).await.ok().flatten() }
);
// The open cases of THIS site's vendor only.
let site = listed
.as_ref()
.map(|l| l.estate_vendors.get(&one.name).cloned().unwrap_or_default());
let open = match site.as_ref() {
Some(v) => tokio::time::timeout(within, site_open(&state, v))
.await
.ok()
.flatten(),
None => None,
};
Ok(Json(json!({
"estate": one.name,
"product": one.product,
// PROD-4: the fleet did not answer within its bound; this row is its state this old.
"stale_secs": stale,
"reached": one.serving,
"heard": one.heard,
"last_heard_unix_ms": one.last_heard_unix_ms,
"open_case": one.open_case,
"error": one.error,
"signed_with": state.monetize.signing_fingerprint(),
"products": [one.product],
// The machines that reported in, live (`estates::reported`), by role.
"machines": one.reported.len(),
"machine_roles": one.reported.clone(),
"installed": one.installed,
"machine_hosts": one.hosts,
// A sold estate's machines are laid by this box; a site's own (gunnar.rs,
// test2) by the operator's kit, with no label of this box on them.
"laid_by_operator": ledger.as_ref().map(|(_, _, sold)| !*sold),
"customers": ledger.as_ref().map(|(n, _, _)| *n),
"revenue": ledger.as_ref().map(|(_, t, _)| t.clone()),
"vendors": listed.as_ref().map(|l| l.vendor_modes.iter().map(|m| json!({ "vendor": m.vendor, "livemode": m.livemode })).collect::<Vec<_>>()).unwrap_or_default(),
"vendor_names": listed.as_ref().map(|l| l.vendors.clone()).unwrap_or_default(),
"clouds": listed.as_ref().map(|l| l.clouds.clone()).unwrap_or_default(),
"default_cloud": listed.as_ref().map(|l| l.default_cloud.clone()).unwrap_or_default(),
"paid_at_vendor_open": open,
// The vendor this site bills through (`stripe/gunnar`), so the
// Overview reads ITS keys.
"site_vendor": site.unwrap_or_default(),
})))
}
/// A strong `ETag` over the exact bytes served.
///
/// Honest because the renderer is deterministic: the same estate always yields
/// the same document, so equal hashes really do mean an unchanged picture —
/// there is no layout nondeterminism for the hash to launder.
pub fn svg_etag(svg: &str) -> String {
format!("\"{}\"", blake3::hash(svg.as_bytes()).to_hex())
}
/// Does the caller's `If-None-Match` already hold this exact document? `*`
/// matches anything, and a weak validator (`W/"…"`) is accepted because weak
/// comparison is what a `GET` revalidation calls for.
pub fn etag_matches(if_none_match: Option<&str>, etag: &str) -> bool {
let Some(raw) = if_none_match else {
return false;
};
raw.split(',')
.map(str::trim)
.any(|t| t == "*" || t == etag || t.strip_prefix("W/") == Some(etag))
}
/// **The caching decision, in one place: revalidate always, transfer rarely.**
///
/// `Cache-Control: no-cache, private` — *store it, never use it without
/// asking*. A freshness lifetime is not available to this route however
/// tempting it looks: half the picture is LIVE (a box's disk, whether the twin
/// answered), so any seconds > 0 is a window in which an operator is shown an
/// estate that has changed, and this picture is opened precisely when something
/// has. `private` because the bytes are one operator's, and no shared cache
/// between the console and the browser may keep them.
///
/// The `ETag` then pays for itself on the transfer and not on the work: the
/// document has to be drawn before it can be hashed, so a `304` saves the
/// kilobytes and the browser's repaint, never the call into monetize. That is
/// the honest description of the saving, and it is still worth having on a
/// picture an operator leaves open and reloads.
fn svg_answer(body: &str, if_none_match: Option<&str>) -> Response {
let etag = svg_etag(body);
let headers = [
(http::header::ETAG, etag.clone()),
(http::header::CACHE_CONTROL, "no-cache, private".to_owned()),
];
if etag_matches(if_none_match, &etag) {
return (http::StatusCode::NOT_MODIFIED, headers).into_response();
}
(
[(http::header::CONTENT_TYPE, "image/svg+xml".to_owned())],
headers,
body.to_owned(),
)
.into_response()
}
#[cfg(test)]
mod estate_tests {
use super::*;
/// ★ A DK price list is shown as DK in DKK — never silently as SE/SEK
/// (owner 2026-09-28: holger sells in DK).
#[test]
fn a_danish_price_list_reads_dk_in_dkk() {
assert_eq!(market_name(pb::Market::Dk as i32), "DK");
assert_eq!(currency_name(pb::Currency::Dkk as i32), "DKK");
assert_eq!(market_from_name("dk"), Some(pb::Market::Dk));
assert_eq!(
(
market_name(pb::Market::Fi as i32),
currency_name(pb::Currency::Eur as i32)
),
("FI", "EUR")
);
}
#[test]
fn an_etag_is_the_document_and_a_changed_estate_is_a_new_one() {
let one = svg_etag("<svg/>");
assert!(
one.starts_with('"') && one.ends_with('"'),
"a strong validator is quoted: {one}"
);
assert_eq!(
one,
svg_etag("<svg/>"),
"the renderer is deterministic, so the tag must be"
);
assert_ne!(
one,
svg_etag("<svg />"),
"one byte of difference is a different picture"
);
}
#[test]
fn if_none_match_holds_the_document_only_when_it_names_it() {
let tag = svg_etag("<svg/>");
assert!(etag_matches(Some(&tag), &tag));
assert!(
etag_matches(Some(&format!("W/{tag}")), &tag),
"a weak validator revalidates a GET"
);
assert!(
etag_matches(Some(&format!("\"other\", {tag}")), &tag),
"a list is a list"
);
assert!(etag_matches(Some("*"), &tag));
assert!(!etag_matches(None, &tag), "no header is never a match");
assert!(
!etag_matches(Some("\"other\""), &tag),
"a stale tag must fetch the new picture"
);
}
/// The trap this route was laid out to avoid: a single-segment `.svg` is
/// handed out with NO session at all, because trunk fetches the console's
/// own bundle without the cookie.
#[test]
fn the_estate_route_is_not_the_shape_that_is_public() {
use http::Method;
assert!(
crate::auth::is_public_asset(&Method::GET, "/estate.svg"),
"the trap is real"
);
assert!(
!crate::auth::is_public_asset(&Method::GET, "/api/estate/gunnar"),
"the estate must need a session"
);
assert!(!crate::auth::is_public("/api/estate/gunnar"));
}
/// The estate-addressed map is behind a session too — and its last
/// segment is a WORD, so `.map` (a public asset extension) never matches.
#[test]
fn the_estate_addressed_map_is_not_a_public_asset() {
use http::Method;
for p in [
"/api/estates/gunnar.rs/map",
"/api/estates/test2.gunnar.rs/summary",
"/api/overview",
] {
assert!(!crate::auth::is_public_asset(&Method::GET, p), "{p}");
assert!(!crate::auth::is_public(p), "{p}");
}
}
/// ★ owner 2026-10-02: live money is live because the PAYMENT said so;
/// test money is kept apart; a payment that said nothing is neither.
#[test]
fn takings_split_by_each_payments_own_mode_and_count_only_money_that_stayed() {
let now = 1_800_000_000_000u64;
let day = 24 * 60 * 60 * 1000;
let s = |minor, cur: pb::Currency, live: Option<bool>, outcome: &str, drawn, started| {
pb::Sale {
price: Some(pb::Money {
minor,
currency: cur as i32,
}),
outcome: outcome.into(),
drawn,
started_unix_ms: started,
livemode: live,
..Default::default()
}
};
let t = takings(
&[
s(
100,
pb::Currency::Eur,
Some(true),
"granted",
true,
now - day,
),
s(
50,
pb::Currency::Eur,
Some(true),
"granted",
true,
now - 29 * day,
),
s(
80,
pb::Currency::Eur,
Some(false),
"granted",
true,
now - day,
),
s(7, pb::Currency::Sek, None, "granted", true, now),
s(
1_000,
pb::Currency::Eur,
Some(true),
"granted",
true,
now - 31 * day,
),
s(2_000, pb::Currency::Eur, Some(true), "refunded", true, now),
s(3_000, pb::Currency::Eur, Some(true), "declined", false, now),
s(4_000, pb::Currency::Eur, Some(true), "open", false, now),
],
now,
);
assert_eq!(t.live, BTreeMap::from([("EUR".to_string(), 150)]));
assert_eq!(t.test, BTreeMap::from([("EUR".to_string(), 80)]));
assert_eq!(t.unsaid, BTreeMap::from([("SEK".to_string(), 7)]));
assert_eq!(t.window_days, 30);
}
/// ★ owner 2026-10-11: Stripe decides per country, each payment is a row —
/// the row the console renders carries the buyer's country and the rate.
#[test]
fn a_sale_row_carries_the_buyers_country_and_the_vendors_rate() {
let s = pb::Sale {
customer_country: "DE".into(),
vat_basis_points: 1_900,
tax_from_vendor: true,
tax: Some(pb::Money {
minor: 190,
currency: pb::Currency::Eur as i32,
}),
..Default::default()
};
let j = sale_json(&s);
assert_eq!(
(j.customer_country.as_str(), j.vat_basis_points, j.tax_minor),
("DE", 1_900, Some(190))
);
}
}
/// **The number the health line carries**: paid-at-vendor rows a person still
/// has to act on. `None` when monetize could not be asked for it — an older
/// monetize without the service, or a read that failed — which is a different
/// thing from zero, and the badge must not say "nothing open" on the strength
/// of a call that did not happen.
/// The open paid-at-vendor cases of one vendor instance.
async fn site_open(state: &AppState, vendor: &str) -> Option<u64> {
let out = state
.monetize
.reconcile()
.list_paid_at_vendor(pb::ListPaidAtVendorRequest { limit: 0 })
.await
.ok()?
.into_inner();
Some(
out.cases
.iter()
.filter(|c| c.vendor == vendor && c.state == "needs-operator")
.count() as u64,
)
}
async fn paid_at_vendor_open(state: &AppState) -> Option<u64> {
let out = state
.monetize
.reconcile()
.list_paid_at_vendor(pb::ListPaidAtVendorRequest { limit: 1 })
.await
.ok()?
.into_inner();
Some(out.needs_operator)
}
/// How many refused callbacks the Activity page lists. The whole ring: monetize
/// keeps 128 and there is nothing behind it to page into, so asking for fewer
/// would hide rows with no way left to ask for them.
pub const RECENT_REFUSALS: u32 = 128;
/// **`GET /api/callback-refusals`** — the vendor deliveries monetize REFUSED,
/// newest first, with the counter that says how many more there were.
///
/// The failure this exists for is the one that used to be invisible: a customer
/// is charged at the vendor, the webhook arrives, monetize refuses it — a wrong
/// clock, a rotated secret, a payload it stopped understanding — and NOTHING is
/// written. There is no ledger row (correctly: the callback authorised
/// nothing), no signup record, no entitlement move. Measured 2026-09-14 on the
/// front, where a two-hour clock skew put every signed Stripe timestamp outside
/// the 300 s tolerance and the only trace was journald.
///
/// **Everything under `refusals` is UNAUTHENTICATED INPUT** unless its
/// `verified` is true — that path takes no bearer, which is the whole reason a
/// callback can be refused at all. The console must render `detail` as text and
/// must say beside it whose words they are. Nothing here is an instruction, a
/// reference, or a name anything else looks up.
///
/// A read, like `/api/signups` beside it: "money moved at the vendor and
/// monetize holds nothing" is precisely what an operator on duty must be able
/// to see, and a viewer is on duty too. It carries no customer, no amount and
/// no address to leak — monetize refused the delivery before it had any.
pub async fn callback_refusals(State(state): State<AppState>) -> Res<Value> {
let out = state
.monetize
.callbacks()
.list_refusals(pb::ListRefusalsRequest {
limit: RECENT_REFUSALS,
})
.await?
.into_inner();
Ok(Json(json!({
"refusals": out.refusals.iter().map(refusal_json).collect::<Vec<_>>(),
// Both numbers, always: `total_since_start` above `kept` is the only way
// an operator learns that older refusals fell out of the ring, and a
// list that showed the rows without the count would read as complete.
"total_since_start": out.total_since_start,
"kept": out.kept,
"capacity": out.capacity,
"first_at_unix_ms": out.first_at_unix_ms,
"last_at_unix_ms": out.last_at_unix_ms,
})))
}
/// One refused callback for the console. Passed through field for field — this
/// layer joins nothing onto it and looks nothing up from it, which is the whole
/// posture towards a row a stranger can cause.
pub fn refusal_json(r: &pb::CallbackRefused) -> Value {
json!({
"at_unix_ms": r.at_unix_ms,
// `unverified` | `not-understood` | `handle-mismatch`. monetize's own
// word for its own arm; an unrecognised one is shown, never mapped to a
// friendlier one this console invented.
"refusal": r.kind,
"vendor": r.vendor,
// **Whose words `detail` holds.** False is a caller who proved nothing.
"verified": r.verified,
"detail": r.detail,
"body_bytes": r.body_bytes,
"refusal_seq": r.refusal_seq,
})
}
/// One signup row for the console. `state` is the wire enum as a lowercase word
/// (`pending` | `created` | `refused` | `unknown`), the way every other enum
/// reaches this console — the browser must not carry a number whose meaning
/// lives in a `.proto` it never sees.
pub fn signup_json(r: &pb::SignupRow) -> Value {
json!({
"vendor": r.vendor,
"session": r.session,
"state": signup_state_name(r.state),
"username": r.username,
"email": r.email,
"namespace": r.namespace,
"reason": r.reason,
"product": r.product,
"plan": r.plan,
"amount_minor": r.amount.as_ref().map(|m| m.minor).unwrap_or(0),
"currency": r.amount.as_ref().map(|m| currency_name(m.currency)).unwrap_or("SEK"),
// The vendor account's mode, read off the payment's own id (`cs_live_…`).
"livemode": r.livemode,
"paid_at_unix_ms": r.paid_at_unix_ms,
"recorded_at_unix_ms": r.recorded_at_unix_ms,
"decided_at_unix_ms": r.decided_at_unix_ms,
// **Money taken and no account**: what is owed and what a human must do
// about it, as monetize wrote it. The sentence travels rather than being
// rebuilt here — one payment must not have two sets of instructions.
"owed": signup_owed_name(r.owed),
"remedy": r.remedy,
})
}
/// An unrecognised `owed` is `unknown`, never `none`: a row this console does
/// not recognise may still be somebody's money, and the safe wrong answer is
/// the one that makes an operator look.
fn signup_owed_name(v: i32) -> &'static str {
use pb::signup_row::Owed as O;
match O::try_from(v) {
Ok(O::Unspecified) => "none",
Ok(O::Unrecorded) => "unrecorded",
Ok(O::Refund) => "refund",
Ok(O::RefundAndTheNameMayBeSpent) => "refund_and_the_name_may_be_spent",
Ok(O::RenameOrRefund) => "rename_or_refund",
Err(_) => "unknown",
}
}
/// An unrecognised state is `unknown`, never defaulted to `pending`: "we are
/// still working on it" is a promise, and this layer must not make one on
/// behalf of a monetize whose enum it does not recognise.
fn signup_state_name(v: i32) -> &'static str {
use pb::signup_status_response::State as S;
match S::try_from(v) {
Ok(S::Pending) => "pending",
Ok(S::Created) => "created",
Ok(S::Refused) => "refused",
Ok(S::Unknown) | Ok(S::Unspecified) | Err(_) => "unknown",
}
}
#[derive(Debug, Deserialize)]
pub struct SetBody {
/// A free label the operator may leave on a hand-set fact (it rides in
/// the fact's `plan` field, which a purchase fills with the order
/// reference). Empty is the normal answer: "hand-set".
#[serde(default)]
pub label: String,
pub state: String,
#[serde(default)]
pub paid_until_unix_ms: Option<u64>,
/// **Absent means KEEP, not clear** — see [`set_entitlement`], where the
/// three cases are told apart. `Option` and not `#[serde(default)]`
/// precisely so that "the form did not mention caps" and "the caller asked
/// for none" stop being the same value.
#[serde(default)]
pub caps: Option<BTreeMap<String, u64>>,
}
/// `POST /api/entitlement/{product}/{tenant}` — the operator's `Entitlement.Set`:
/// source `operator`, empty signature ("sign it for me"). Admin only, by `role_floor`.
///
/// # The caps a state change must not eat
///
/// `Entitlement.Set` REPLACES a fact; it does not merge into one. The console's
/// "Set entitlement…" form posts a label, a state and a date and says nothing
/// about caps, because caps are bought rather than typed — so while
/// [`SetBody::caps`] was a plain map with `#[serde(default)]`, an absent field
/// arrived as an EMPTY map and moving a paying tenant to `grace` for a week
/// deleted their `pack_bytes` and `cache_bytes`. On gunnar an absent cap means
/// **unlimited** (`gunnar_core::Entitlement::pack_cap`, "0 is unlimited"), so
/// the punishment for not paying was an uncapped plan and an unbounded disk.
///
/// Three cases, kept apart here because this is the last place they still can
/// be — the proto's `caps` is a plain map and cannot express the difference:
///
/// | body | meaning |
/// |---|---|
/// | no `caps` key | **keep** what the tenant already holds |
/// | `"caps": {…}` | **set** exactly that, replacing what is stored |
/// | `"caps": {}` | **clear** — deliberate, typed, and still reachable |
///
/// The carry-forward costs one `Entitlement.Get` on a route an admin hits by
/// hand. A tenant with no fact yet carries nothing forward and is not an error:
/// a first fact must not need a previous one.
pub async fn set_entitlement(
State(state): State<AppState>,
Path((product, tenant)): Path<(String, String)>,
Json(body): Json<SetBody>,
) -> Res<Value> {
let st = state_from_name(&body.state).ok_or_else(|| {
ApiError(Fail::invalid(format!(
"state {:?} is not free, paid, grace, suspended or retention",
body.state
)))
})?;
let caps = match body.caps {
Some(caps) => caps,
None => state
.monetize
.entitlement()
.get(pb::GetEntitlementRequest {
product: product.clone(),
tenant: tenant.clone(),
})
.await?
.into_inner()
.fact
.map(|f| f.caps)
.unwrap_or_default(),
};
let fact = pb::EntitlementFact {
tenant: tenant.clone(),
plan: body.label,
state: st as i32,
paid_until_unix_ms: body.paid_until_unix_ms,
caps,
source: "operator".into(),
signature: Vec::new(),
issued_unix_ms: None,
issued_signature: Vec::new(),
};
let r = state
.monetize
.entitlement()
.set(pb::SetEntitlementRequest {
product: product.clone(),
fact: Some(fact),
})
.await?
.into_inner();
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "entitlement.set", product = %product, tenant = %tenant, pushed = r.pushed, "an operator set an entitlement");
state.events.poke();
let verifier = verifier(&state, &product).await?;
Ok(Json(
json!({ "fact": r.fact.as_ref().map(|f| fact_json(f, &verifier)), "pushed": r.pushed, "push_error": r.push_error }),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fact(tenant: &str, state: pb::State) -> pb::EntitlementFact {
pb::EntitlementFact {
tenant: tenant.into(),
plan: "10gb-se".into(),
state: state as i32,
paid_until_unix_ms: Some(5),
caps: BTreeMap::new(),
source: "payment:invoice/x".into(),
signature: vec![1],
issued_unix_ms: None,
issued_signature: Vec::new(),
}
}
#[test]
fn the_join_is_by_tenant_and_a_fact_less_tenant_is_free() {
let usages = [pb::UsageReport {
tenant: "a".into(),
meters: vec![pb::Meter {
name: "pack_bytes".into(),
value: 7,
}],
measured_at_unix_ms: 9,
}];
let facts = [fact("b", pb::State::Grace), fact("a", pb::State::Paid)];
let rows = join_tenants(&usages, &facts, &Verifier::none());
assert_eq!(rows.len(), 2);
assert_eq!(
(
rows[0].tenant.as_str(),
rows[0].state,
rows[0].meters["pack_bytes"],
rows[0].measured_at_unix_ms
),
("a", "paid", 7, Some(9))
);
assert_eq!(
(
rows[1].tenant.as_str(),
rows[1].state,
rows[1].measured_at_unix_ms
),
("b", "grace", None)
);
let only_usage = join_tenants(&usages, &[], &Verifier::none());
assert_eq!(
(
only_usage[0].state,
only_usage[0].order.as_str(),
only_usage[0].signed
),
("free", "", false)
);
}
#[test]
fn a_cap_on_an_undeclared_meter_is_refused_by_name() {
let declared = vec![MeterInfo {
name: "pack_bytes".into(),
meaning: String::new(),
enforcement: "enforced".into(),
scope: "tenant".into(),
unit: "disk_gib_month".into(),
}];
let mut caps = BTreeMap::new();
caps.insert("pack_bytes".to_string(), 1);
assert!(validate_caps("gunnar", &declared, &caps).is_ok());
caps.insert("lfs_bytes".to_string(), 1);
let e = validate_caps("gunnar", &declared, &caps).unwrap_err();
assert!(e.contains("\"lfs_bytes\"") && e.contains("gunnar"), "{e}");
}
#[test]
fn the_cloud_block_passes_its_currency_through_and_an_older_server_sends_none() {
let cloud = pb::CloudInventory {
provider: "sandbox".into(),
available: true,
monthly_minor: 230,
currency: "SEK".into(),
..Default::default()
};
let f = pb::FleetResponse {
cloud: Some(cloud),
..Default::default()
};
let j = fleet_json(&f).cloud;
assert_eq!(
(j["monthly_minor"].as_u64(), j["currency"].as_str()),
(Some(230), Some("SEK")),
"{j}"
);
let none = fleet_json(&pb::FleetResponse::default()).cloud;
assert_eq!(
none["currency"].as_str(),
Some(""),
"no block is no currency, not a guessed one: {none}"
);
}
/// **A quote arrives with every money figure, its currency, the delta and
/// the iron in words** — and a query-string order parses, or refuses by
/// naming the entry.
#[test]
fn a_quote_is_shaped_with_its_money_delta_and_iron_and_a_caps_query_parses() {
let q = pb::QuoteResponse {
reference: "gunnar/alice/2026-09-05+pack_bytes=107374182400".into(),
price: Some(pb::Money {
minor: 198_000,
currency: pb::Currency::Sek as i32,
}),
cost: Some(pb::Money {
minor: 2_070,
currency: pb::Currency::Eur as i32,
}),
current: [("pack_bytes".to_string(), 10u64 << 30)].into(),
caps: [("pack_bytes".to_string(), 100u64 << 30)].into(),
delta: [("pack_bytes".to_string(), 90u64 << 30)].into(),
resources: vec![pb::Resource {
kind: Some(pb::resource::Kind::Disk(pb::resource::Disk {
server: "gunnar-se-sto1".into(),
gib: 90,
})),
}],
can_serve: false,
reason: "the cloud cannot serve this for alice: short 5 GiB".into(),
period: "month".into(),
words: String::new(),
// The operator console formats its own amount from `price` — this
// test asserts exactly that — so the customer-facing string plays
// no part here.
price_words: String::new(),
};
let j = quote_json(&q);
assert_eq!(
(j.price_minor, j.currency, j.cost_minor, j.cost_currency),
(198_000, "SEK", 2_070, "EUR")
);
assert_eq!(j.delta["pack_bytes"], 90u64 << 30);
assert_eq!(
(j.resources[0].kind.as_str(), j.resources[0].detail.as_str()),
("disk", "90 GiB on gunnar-se-sto1")
);
assert!(!j.can_serve && j.reason.contains("short 5 GiB"));
assert_eq!(
parse_caps_query("pack_bytes=107374182400, cache_bytes=5").unwrap(),
[
("pack_bytes".to_string(), 107_374_182_400u64),
("cache_bytes".to_string(), 5)
]
.into()
);
assert!(parse_caps_query("").unwrap().is_empty());
assert!(parse_caps_query("pack_bytes")
.unwrap_err()
.contains("meter=value"));
assert!(parse_caps_query("pack_bytes=lots")
.unwrap_err()
.contains("\"lots\""));
assert_eq!(state_from_name("paid").map(state_name), Some("paid"));
let list = pb::PriceList {
product: "gunnar".into(),
market: pb::Market::Fi as i32,
currency: pb::Currency::Eur as i32,
units: [("disk_gib_month".to_string(), 200u64)].into(),
};
assert_eq!(
price_list_json(&list),
PriceListJson {
product: "gunnar".into(),
market: "FI".into(),
currency: "EUR".into(),
units: [("disk_gib_month".to_string(), 200u64)].into()
}
);
assert_eq!(market_from_name("se"), Some(pb::Market::Se));
assert_eq!(
policy_json(None).period,
"",
"an older monetize names no period; none is guessed"
);
}
}
// ── The TAX page ──────────────────────────────────────────────────────
/// **`GET /api/tax/periods?year=2026`** — the periods that year is filed in,
/// and the turnover that chose them.
///
/// A separate call from the report because it is a different question and a
/// different answer size: the page asks this once when a year is picked and
/// asks for a report each time a period is clicked. Folding them into one would
/// mean recomputing the whole ledger's turnover on every click of a period the
/// page already has.
pub async fn tax_periods(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Res<Value> {
let year = tax_year(&q)?;
let out = state
.monetize
.tax()
.periods(pb::TaxPeriodsRequest { year })
.await?
.into_inner();
Ok(Json(json!({
"jurisdiction": out.jurisdiction,
"turnover_minor": out.turnover.as_ref().map(|m| m.minor).unwrap_or(0),
"currency": out.turnover.as_ref().map(|m| currency_name(m.currency)).unwrap_or("SEK"),
"turnover_year": out.turnover_year,
"periods": out.periods.iter().map(tax_period_json).collect::<Vec<_>>(),
})))
}
/// **`GET /api/tax/report?year=2026&period=2026-Q1`** — the filled form.
///
/// The CSV rides along on the JSON rather than being a second call: it is the
/// same fold, monetize already rendered it, and a download that re-asked the
/// server could return a file that disagrees with the table the operator is
/// looking at.
pub async fn tax_report(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Res<Value> {
let year = tax_year(&q)?;
let period = q.get("period").cloned().unwrap_or_default();
if period.trim().is_empty() {
return Err(ApiError(Fail::invalid(
"period is required; take one from /api/tax/periods",
)));
}
let out = state
.monetize
.tax()
.report(pb::TaxReportRequest { year, period })
.await?
.into_inner();
Ok(Json(json!({
"jurisdiction": out.jurisdiction,
"period": out.period.as_ref().map(tax_period_json).unwrap_or(Value::Null),
// **Absent stays absent.** A box nobody computed arrives with no
// `amount_minor` at all, and the page prints `—`. Defaulting it to 0
// here would invent a figure at the last hop.
"lines": out.lines.iter().map(|l| json!({
"box_id": l.box_id,
"label": l.label,
"amount_minor": l.amount_minor,
"currency": l.currency,
})).collect::<Vec<_>>(),
"notes": out.notes,
"csv": out.csv,
})))
}
/// The year on a tax query. Refused here rather than passed on when it is not a
/// year at all, so the message names the parameter the browser sent.
fn tax_year(q: &HashMap<String, String>) -> Result<u32, ApiError> {
let raw = q.get("year").map(String::as_str).unwrap_or("").trim();
if raw.is_empty() {
return Err(ApiError(Fail::invalid("year is required, e.g. year=2026")));
}
raw.parse::<u32>()
.map_err(|_| ApiError(Fail::invalid(format!("year={raw:?} is not a year"))))
}
fn tax_period_json(p: &pb::TaxPeriod) -> Value {
json!({ "label": p.label, "kind": p.kind, "from_unix_ms": p.from_unix_ms, "to_unix_ms": p.to_unix_ms })
}
#[cfg(test)]
mod tax_tests {
use super::*;
#[test]
fn a_year_query_is_required_and_must_be_a_year() {
let none: HashMap<String, String> = HashMap::new();
assert!(tax_year(&none)
.expect_err("no year")
.0
.message
.contains("year is required"));
let bad: HashMap<String, String> = [("year".to_string(), "last".to_string())].into();
assert!(tax_year(&bad)
.expect_err("not a year")
.0
.message
.contains("\"last\""));
let good: HashMap<String, String> = [("year".to_string(), " 2026 ".to_string())].into();
assert_eq!(tax_year(&good).ok(), Some(2026));
}
#[test]
fn a_period_travels_with_its_kind_and_its_window() {
let p = pb::TaxPeriod {
label: "2026-Q2".into(),
kind: "quarterly".into(),
from_unix_ms: 10,
to_unix_ms: 20,
};
assert_eq!(
tax_period_json(&p),
json!({ "label": "2026-Q2", "kind": "quarterly", "from_unix_ms": 10, "to_unix_ms": 20 })
);
}
}