// Console-side read path for unit economics: assembling what the operator // /fleet/economics dashboard (Task 13) renders out of sweepUsage's per-day // accumulation (economicspass.go), an active pricing scenario // (internal/economics), and the two most recent real bills. Like // metricsview.go this is a bucket sweep plus an in-process cache — nothing // here writes anything. package console import ( "context" "encoding/json" "errors" "fmt" "html/template" "log" "os" "sort" "strings" "sync" "time" "github.com/carlosframework/platform/internal/economics" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/usage" ) // economicsCacheTTL/economicsStaleServeCeiling/economicsSweepTimeout mirror // metricsview.go's metricsCacheTTL/metricsStaleServeCeiling/ // metricsSweepTimeout exactly (same 60s freshness window, same 2-minute // stale-while-revalidate ceiling) — see those constants' doc comments for // the full reasoning, which applies here unchanged: LoadEconomics is called // from the same kind of per-request path (the dashboard page) and must not // make every request pay for a fresh bucket sweep. const ( economicsCacheTTL = 60 * time.Second economicsStaleServeCeiling = 2 * time.Minute economicsSweepTimeout = 15 * time.Second ) // EconomicsView is one priced period, ready to render. type EconomicsView struct { GeneratedAt time.Time Period string Scenario economics.Scenario // active (bucket) or DefaultScenario fallback // ScenarioWarning is set when active.json named a scenario that could // not be loaded/parsed/validated — never set when active.json is simply // absent (a fresh deployment, not a fault; see loadActiveScenario). ScenarioWarning string // Warning is set when the SWEEP itself degraded — currently only "no // registry configured/reachable", which makes every sampled unit // attribute to release.OpsAccount instead of its real account (see // loadRoutesForEconomics). Distinct from ScenarioWarning: the two can // both be set at once and Task 13 renders them as separate notices. Warning string Scenarios []string // names under economics/scenarios/ Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc, Account asc on ties Meters map[string]map[string]float64 // account -> meter (for embedding) Boxes []BoxEconomics Bills []economics.Bill // this month + previous, if present CoveredDays, PeriodDays int // DaysInMonth is the CURRENT real calendar month's length (statementsPass's // own daysInMonth derivation: time.Date(y, mo+1, 0, ...).Day()) — // deliberately NOT PeriodDays, which on "mtd" is only the number of days // elapsed so far this month. economics.Project's mtd->full-month // projection needs the FULL month's length as its target denominator's // mate; PeriodDays there would make the projected figure equal plain MTD // whenever coverage is complete, which is a labelled projection that // doesn't project (the honesty rule's whole point). Populated on every // view regardless of Period — only "mtd" ever renders it. DaysInMonth int DataJSON template.JS // the embedded payload — see economicsPayload // Tiers is every live account's fleet-pool tier, keyed by sqid (Store. // AccountTiers, Task 4) — Task 6's econAccountRow reads it at render. // An account absent from this map (usage with no console row at all, // e.g. a fleet-only account whose owner never signed up) carries pool // semantics, same as an account present with the explicit "" tier — see // tierOf. Shared, read-only, like Scenario/Boxes/Bills — see // copyEconomicsView's doc comment. Tiers map[string]string // Names is every live account's display name, keyed by sqid (the SAME // s.store.Accounts() read buildEconomicsView already performs to build // the universe/Tiers, Account.Sqid -> Account.Name) — account-names // batch. Display + search only: the join/key universe (meters, // statements, tiers, cursors, form values) stays sqid-shaped everywhere // unchanged; this map exists purely so the render layer can show a // human name next to the sqid it already has. An account absent from // this map (usage with no matching console account row at all, e.g. a // fleet-only account whose owner never signed up) has no name to show — // same "index returns the zero value" shape as Tiers/tierOf, and the // render layer falls back to the bare sqid for it, exactly as before // this map existed. Shared, read-only, like Scenario/Boxes/Bills/Tiers // — see copyEconomicsView's doc comment; gets no defensive copy of its // own for the same reason Tiers doesn't. Names map[string]string // Pool is this period's fleet-pool ledger (Task 5) — see PoolLedger. Pool PoolLedger } // PoolLedger is one period's fleet-pool revenue-sharing standing: how much // the pool holds (a share of paying revenue plus a day-accrued monthly // top-up — see poolTopupAccrual), how much pool-tier accounts have drawn // against it in cost, and the headroom between the two — which MAY be // negative (the pool is overdrawn this period) and must be rendered // honestly, not clamped to zero. See buildEconomicsView's doc comment for // the exact formula; the // dashboard's inline knob JS (Task 6) recomputes this same ledger // client-side from DataJSON's "tiers"/"scenario"/"meters" rather than this // struct being marshalled itself — nothing here carries a json tag. type PoolLedger struct { PoolUSD, DrawUSD, HeadroomUSD float64 PayingRevenueUSD float64 // the revenue the frac applies to } // BoxEconomics is one box, priced under Scenario over the period. type BoxEconomics struct { Box string Hours float64 CostUSD float64 IdleUSD float64 RevenueUSD float64 // Σ over tenants: their revenue × (this box's allocated share / their total allocated cost) Utilization float64 // 1 - IdleUSD/CostUSD (0 when CostUSD is 0) EgressMetered bool } // economicsPayload is DataJSON's shape: everything Task 13's inline knob JS // needs to redo AllocateBox/PriceAccount client-side when an operator tweaks // a scenario knob live, without a server round trip. Field names are frozen // by that JS — change both together (economics.go's PriceAccount doc carries // the same warning for the pricing formulas themselves). Every field, // including the nested economics.BoxLoad/BoxAccountLoad values inside // BoxLoads, carries an explicit snake_case json tag (compute.go) — one // convention for the whole payload, not a mix of Go field names and // snake_case. json.Marshal HTML-escapes '<', '>' and '&' by default (unlike // an Encoder with SetEscapeHTML(false)) — that default is exactly what // keeps this safe to embed inside a literal