carlos / platform Public

console: tier-aware revenue, fleet meters in the view, pool ledger

Task 5 of economics-v2: buildEconomicsView now merges fleetMeters
(Task 3) into each account's meters before pricing, loads
Store.AccountTiers() (Task 4), and overrides the priced row for
pool-semantics tiers ("", "try", "pool"): RevenueUSD=0, MarginFrac=0,
FreeTierPos="pooled" (a new fourth FreeTierPos word, documented in
economics.AccountEconomics's doc comment; PriceAccount itself stays
tier-unaware — the override happens in the view layer per the carried
Task 2 review ruling, to avoid a fleet-only pool account misreporting
"inside" the free tier). Cost stands untouched for pool-tier accounts.

Adds EconomicsView.Pool (PoolLedger: PoolUSD/DrawUSD/HeadroomUSD/
PayingRevenueUSD) and EconomicsView.Tiers, plus a "tiers" key in the
embedded DataJSON payload for Task 6's client-side ledger recompute.
PoolUSD = Pool.RevenueFrac*payingRevenue + Pool.TopupUSD*(PeriodDays/
DaysInMonth) — the monthly top-up prorated to the period. fleetMeters
errors degrade the view (log + append to Warning) rather than failing
the page — the opposite of statementsPass's write-side refusal, and
bounded by the existing 60s sweep cache, not a new throttle.

Sanctioned test-only changes (economics-v2 plan, Task 5): untiered
fixture accounts now default to pool-tier zero revenue, so two
pre-existing tests whose assertions depend on the fixture account
pricing normally needed it explicitly tiered "hosted" via
SetAccountTier — TestLoadEconomicsGolden (economicsview_test.go, as
the plan called out) and, found during the full-suite gate,
TestEconomicsAccountsPageContent (economicspage_test.go), which
asserts a real "partial" free-tier chip against the same shared
golden fixture. No production behavior change in either case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5ea2d002e9a29fff03b40b2f635db10f245ce08c parent ef659cd
4 files changed, +325 −6
  • internal/console/economicspage_test.go +11 −0
  • internal/console/economicsview.go +146 −1
  • internal/console/economicsview_test.go +155 −0
  • internal/economics/compute.go +13 −5
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
index a6523538c..947e428b2 100644
--- a/internal/console/economicspage_test.go
+++ b/internal/console/economicspage_test.go
@@ -370,6 +370,17 @@ func TestEconomicsAccountsPageContent(t *testing.T) {
fx := newConsoleFixture(t)
seedEconomicsGolden(t, fx)
+ // Task 5 (tier-aware revenue): an untiered account is pool-tier by
+ // default and renders FreeTierPos "pooled", not a priced position —
+ // this test wants the fixture priced normally to assert a real
+ // "partial" chip, so it needs the fixture account tiered "hosted".
+ // Same sanctioned pattern as economicsview_test.go's
+ // TestLoadEconomicsGolden (economics-v2 plan, Task 5); no production
+ // behavior change.
+ if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil {
+ t.Fatal(err)
+ }
+
c := signInPaul(t, fx)
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(err)
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go
index 9b6c08de1..fd639fcd7 100644
--- a/internal/console/economicsview.go
+++ b/internal/console/economicsview.go
@@ -69,6 +69,30 @@ type EconomicsView struct {
// 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
+ // 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 prorated monthly
+// top-up), 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.
@@ -107,6 +131,13 @@ type economicsPayload struct {
// additive key, the JS twin of the Go-side project() call's third
// argument.
DaysInMonth int `json:"daysInMonth"`
+ // Tiers mirrors EconomicsView.Tiers (Task 5) — additive key. The pool
+ // ledger itself is NOT part of this payload: "pool" scenario knobs
+ // already travel inside Scenario above, so the JS twin recomputes
+ // PoolLedger client-side from Tiers + Scenario.Pool + Meters the same
+ // way buildEconomicsView does (Task 6), rather than this struct
+ // shipping a second, potentially stale copy of the same numbers.
+ Tiers map[string]string `json:"tiers"`
}
// economicsCacheEntry is one period's cached sweep — the metricsCache
@@ -343,6 +374,42 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
return nil, fmt.Errorf("console: load economics: %w", err)
}
+ // Fleet meters (Task 3's fleetMeters) fold into sweep.PerAccount exactly
+ // like statementsPass's own union merge (economicspass.go) — an account
+ // with ONLY fleet usage and no platform route still needs its meter
+ // keys before universe below is built from sweep.PerAccount's keys, or
+ // it would never get a priced row at all.
+ //
+ // Unlike statementsPass (a WRITE pass, which refuses the whole run on a
+ // fleetMeters error — see that function's doc comment), THIS is a READ
+ // path: a bad registry read or a bucket hiccup under a fleet prefix
+ // degrades to "no fleet meters this render" plus a visible Warning,
+ // never a failed dashboard — the same asymmetry loadRoutesForEconomics
+ // already draws for the registry itself. Called once per
+ // buildEconomicsView call, which is already the unit the 60s
+ // stale-while-revalidate cache (economicsCacheTTL) bounds — no separate
+ // throttling needed here.
+ if fleetMM, ferr := s.fleetMeters(sweepCtx, from, to); ferr != nil {
+ log.Printf("console: load economics: fleet meters unavailable: %v", ferr)
+ msg := fmt.Sprintf("fleet metering unavailable: %v", ferr)
+ if warning == "" {
+ warning = msg
+ } else {
+ warning += "; " + msg
+ }
+ } else {
+ for acct, mm := range fleetMM {
+ dst := sweep.PerAccount[acct]
+ if dst == nil {
+ dst = map[string]float64{}
+ sweep.PerAccount[acct] = dst
+ }
+ for meter, v := range mm {
+ dst[meter] += v
+ }
+ }
+ }
+
sc, scenarioWarning := s.loadActiveScenario(sweepCtx)
scenarioNames, err := s.listScenarioNames(sweepCtx)
@@ -358,6 +425,10 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
if err != nil {
return nil, fmt.Errorf("console: load economics: %w", err)
}
+ tiers, err := s.store.AccountTiers()
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: %w", err)
+ }
universe := map[string]bool{}
for acct := range sweep.PerAccount {
universe[acct] = true
@@ -392,7 +463,24 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
mm = map[string]float64{}
}
meters[acct] = mm
- accountRows = append(accountRows, economics.PriceAccount(sc, acct, mm, totalAllocated[acct]))
+ row := economics.PriceAccount(sc, acct, mm, totalAllocated[acct])
+ // Tier-aware revenue (Task 5): PriceAccount stays tier-unaware by
+ // design (see its own doc comment) — a pool/try/"" account's
+ // revenue is pooled, not individually priced, so this view
+ // overrides the priced row for those tiers AFTER PriceAccount runs.
+ // Its cost stands untouched. FreeTierPos is overridden too, not
+ // left at whatever PriceAccount computed: a fleet-only pool account
+ // can carry real (fleet) revenue under PriceAccount's tier-unaware
+ // maths, so naively zeroing revenue and recomputing "inside" off
+ // that zero would misreport it as sitting inside the free tier
+ // rather than pooled — the carried ruling this override exists to
+ // satisfy.
+ if isPoolTier(tierOf(tiers, acct)) {
+ row.RevenueUSD = 0
+ row.MarginFrac = 0
+ row.FreeTierPos = "pooled"
+ }
+ accountRows = append(accountRows, row)
}
// CostUSD desc, Account asc on ties — a stable tie-break so the
// zero-cost tail (every account with no usage this period, all tied at
@@ -404,6 +492,36 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
return accountRows[i].Account < accountRows[j].Account
})
+ // PoolLedger (Task 5): PayingRevenueUSD sums the revenue of every
+ // account priced normally (hosted/fleet/dedicated — i.e. everything
+ // NOT pool-tier, since accountRows already carries the RevenueUSD=0
+ // override above for pool/try/"" accounts, summing them contributes
+ // nothing extra either way); DrawUSD sums the COST of every pool-tier
+ // account, which is real spend the pool must cover since those
+ // accounts carry no individual revenue of their own. TopupUSD is a
+ // flat MONTHLY figure, prorated to this period exactly like the
+ // dashboard's own MTD-vs-full-month projection denominator
+ // (EconomicsView.DaysInMonth's doc comment): PeriodDays/DaysInMonth,
+ // so a partial "mtd" window doesn't claim a whole month's top-up.
+ // Headroom is Pool-Draw and MAY be negative — rendered honestly, never
+ // clamped (brief).
+ var payingRevenue, poolDraw float64
+ for _, row := range accountRows {
+ if isPoolTier(tierOf(tiers, row.Account)) {
+ poolDraw += row.CostUSD
+ } else {
+ payingRevenue += row.RevenueUSD
+ }
+ }
+ poolUSD := sc.Pool.RevenueFrac*payingRevenue +
+ sc.Pool.TopupUSD*(float64(sweep.PeriodDays)/float64(daysInMonth))
+ pool := PoolLedger{
+ PoolUSD: poolUSD,
+ DrawUSD: poolDraw,
+ HeadroomUSD: poolUSD - poolDraw,
+ PayingRevenueUSD: payingRevenue,
+ }
+
// Revenue attribution to boxes: for each account, boxShare =
// allocated[box][acct] / totalAllocated[acct] (0 when the total is 0),
// box.RevenueUSD += acct.RevenueUSD * boxShare (brief step 3 formula).
@@ -449,6 +567,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
CoveredDays: sweep.CoveredDays,
PeriodDays: sweep.PeriodDays,
DaysInMonth: daysInMonth,
+ Tiers: tiers,
}
raw, err := json.Marshal(payload)
if err != nil {
@@ -470,9 +589,35 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
PeriodDays: sweep.PeriodDays,
DaysInMonth: daysInMonth,
DataJSON: template.JS(raw),
+ Tiers: tiers,
+ Pool: pool,
}, nil
}
+// tierOf returns acct's fleet-pool tier from tiers — "" (pool semantics,
+// same as the explicit "pool" tier — see isPoolTier) both when acct is
+// simply absent from tiers (usage with no matching console account row,
+// e.g. a fleet-only account) and when it's present with the explicit ""
+// tier (a live account never yet placed). A plain map index already
+// returns the zero value for a missing key, so this exists purely to give
+// that lookup one named call site, per the brief.
+func tierOf(tiers map[string]string, acct string) string {
+ return tiers[acct]
+}
+
+// isPoolTier reports whether tier draws from the shared fleet-pool rather
+// than being individually priced — the "", "try" and "pool" tiers (Task 4's
+// validAccountTiers minus the three that price normally: hosted, fleet,
+// dedicated).
+func isPoolTier(tier string) bool {
+ switch tier {
+ case "", "try", "pool":
+ return true
+ default:
+ return false
+ }
+}
+
// loadRoutesForEconomics resolves the routes sweepUsage needs to attribute
// sampled units to accounts, DEGRADING rather than failing when the
// registry is missing or unreadable — the opposite of openPassRegistry's
diff --git a/internal/console/economicsview_test.go b/internal/console/economicsview_test.go
index dba360bc3..58b3b794b 100644
--- a/internal/console/economicsview_test.go
+++ b/internal/console/economicsview_test.go
@@ -83,6 +83,15 @@ func TestLoadEconomicsGolden(t *testing.T) {
putStoreJSON(t, fx.rel, economics.ScenarioKey("test"), sc)
putStoreJSON(t, fx.rel, economics.ActiveKey, economics.Active{Schema: 1, Name: "test"})
+ // Task 5 (tier-aware revenue): an untiered account is pool-tier by
+ // default and prices at RevenueUSD=0 — this test's revenue assertions
+ // need the fixture account tiered "hosted" to keep meaning under that
+ // rule. Sanctioned test-only change (economics-v2 plan, Task 5); no
+ // production behavior here.
+ if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil {
+ t.Fatal(err)
+ }
+
view, err := fx.srv.LoadEconomics(ctx, "mtd")
if err != nil {
t.Fatal(err)
@@ -164,6 +173,152 @@ func TestLoadEconomicsGolden(t *testing.T) {
}
}
+// TestLoadEconomicsTierRevenueAndPool: tier-aware pricing (Task 5). A
+// hosted-tier account prices normally — economics.PriceAccount's own
+// RevenueUSD stands. A pool-tier account, even one with real cost AND fleet
+// meters merged into its meters map (interface (a)'s merge, proven via
+// view.Meters below), gets RevenueUSD=0/MarginFrac=0 and FreeTierPos
+// overridden to "pooled" in the VIEW layer — never economics.PriceAccount's
+// own "inside"/"partial"/"paying", which is exactly the trap the carried
+// ruling calls out: a fleet-only pool account has real (fleet) revenue by
+// PriceAccount's tier-unaware maths, so naively zeroing revenue and then
+// recomputing FreeTierPos off that zero would misreport it "inside" the
+// free tier rather than "pooled". PoolLedger sums the paying side's revenue
+// and the pool side's cost per economicsview.go's documented formula,
+// top-up prorated to the period via view's own PeriodDays/DaysInMonth.
+func TestLoadEconomicsTierRevenueAndPool(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance",
+ Status: "ready", App: fixtureApp, Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+
+ date := usage.DayOf(time.Now())
+ cpu, mem := 0.5, 1e9
+ roll := metrics.DayRollup{V: 1, Box: "box1", Date: date, Hours: map[string]metrics.HourAgg{
+ "09": {Units: map[string]map[string]metrics.Agg{
+ "carlos-app@a.example.com.service": {
+ "cpu": {Avg: cpu, N: 60},
+ "mem_bytes": {Avg: mem, N: 60},
+ },
+ }},
+ }}
+ putStoreJSON(t, fx.rel, metrics.DayKey("box1", date), roll)
+
+ // A second, pool-tier account with no console app and no registered
+ // route of its own — CreateAccount alone is enough for it to land in
+ // s.store.Accounts() and therefore the pricing universe.
+ poolID := testAccountID()
+ poolAcct := "poolsqid"
+ if err := fx.st.CreateAccount(poolID, poolAcct, "Poolco", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ // Edge usage for both accounts on the same box's edge day: fixtureAcct
+ // gets the golden test's revenue-bearing traffic, poolAcct gets its own
+ // smaller row — EdgeRow.Account is read directly by sweepUsage with no
+ // registry route required (economicspass.go's edge loop).
+ putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", date), usage.EdgeDay{
+ V: 1, Box: "box1", Date: date,
+ Hours: map[string]map[string]usage.EdgeRow{"09": {
+ "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9},
+ "b.example.com": {Account: poolAcct, App: "poolapp", Requests: 1000, BytesOut: 1e9},
+ }},
+ })
+
+ // Fleet meters (Task 3) attributed to poolAcct — proves interface (a)'s
+ // merge reaches an account that never touched a platform route at all.
+ if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: poolAcct, Bucket: "fx-data"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx1", TokenHash: strings.Repeat("a", 64)}); err != nil {
+ t.Fatal(err)
+ }
+ putStoreJSON(t, fx.rel, "fleets/fx/"+metrics.DayKey("bx1", date), metrics.DayRollup{
+ V: 1, Box: "bx1", Date: date, Hours: map[string]metrics.HourAgg{
+ "09": {Host: map[string]metrics.Agg{"cpu": {N: 60}}},
+ },
+ })
+
+ sc := economics.DefaultScenario()
+ sc.Name = "test"
+ putStoreJSON(t, fx.rel, economics.ScenarioKey("test"), sc)
+ putStoreJSON(t, fx.rel, economics.ActiveKey, economics.Active{Schema: 1, Name: "test"})
+
+ if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.SetAccountTier(poolID, "pool"); err != nil {
+ t.Fatal(err)
+ }
+
+ view, err := fx.srv.LoadEconomics(ctx, "mtd")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var hostedRow, poolRow *economics.AccountEconomics
+ for i := range view.Accounts {
+ switch view.Accounts[i].Account {
+ case fixtureAcct:
+ hostedRow = &view.Accounts[i]
+ case poolAcct:
+ poolRow = &view.Accounts[i]
+ }
+ }
+ if hostedRow == nil {
+ t.Fatalf("no AccountEconomics row for hosted %q in %+v", fixtureAcct, view.Accounts)
+ }
+ if poolRow == nil {
+ t.Fatalf("no AccountEconomics row for pool %q in %+v", poolAcct, view.Accounts)
+ }
+
+ if hostedRow.RevenueUSD <= 0 {
+ t.Fatalf("hostedRow.RevenueUSD = %v, want > 0", hostedRow.RevenueUSD)
+ }
+ if poolRow.RevenueUSD != 0 {
+ t.Fatalf("poolRow.RevenueUSD = %v, want 0 (pool tier)", poolRow.RevenueUSD)
+ }
+ if poolRow.MarginFrac != 0 {
+ t.Fatalf("poolRow.MarginFrac = %v, want 0 (pool tier)", poolRow.MarginFrac)
+ }
+ if poolRow.CostUSD <= 0 {
+ t.Fatalf("poolRow.CostUSD = %v, want > 0 (its cost stands)", poolRow.CostUSD)
+ }
+ if poolRow.FreeTierPos != "pooled" {
+ t.Fatalf("poolRow.FreeTierPos = %q, want %q", poolRow.FreeTierPos, "pooled")
+ }
+ if got := view.Meters[poolAcct][usage.MeterFleetBoxHoursServer]; got <= 0 {
+ t.Fatalf("Meters[%q][fleet_box_hours_server] = %v, want > 0 (fleet meters merged)", poolAcct, got)
+ }
+
+ if got, want := view.Pool.PayingRevenueUSD, hostedRow.RevenueUSD; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("Pool.PayingRevenueUSD = %v, want %v (only hosted/fleet/dedicated revenue)", got, want)
+ }
+ wantPoolUSD := sc.Pool.RevenueFrac*view.Pool.PayingRevenueUSD +
+ sc.Pool.TopupUSD*(float64(view.PeriodDays)/float64(view.DaysInMonth))
+ if math.Abs(view.Pool.PoolUSD-wantPoolUSD) > 1e-9 {
+ t.Fatalf("Pool.PoolUSD = %v, want %v", view.Pool.PoolUSD, wantPoolUSD)
+ }
+ if math.Abs(view.Pool.DrawUSD-poolRow.CostUSD) > 1e-9 {
+ t.Fatalf("Pool.DrawUSD = %v, want %v (sum of pool-tier CostUSD)", view.Pool.DrawUSD, poolRow.CostUSD)
+ }
+ wantHeadroom := view.Pool.PoolUSD - view.Pool.DrawUSD
+ if math.Abs(view.Pool.HeadroomUSD-wantHeadroom) > 1e-9 {
+ t.Fatalf("Pool.HeadroomUSD = %v, want %v (Pool - Draw)", view.Pool.HeadroomUSD, wantHeadroom)
+ }
+
+ if !strings.Contains(string(view.DataJSON), `"tiers"`) {
+ t.Fatalf("DataJSON missing %q: %s", "tiers", view.DataJSON)
+ }
+}
+
// TestLoadEconomicsWarnsOnMissingNamedScenario: active.json naming a
// scenario that doesn't exist under economics/scenarios/ is a WARNING, not
// silence — the spec distinguishes "no active.json at all" (fresh
diff --git a/internal/economics/compute.go b/internal/economics/compute.go
index 1fece7271..b10b3e471 100644
--- a/internal/economics/compute.go
+++ b/internal/economics/compute.go
@@ -67,11 +67,19 @@ func AllocateBox(rc RateCard, al Allocation, b BoxLoad) (perAccount map[string]f
// AccountEconomics is one account priced under one scenario.
type AccountEconomics struct {
- Account string
- CostUSD float64
- RevenueUSD float64
- MarginFrac float64 // (revenue-cost)/revenue, 0 when revenue is 0
- FreeTierPos string // "inside" | "partial" | "paying"
+ Account string
+ CostUSD float64
+ RevenueUSD float64
+ MarginFrac float64 // (revenue-cost)/revenue, 0 when revenue is 0
+ // FreeTierPos is "inside" | "partial" | "paying" as computed by this
+ // function — plus a fourth word, "pooled", that PriceAccount itself
+ // never produces: economics-v2's fleet-pool tiers (try/pool/"") draw
+ // from a shared pool rather than being individually priced, so the
+ // console view layer (buildEconomicsView, internal/console/
+ // economicsview.go) overrides FreeTierPos to "pooled" for those tiers
+ // AFTER calling PriceAccount, rather than this function knowing about
+ // tiers at all — PriceAccount stays tier-unaware by design (Task 5).
+ FreeTierPos string
}
func billable(meters map[string]float64, meter string, allowance float64) float64 {