carlos / platform Public

Merge pull request #197 from carlosframework/business-model

Carloku business model: the ladder, the pool, fleet pricing
Paul Campbell c4fb7309015e8f80e0004338d15bcf8e84efd992 parent 8371cf8parent 34b0ad8
19 files changed, +2434 −54
  • docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md +291 −0
  • docs/superpowers/specs/2026-08-13-carloku-business-model-design.md +214 −0
  • internal/console/econlistquery.go +8 −3
  • internal/console/econlistquery_test.go +40 −0
  • internal/console/economicspage_test.go +409 −0
  • internal/console/economicspass.go +293 −6
  • internal/console/economicspass_test.go +244 −0
  • internal/console/economicsview.go +220 −12
  • internal/console/economicsview_test.go +199 −0
  • internal/console/server.go +42 −0
  • internal/console/store.go +56 −0
  • internal/console/templates/economics.html +142 −6
  • internal/console/templates/economics_accounts.html +68 −8
  • internal/console/templates/layout.html +20 −1
  • internal/economics/compute.go +30 −9
  • internal/economics/compute_test.go +55 −0
  • internal/economics/scenario.go +44 −1
  • internal/economics/scenario_test.go +50 −1
  • internal/usage/usage.go +9 −7
diff --git a/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md b/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md
new file mode 100644
index 000000000..464b8a5d6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md
@@ -0,0 +1,291 @@
+# Economics v2 (fleet meters, pricing axes, pool ledger, tiers) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the business-model spec measurable: fleet box-hours metered per account with device classes, the new pricing knobs (box-months, mem overage, pool), the pool ledger on `/fleet/economics`, and an account-tier marker.
+
+**Architecture:** Pure extension of the live unit-economics machinery (`2026-08-11-unit-economics-design.md` + this week's code): two new frozen meter names derived in the statements pass from registry fleet rows × metrics presence; additive `Scenario` fields with `Validate` guards; tier as a console-store column consumed by the view; pool ledger as arithmetic in `buildEconomicsView` + one tile. Spec: `docs/superpowers/specs/2026-08-13-carloku-business-model-design.md`.
+
+**Tech Stack:** Go stdlib, sqlite console store, existing `release.Store` bucket I/O, Go templates + inline JS twins (no external subresources).
+
+## Global Constraints
+
+- Gate before any commit: `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` clean on touched files, `GOOS=darwin go build ./...`. `internal/activator` has a known unrelated timing flake — isolated rerun acceptable.
+- Env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox-disable on "read-only file system".
+- Branch: `business-model` (spec lives there; PR #197). Everything lands through that PR — never merge to main locally (repo rule 2026-08-10).
+- FROZEN meter names, chosen once here: `fleet_box_hours_server`, `fleet_box_hours_device`. Additive to `internal/usage`'s vocabulary; never rename.
+- All bucket/JSON shapes additive; `Scenario.Schema` stays 1; old scenarios must parse (new fields omitempty/zero-default with semantics "feature off").
+- DEVICE CLASS RULING (adjusted from spec — tag grammar has no `=`): a fleet box is device-class iff its `FleetBox.Tags` comma-list contains the literal tag `device`; otherwise server-class. Update the spec's "Device class is a tag" bullet to name the bare `device` tag in Task 8.
+- Tier values: `""` (unset, treated as `pool`), `try`, `pool`, `hosted`, `fleet`, `dedicated`. Revenue is computed ONLY for `hosted`/`fleet`/`dedicated`; `try`/`pool`/`""` accounts price to zero revenue and their COST is the pool draw.
+- Console gates: operator pages/doors keep the exact patterns now in `economicspass.go` (method-check first on POST doors, then session → IsOperator → bare 404, then CSRF).
+- Volume break applies when average concurrent fleet boxes ≥ threshold: `(server_hours+device_hours)/730 >= MinBoxes`.
+- Money float64 USD; JS twins in `econ_maths_js` must mirror Go formulas exactly.
+
+---
+
+### Task 1: Meter names + scenario knobs
+
+**Files:**
+- Modify: `internal/usage/usage.go` (two constants)
+- Modify: `internal/economics/scenario.go`
+- Test: `internal/economics/scenario_test.go` (append)
+
+**Interfaces:**
+- Produces (later tasks consume verbatim): `usage.MeterFleetBoxHoursServer = "fleet_box_hours_server"`, `usage.MeterFleetBoxHoursDevice = "fleet_box_hours_device"`; on `RateSheet`: `BoxMonthServerUSD float64 \`json:"box_month_server_usd"\``, `BoxMonthDeviceUSD float64 \`json:"box_month_device_usd"\``, `MemGBHourUSD float64 \`json:"mem_gb_hour_usd"\``; new structs on `Scenario`: `FleetBreak struct { MinBoxes float64 \`json:"min_boxes"\`; Discount float64 \`json:"discount"\` } \`json:"fleet_break"\`` and `Pool struct { RevenueFrac float64 \`json:"revenue_frac"\`; TopupUSD float64 \`json:"topup_usd"\` } \`json:"pool"\``.
+- `DefaultScenario()` gains: sheet box prices 5 and 1, mem 0 (off), `FleetBreak{MinBoxes: 10, Discount: 0.4}`, `Pool{RevenueFrac: 0.10, TopupUSD: 200}`.
+- `Validate()` additions: `FleetBreak.Discount` in [0,1); `MinBoxes >= 0`; `Pool.RevenueFrac` in [0,1]; `TopupUSD >= 0`; all three sheet prices `>= 0`.
+
+- [ ] **Step 1: Failing tests** — append to `scenario_test.go`:
+
+```go
+func TestDefaultScenarioV2Knobs(t *testing.T) {
+ sc := DefaultScenario()
+ if sc.Sheet.BoxMonthServerUSD != 5 || sc.Sheet.BoxMonthDeviceUSD != 1 {
+ t.Fatalf("box prices = %v/%v", sc.Sheet.BoxMonthServerUSD, sc.Sheet.BoxMonthDeviceUSD)
+ }
+ if sc.FleetBreak.MinBoxes != 10 || sc.FleetBreak.Discount != 0.4 {
+ t.Fatalf("fleet break = %+v", sc.FleetBreak)
+ }
+ if sc.Pool.RevenueFrac != 0.10 || sc.Pool.TopupUSD != 200 {
+ t.Fatalf("pool = %+v", sc.Pool)
+ }
+ if err := sc.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateRejectsV2(t *testing.T) {
+ cases := []func(*Scenario){
+ func(s *Scenario) { s.FleetBreak.Discount = 1.0 },
+ func(s *Scenario) { s.FleetBreak.Discount = -0.1 },
+ func(s *Scenario) { s.FleetBreak.MinBoxes = -1 },
+ func(s *Scenario) { s.Pool.RevenueFrac = 1.5 },
+ func(s *Scenario) { s.Pool.TopupUSD = -5 },
+ func(s *Scenario) { s.Sheet.MemGBHourUSD = -0.01 },
+ }
+ for i, mutate := range cases {
+ sc := DefaultScenario()
+ sc.Name = "test"
+ mutate(&sc)
+ if err := sc.Validate(); err == nil {
+ t.Fatalf("case %d validated", i)
+ }
+ }
+}
+
+func TestOldScenarioStillParses(t *testing.T) {
+ old := `{"schema":1,"name":"x","rate_card":{"boxes":{"":{"hour_usd":0.02,"cores":2,"mem_gb":2}}},"allocation":{"policy":"cpu"},"mode":"cost-plus","margin":0.8}`
+ var sc Scenario
+ if err := json.Unmarshal([]byte(old), &sc); err != nil {
+ t.Fatal(err)
+ }
+ if sc.Pool.RevenueFrac != 0 || sc.FleetBreak.MinBoxes != 0 {
+ t.Fatalf("zero-defaults broken: %+v %+v", sc.Pool, sc.FleetBreak)
+ }
+}
+```
+
+(`encoding/json` import may be needed in the test file.)
+- [ ] **Step 2: Run** `go test ./internal/economics/ -count=1` — FAIL (undefined fields).
+- [ ] **Step 3: Implement** — the two constants in `usage.go` beside the existing `Meter*` block; the fields/structs/defaults/validation exactly as the Interfaces block states. Zero values must mean "off/permissive" so `TestOldScenarioStillParses` holds (a zero `FleetBreak` never discounts because `MinBoxes 0` with `Discount 0` is a no-op discount; guard division nowhere needed).
+- [ ] **Step 4: Run** `go test ./internal/economics/ ./internal/usage/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git add internal/usage/ internal/economics/ && git commit -m "economics: v2 knobs — fleet box prices, mem overage, break, pool"`
+
+---
+
+### Task 2: PriceAccount v2 — fleet pricing, break, mem overage (golden)
+
+**Files:**
+- Modify: `internal/economics/compute.go`
+- Test: `internal/economics/compute_test.go` (append)
+
+**Interfaces:**
+- Consumes: Task 1's fields; existing `PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics` — signature UNCHANGED (meters carry the new keys).
+- Canonical formulas (JS twin mirrors in Task 6):
+ - `fleetRevenue = (m[fleet_box_hours_server]/730)*Sheet.BoxMonthServerUSD + (m[fleet_box_hours_device]/730)*Sheet.BoxMonthDeviceUSD`
+ - break: `avgBoxes = (m[server]+m[device])/730`; if `FleetBreak.MinBoxes > 0 && avgBoxes >= MinBoxes` then `fleetRevenue *= (1 - Discount)`.
+ - Fleet revenue adds in BOTH modes (fleet boxes bill per-box regardless of cost-plus/rate-sheet for apps — the one rule).
+ - mem overage (rate-sheet mode only, since cost-plus already recovers mem via allocated cost): `overage = max(0, m[mem_gb_hours] - FreeTier.MemGBHours) * Sheet.MemGBHourUSD` added to revenue.
+ - FreeTierPos unchanged (fleet meters have no allowance axis).
+
+- [ ] **Step 1: Failing tests** — append (uses the file's existing `approx` helper):
+
+```go
+func TestPriceAccountFleetBoxes(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5, BoxMonthDeviceUSD: 1}
+ sc.FreeTier = FreeTier{}
+ // 2 server boxes + 5 device boxes, full month.
+ meters := map[string]float64{
+ usage.MeterFleetBoxHoursServer: 2 * 730,
+ usage.MeterFleetBoxHoursDevice: 5 * 730,
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "fleet revenue", ae.RevenueUSD, 2*5+5*1) // $15
+}
+
+func TestPriceAccountFleetVolumeBreak(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5}
+ sc.FleetBreak = FleetBreak{MinBoxes: 10, Discount: 0.4}
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{usage.MeterFleetBoxHoursServer: 12 * 730}
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "discounted", ae.RevenueUSD, 12*5*0.6) // $36
+ // 9.99 avg boxes: no break.
+ meters[usage.MeterFleetBoxHoursServer] = 9.99 * 730
+ ae = PriceAccount(sc, "bae", meters, 0)
+ approx(t, "no break", ae.RevenueUSD, 9.99*5)
+}
+
+func TestPriceAccountFleetAddsInCostPlusToo(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "cost-plus"
+ sc.Margin = 0.5
+ sc.Sheet.BoxMonthServerUSD = 5
+ sc.RateCard.EgressGBUSD = 0.10
+ sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{
+ usage.MeterEgressGB: 10, // $1 cost -> $2 cost-plus revenue
+ usage.MeterFleetBoxHoursServer: 1 * 730, // + $5 box-month
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "combined", ae.RevenueUSD, 2+5)
+}
+
+func TestPriceAccountMemOverage(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{MemGBHourUSD: 0.01}
+ sc.FreeTier = FreeTier{MemGBHours: 100}
+ meters := map[string]float64{usage.MeterMemGBHours: 400}
+ ae := PriceAccount(sc, "bab", meters, 0)
+ approx(t, "overage", ae.RevenueUSD, 3.0) // 300 over * $0.01
+}
+```
+
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** in `PriceAccount` exactly per the canonical formulas; fleet revenue computed once, added after the mode switch (both modes); mem overage inside the rate-sheet branch. Update `PriceAccount`'s doc comment's JS-mirror warning to name the new formulas.
+- [ ] **Step 4: Run** `go test ./internal/economics/ -count=1` — PASS (all pre-existing goldens untouched).
+- [ ] **Step 5: Commit** `git commit -am "economics: fleet box-month pricing with volume break, mem overage"`
+
+---
+
+### Task 3: Fleet hours derivation in the statements pass
+
+**Files:**
+- Modify: `internal/console/economicspass.go`
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Consumes: `registry.Registry.ListFleets() ([]registry.Fleet, error)` and `ListFleetBoxes(fleet string) ([]registry.FleetBox, error)` (`internal/registry/fleets.go:215,405`); `FleetBox.Tags` comma-list (`device` tag = device class); `Fleet.Account` (sqid domain, same as Route.Account); metrics day rollups already read by `sweepUsage` (per-box `metrics.DayRollup.Hours[hour].Units` — a box's PRESENT hour = any hour key in its rollup, hours counted as `len(Hours)`... NO: presence must count sample time. RULING: a fleet box's presence for a day = `Σ over hours of (max unit N in that hour)/60` — but fleet rollups may carry only host-level aggregates; USE the host CPU agg: `HourAgg.Host["cpu"].N` minutes when present, else the max N across `Host` entries; fallback when Host empty: 60 minutes per present hour key. Implement helper `rollupPresenceHours(d metrics.DayRollup) float64` with exactly that rule and unit-test it).
+- Produces: `(*Server).fleetMeters(ctx, from, to time.Time) (map[string]map[string]float64, error)` — account sqid → {fleet_box_hours_server, fleet_box_hours_device}; called by `statementsPass` (merged into each account's meters before `UpsertStatement`) and by `buildEconomicsView` (merged into `sweep.PerAccount` copies — Task 5 wires the view side).
+- Mapping rule: metrics box label → fleet box: build `map[label]struct{acct string; device bool}` from `ListFleets`×`ListFleetBoxes` (ALL statuses — a detached box's past hours still bill); label collisions across fleets: first fleet wins alphabetically + one log line per pass (registry enforces per-fleet uniqueness only).
+- Boxes that are NOT fleet boxes (platform boxes) contribute nothing here.
+
+- [ ] **Step 1: Failing tests** — append: `TestRollupPresenceHours` (rollup with 2 hours: one with Host cpu N=60 → 1.0h, one with empty Host map → 1.0h fallback; total 2.0); `TestStatementsPassFleetMeters`: seed fixture registry with a fleet (`fx` helper — check how fleets tests seed `registry` fleets: grep `UpsertFleet\|AddFleetBox` in `internal/console/*_test.go` and reuse that helper) owning account `"bae"`, two boxes `bx-server` (no tags) + `bx-pi` (tags `"pi,device"`); write metrics day rollups for boxes `bx-server` (24 present hours, Host cpu N=60 each) and `bx-pi` (12 hours) via `putStoreJSON`; run `statementsPass`; assert statement for `bae` has `fleet_box_hours_server == 24` and `fleet_box_hours_device == 12`.
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** — `rollupPresenceHours` + `fleetMeters` (open registry via the existing `openPassRegistry`; sweep `metrics/boxes/` day objects for the range — REUSE the day-reading that `sweepUsage` already does rather than re-listing: extend `usageSweep` with `FleetHours map[string]map[string]float64` populated during its metrics-day loop from a label-map parameter, OR do a second bounded read in `fleetMeters`; choose whichever keeps `sweepUsage`'s existing tests green unmodified and note the choice); merge into `statementsPass` meters before upsert.
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS including all pre-existing statements tests unmodified.
+- [ ] **Step 5: Commit** `git commit -am "console: fleet box-hours metered per account with device class"`
+
+---
+
+### Task 4: Account tier column + operator door
+
+**Files:**
+- Modify: `internal/console/store.go` (migration + methods)
+- Modify: `internal/console/economicspass.go` (the door, beside the other economics doors)
+- Modify: `internal/console/server.go` (route registration)
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Migration (append to the migration list, pattern of `store.go:209`): `ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`
+- `func (s *Store) SetAccountTier(accountID int64, tier string) error` — validates tier ∈ {"", "try", "pool", "hosted", "fleet", "dedicated"}; `func (s *Store) AccountTiers() (map[string]string, error)` — sqid → tier (all accounts, deleted excluded, same filter as `Accounts()`).
+- Door: `POST /fleet/economics/tier` (bare-registered; handler checks method FIRST → NotFound, then session → IsOperator → bare 404, then CSRF via `subtle.ConstantTimeCompare`) — form fields `csrf`, `account` (sqid), `tier`; resolves sqid via `AccountAnyBySqid`-style lookup (grep for the right store method that finds an account by sqid regardless of viewer); flash + redirect to `/fleet/economics/accounts`.
+- Produces for Task 5: `AccountTiers()`.
+
+- [ ] **Step 1: Failing tests** — append: `TestAccountTierStore` (set on fixture account → AccountTiers shows it; invalid tier rejected; unknown id error or no-op — match store conventions, assert what you implement); `TestEconomicsTierDoor` (ghost-404 parity for GET + signed-out POST + non-operator POST; operator POST sets tier and redirects — clone the `TestEconomicsWriteDoorsRejectNonPOST`/gate patterns already in the file).
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** per Interfaces.
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git commit -am "console: account tier column with operator door"`
+
+---
+
+### Task 5: View v2 — tier-aware revenue, fleet meters, pool ledger
+
+**Files:**
+- Modify: `internal/console/economicsview.go`
+- Test: `internal/console/economicsview_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 1-4 (`fleetMeters`, `AccountTiers`, v2 scenario fields, v2 `PriceAccount`).
+- `buildEconomicsView` changes: (a) merge `fleetMeters` results into each account's meters before pricing; (b) load `AccountTiers()`; (c) revenue rule: accounts with tier `hosted`/`fleet`/`dedicated` price normally; tier `try`/`pool`/`""` accounts get `RevenueUSD = 0, MarginFrac = 0, FreeTierPos` computed as today (their cost stands); (d) compute `PoolLedger`.
+- Produces:
+ ```go
+ type PoolLedger struct {
+ PoolUSD, DrawUSD, HeadroomUSD float64
+ PayingRevenueUSD float64 // the revenue the frac applies to
+ }
+ ```
+ `EconomicsView.Pool PoolLedger` + `EconomicsView.Tiers map[string]string`; `AccountEconomics` rows unchanged in shape; `econAccountRow` gains tier at render via `view.Tiers` (Task 6).
+ Formula: `PayingRevenueUSD = Σ revenue of hosted/fleet/dedicated accounts` (period figure); `PoolUSD = Scenario.Pool.RevenueFrac × PayingRevenueUSD + Scenario.Pool.TopupUSD × (PeriodDays/DaysInMonth)` (top-up prorated to the period so mtd reads sensibly); `DrawUSD = Σ CostUSD of try/pool/"" accounts`; `Headroom = Pool − Draw` (may be negative — render honestly).
+- `economicsPayload` additions (frozen keys): `"tiers": map[string]string`, `"pool": {"revenue_frac","topup_usd"}` comes via scenario already; ledger recomputed client-side from tiers+scenario+meters — document that the JS twin computes the ledger the same way (Task 6).
+
+- [ ] **Step 1: Failing tests** — append: `TestLoadEconomicsTierRevenueAndPool`: fixture seeds golden usage (reuse existing helpers) + fleet meters via Task 3's seeding + tiers: fixture acct → `hosted`, a second minted account → `pool` with seeded cost; assert hosted account has revenue > 0, pool account revenue == 0 with cost > 0; assert `view.Pool.PoolUSD == 0.10*PayingRevenue + 200*(PeriodDays/DaysInMonth)` (compute expected from the view's own PayingRevenueUSD then assert Draw/Headroom arithmetic exactly); assert `DataJSON` contains `"tiers"`.
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** per Interfaces (tier zero-value: absent from map = "" = pool semantics — one helper `tierOf(tiers, acct)`).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS, pre-existing view tests unmodified (they seed no tiers → every fixture account is pool-tier → their REVENUE assertions... CAUTION: existing tests assert CostUSD>0 and revenue under active scenario. `TestLoadEconomicsGolden` asserts `acctRow.CostUSD` and revenue — if it asserts revenue > 0 it BREAKS under tier-aware pricing. Resolution, decided here: untiered fixture accounts defaulting to pool-tier zero revenue would break honest tests — so the golden test gets its account explicitly tiered `hosted` in seeding (one-line addition to THAT test only, using Task 4's SetAccountTier; this is a sanctioned test modification, note it in the commit body). No production behavior compromise.)
+- [ ] **Step 5: Commit** `git commit -am "console: tier-aware revenue, fleet meters in the view, pool ledger"`
+
+---
+
+### Task 6: Dashboard v2 — pool tile, knobs, tier chips
+
+**Files:**
+- Modify: `internal/console/templates/economics.html` (pool tile + knob rows + JS)
+- Modify: `internal/console/templates/economics_accounts.html` (tier chip column + tier-set form per row for operators)
+- Modify: `internal/console/econlistquery.go` (tier filter? NO — YAGNI; Pos filter exists. Only the chip column renders.)
+- Test: `internal/console/economicspage_test.go` (append)
+
+**Interfaces:**
+- Pool tile on `/fleet/economics`: "POOL $X · draw $Y · headroom $Z" with the coverage label; negative headroom rendered with the page's existing warning styling.
+- Knob drawer additions (server-rendered values + JS recompute): `box_month_server_usd`, `box_month_device_usd`, `mem_gb_hour_usd`, `fleet_break.min_boxes`, `fleet_break.discount`, `pool.revenue_frac`, `pool.topup_usd`.
+- JS twins in `econ_maths_js`: extend `priceAccount` with the Task 2 formulas verbatim (fleet revenue both modes, break, mem overage — same names); new `poolLedger(scenario, tiers, rows)` mirroring Task 5's formula; recompute updates the pool tile; tier read from `payload.tiers` with `"" → pool`.
+- Accounts page: tier chip (`chip chip-tier-<tier>` classes, colors in the template CSS near the freetier chips) + a small per-row operator form POSTing to `/fleet/economics/tier` (account hidden field + tier `<select>` + csrf) — progressive: works without JS.
+
+- [ ] **Step 1: Failing tests** — append: content test asserting the rendered `/fleet/economics` contains `id="tile-pool"` and the pool figures; accounts page contains `chip-tier-hosted` for the tiered fixture account and the tier form posts to `/fleet/economics/tier`; off-origin subresource test stays green (runs automatically).
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement**; reuse existing tile/knob/chip markup classes; keep every new JSON key read in JS matching the Go tags exactly (list them in the template header comment).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git commit -am "console: pool tile, v2 knobs and tier chips on the economics pages"`
+
+---
+
+### Task 7: Scenario refresh + spec truth-up
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-08-13-carloku-business-model-design.md` (device-tag ruling: bare `device` tag, not `class=`; pool top-up proration note; mark items 1-4 of "What exists today vs what this needs" as BUILT-BY this plan)
+- No code.
+
+- [ ] **Step 1:** Edit the spec's "Device class is a tag" bullet to: tag grammar has no `=`; device class = literal `device` tag present (registry `ValidBoxTags` grammar), server otherwise. Add one sentence to the pool section: the top-up prorates to the viewed period (`× PeriodDays/DaysInMonth`) so month-to-date ledgers read honestly. Update the follow-ups list.
+- [ ] **Step 2: Commit** `git commit -am "business model: device-tag grammar ruling, pool proration, v2 build status"`
+
+---
+
+### Task 8: Full gate + PR update
+
+- [ ] **Step 1:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1`; `gofmt -l` on all branch-touched `.go` files (expect empty); `GOOS=darwin go build ./...`.
+- [ ] **Step 2:** Push the branch; PR #197 now carries spec + implementation. Comment on the PR summarizing the v2 slice (meters, knobs, ledger, tiers) and the two deliberate scope-outs (durability machinery = own spec; `carlos economics tier/scenario` CLI verbs = gap #182 family).
+- [ ] **Step 3:** After merge+deploy (operator flow, same as unit-economics): update the live `ladder-launch`/`ladder-lean` scenarios with the v2 knob values (box 5/1, break 10/0.4, pool 0.10/200, mem 0) — recorded here as a deploy-time step, not a task.
+
+## Self-review notes (run inline at write time)
+
+- Spec coverage: items 1 (fleet meter + class → T1/T3), 2 (box prices + break → T1/T2/T6), 3 (pool ledger → T5/T6), 4 (tier marker → T4/T5/T6), mem overage (T1/T2/T6), durability = explicitly out (own spec), device-tag grammar fix (T7).
+- Known sanctioned deviations: bare `device` tag (grammar reality); pool top-up prorated by period; `TestLoadEconomicsGolden` gains one tier-seeding line (T5 step 4 records why).
+- Type consistency: meter constant names identical in T1/T2/T3/T6 JS; `PoolLedger` fields T5/T6; door path `/fleet/economics/tier` T4/T6.
+- Open verification points flagged inline: fleet test seeding helper (T3), sqid-lookup store method for the door (T4), sweepUsage-extension vs second-read choice (T3).
diff --git a/docs/superpowers/specs/2026-08-13-carloku-business-model-design.md b/docs/superpowers/specs/2026-08-13-carloku-business-model-design.md
new file mode 100644
index 000000000..6b93effac
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-13-carloku-business-model-design.md
@@ -0,0 +1,214 @@
+# Carloku business model — the ladder, the pool, and fleet pricing
+
+**Date:** 2026-08-13 (approved in conversation 2026-08-13/14, Paul)
+**Status:** Approved direction — a starting point, tweakable by construction:
+every number here lives in scenario JSON or in this doc, not in code.
+**Grounding:** the live unit-economics instrument
+(`2026-08-11-unit-economics-design.md`): real meters, real pulled AWS
+bills, per-account cost attribution on both consoles since 2026-08-12.
+
+## What this is
+
+The commercial model for Carloku (the flagship CARLOS deployment):
+who pays, what they pay on, what free means, and what operating a
+customer fleet charges. It is a modelling-instrument output — prices are
+decision-grade anchors to be tuned against real usage in the economics
+dashboard, not published commitments.
+
+## The one rule
+
+**You pay per app or per box, never both.**
+
+- **App-months** bill only on shared multi-tenant capacity (our boxes,
+ many tenants).
+- **Box-months** bill for metal we operate whole for you: your own boxes
+ (fleet) or our boxes rented dedicated. A box that bills runs unlimited
+ apps free.
+- **Egress and storage** are their own metered axes everywhere and never
+ fold into either price above. Fleet direct-served traffic never bills —
+ it never touches our infrastructure, and the meter boundary is the cost
+ boundary.
+
+## The ladder
+
+| Rung | Who | Pays | Anchor price |
+| --- | --- | --- | --- |
+| **Try** | everyone, forever | $0 (our permanent marketing cost) | 1 tiny hosted app; hard caps (1 GB egress/mo, 250 MB storage); sleeps aggressively |
+| **Free pool** | hobbyists | $0 (pool-funded — see below) | generous limits (10 GB egress, 1 GB storage shape) while the pool covers it |
+| **Hosted** | indies, agencies | per app + usage | **$1/app-month baseline** + **$0.004/instance-hour** (a hibernating app ≈ $1/mo; an always-on app ≈ $3.90/mo — hibernation is the product and the price says so); egress $0.15/GB and storage $0.05/GB-month past allowances; durability TTL baseline 5 min + flush-on-hibernate, tighter TTL metered at S3-write cost × 5 (see Durability axis) |
+| **Fleet** | self-hosting companies (Eleven shape), edge/IoT (Pi shape), agencies on own metal | per box, tiered by device class | **$5/box-month server class**, **$1/box-month device class**; 10+ boxes → 40% off; relay egress $0.15/GB past 20 GB/box/mo; direct-served traffic free (unmetered by construction); **apps free** |
+| **Dedicated** | big orgs wanting the efficiency without self-hosting | per dedicated box we run | **cost × multiple** all-in; at the standard 5× a t4g.small is ≈ $95/box-mo and a t4g.medium ≈ $170 (2026-08 all-in costs ≈ $19/$34) — the dedicated multiple is expected to settle LOWER than 5× (managed capacity competes with raw EC2 at ~$13); it is a first-class knob. **Apps free**; metered egress/storage on top; idle included — they rent the whole box, so no idle-smearing question exists |
+
+Graduation is monotonic (Try → pool → Hosted → Fleet → Dedicated) and the
+vocabulary never changes. Named plans (Heroku-style bundles) are a
+POSSIBLE FUTURE presentation layer over this model, not part of it.
+
+## The pool (the free tier is a budget, not a promise)
+
+Mechanism (Paul's Eleven-conversations design, made concrete):
+
+- `pool = 10% × recognised monthly revenue + bootstrap top-up`.
+- The bootstrap top-up is set by hand monthly during the kickstart phase;
+ starting anchor **$200/month** (~2× the entire flagship fleet's current
+ all-in cost, so genuinely generous).
+ The top-up accrues per-day against each day's own month length (Σ TopupUSD/daysInMonth(d)),
+ so month-to-date and closed-month ledgers both read honestly.
+- Free-pool accounts draw from the pool at **cost** — the economics
+ dashboard's per-account cost-to-us figure (allocated box share + direct
+ meters) IS the draw. The pool report is arithmetic over numbers the
+ dashboard already computes.
+- As the month's draw approaches the pool, **generosity degrades, never
+ availability**: pool-tier limits step down toward Try caps for new
+ consumption. Nothing is killed for budget reasons; apps sleep harder and
+ cap harder.
+- Try sits outside the pool: unconditional, permanent, small.
+
+Properties this buys: the free tier is self-limiting by construction
+(no "free tier is eating us" cliff); "how generous can we afford to be"
+is a dashboard line, not a debate; paying customers visibly fund the
+commons, which is a story we can tell.
+
+## Fleet axis — rulings
+
+- **Box-months from presence**: a fleet box's metrics reporting into the
+ deployment bucket is its billable presence; box-months prorate by
+ metered hours (attach mid-month = half a month; honest by default).
+- **Device class is a tag**: the tag grammar has no `=`; device class =
+ literal `device` tag present in the box's comma-list (registry
+ `ValidBoxTags` grammar), server class otherwise. Set via `carlos fleets
+ tag-box` by the customer. Trust-first: a mis-tagged box is visible in
+ its own metrics; auditing is a later policy question, not launch
+ machinery.
+- **Relay is the billing boundary for traffic**: tunnel-relayed traffic
+ flows through our edge and is already metered (`usage/edge/*`) —
+ billable past the allowance. Direct-served traffic is invisible to us,
+ costs us nothing, bills nothing. No code enforces this; the
+ architecture does.
+- **Volume break**: 10+ boxes in an account → 40% off box prices. One
+ break only at launch; more tiers when real fleets exist.
+
+## Dedicated axis — rulings
+
+- Dedicated = one of our boxes, in our AWS account, allocated whole to
+ one account. Priced at that box's **all-in cost × a dedicated
+ multiple**, where all-in cost is the rate card's number for that box
+ (EC2 + EBS + its overhead share). The standard 5× is the starting
+ anchor, but dedicated is the rung where the multiple most plausibly
+ settles lower — managed whole-box capacity competes visibly with raw
+ EC2 — so the multiple is a first-class scenario knob, not a constant.
+- Apps free (the one rule). Egress/storage metered on top — a dedicated
+ box's traffic leaves through our pipes, unlike fleet direct-serve.
+- Idle included: renting the whole box IS buying its idle. The
+ per-box utilization view stays useful to the customer, not to a
+ pricing argument.
+- **Dedicated is per-region** (Paul, 2026-08-14): a box lives in one
+ region, so multi-region presence = one dedicated box per region, each
+ at its region's cost × the dedicated multiple (regional rate-card
+ prices differ and the price follows them). There is no bundled "multi-region dedicated" SKU —
+ the region count IS the box count, which keeps the price sheet honest
+ and makes HA/residency a natural multiplier instead of a negotiation.
+
+## Durability axis — TTL scales with payment (Paul, 2026-08-15)
+
+The Eleven mechanism applied to storage durability. A tenant app's data
+durability is defined by its litestream replication TTL, and the TTL is
+a priced choice because its cost driver is S3 PUTs:
+
+- **Baseline (included in $1/app-month):** long TTL (anchor: 5-minute
+ sync) plus a **guaranteed flush at hibernation** — the durability
+ promise every app gets is "hibernate = durable". Between periodic
+ syncs, a box loss can cost up to one TTL of writes; that bound is the
+ product tier, stated plainly. Cost to us ≈ $0.04/month even for a
+ busy app (8.6k PUTs at $0.005/1k) — safely inside the $1.
+- **Paid tightness:** the customer picks a shorter TTL, down to 1–2 s
+ (continuous litestream), and pays **metered replication writes at S3
+ write cost × 5** (~$0.025/1k PUTs). Litestream only uploads on
+ change, so the meter charges actual sync PUTs, not wall-clock: a
+ worst-case continuous writer at 1 s TTL ≈ 2.6M PUTs/mo ≈ $13 cost →
+ ≈ $65 billed; a sporadic writer at the same TTL pays cents. Fair by
+ construction — you pay for the durability you actually consumed.
+- **Scope:** applies where OUR buckets carry the writes — hosted and
+ dedicated. Fleet boxes replicate to the customer's own storage, so
+ their durability cadence is their business and their bill (consistent
+ with "direct-served traffic never bills": the meter boundary is the
+ cost boundary).
+- **Machinery this needs** (joins the follow-up spec): per-app/instance
+ litestream sync-interval config, the hibernate-flush guarantee as a
+ tested invariant, and per-tenant S3 write metering — the same meter
+ already named as the litestream-spam guard, now also a revenue axis
+ (`durability_writes_1k` in the meter vocabulary, priced in the rate
+ sheet).
+
+## Unit maths (2026-08 real data)
+
+- Whole flagship fleet (4 × t4g.small in IE/US/UK/AU + overheads):
+ ≈ **$91/month all-in** at steady state (live rate card
+ `actual-prices`, AWS Pricing API rates, EIP/Route53/S3-churn/DNS
+ overheads included).
+- Per-app cost truth (2026-08-15, live meters + CE usage-type split): a
+ **hibernating app costs cents** (storage at $0.023/GB; no running unit
+ = no box allocation, by metering construction); an always-on ~190 MB
+ app allocates ≈ **$1.20–1.80/month** of box. Hence the $1 + $0.004/h
+ shape: sleeper ≈ $1 (very high margin on cents of cost), always-on
+ small ≈ $3.90 (~60–70% margin — acceptable for the volume play;
+ a `mem_gb_hour` overage price axis is the v2 lever for heavy apps).
+- **S3 cost anatomy** (flagship MTD, CE by usage type): $4.32 total =
+ $2.31 PUT/LIST + $1.71 GET + **$0.26 storage** — 94% requests, and the
+ GETs are our own 2 s control-plane polling (~$1/box/month of
+ heartbeat), NOT litestream. Litestream is currently quiet on flagship
+ (uploads only on change) but Eleven's litestream-spam lesson is an
+ inherited RISK as tenant data planes grow — recorded as a watch-item,
+ and per-tenant S3-request metering is the future guard.
+- A fleet box costs us ≈ **$0.20–0.50/month** (control-plane bucket
+ churn) plus relay egress at $0.09/GB when tunnelled → $5/box-month is
+ ~90%+ margin; $1/box-month device class still >50% margin if the
+ device is quiet, and the 20 GB relay allowance bounds the loss if not.
+- Dedicated all-in costs (real rate card): t4g.small ≈ **$19/mo**
+ (730 h × $0.0184 = $13.43 EC2 + $1.78 EBS + $3.65 IPv4 + overhead
+ share) → ≈ **$95/box-month at 5×**; t4g.medium ≈ **$34/mo** → ≈
+ **$170/box-month at 5×**. (An earlier conversational draft said $23/$45
+ — that was an arithmetic error, cost × ~1.2, recorded here so the
+ wrong anchors don't survive in anyone's head.)
+- August whole-account bills for reality-checking: flagship $24 MTD,
+ titogo $3,397 MTD (dominated by non-CARLOS legacy; the reconcile
+ knob's whole point).
+
+## What exists today vs what this needs
+
+**Expressible now (scenarios, no code):** hosted app pricing, egress and
+storage prices/allowances, margin multiples, the free-tier allowance
+shapes — saved as scenarios `ladder-launch` (bootstrap-generous pool
+assumptions) and `ladder-lean` (10%-of-revenue pool only) on the flagship
+console.
+
+**Built in economics-v2 plan (2026-08-15, this branch):**
+
+1. `fleet_box_hours_server` and `fleet_box_hours_device` meters (device-class split)
+ derived in the statements pass from fleet-labelled metrics presence,
+ attributed to the owning account.
+2. `box_month_server_usd` and `box_month_device_usd` in the rate sheet;
+ `mem_gb_hour_usd` overage axis; fleet volume break as a knob.
+3. Pool ledger line on `/fleet/economics`:
+ `pool = 10% × revenue + top-up · draw = Σ free-pool accounts' cost ·
+ headroom`, with the top-up and percentage as scenario knobs.
+4. Account-tier marker (try/pool/hosted/fleet/dedicated) as a console-store
+ column and operator door, so the dashboard can group accounts by rung.
+
+**Explicitly out of scope here:** payments and invoicing (the
+unit-economics spec's phase-2 stance holds: statements × promoted
+scenario = future invoices); cap ENFORCEMENT for Try/pool (provisioning
+work, own spec); the carloku.com pricing page (follows this doc).
+
+## Follow-ups recorded
+
+- Durability machinery spec: per-app/instance litestream sync-interval config,
+ the hibernate-flush guarantee as a tested invariant, and per-tenant S3-request
+ metering (the litestream guard and a revenue axis).
+- Control-plane polling efficiency: ~$1/box/month of 2 s heartbeat GETs
+ is the platform's own S3-cost floor — a poll-interval/long-poll lever
+ worth a look before the fleet is 50 boxes.
+- Enforcement spec for Try/pool caps (sleep/cap policy hooks).
+- Pricing-page copy for carloku.com once numbers survive a month of
+ dashboard contact.
+- Revisit the volume break and device-class prices when the first real
+ external fleet attaches.
diff --git a/internal/console/econlistquery.go b/internal/console/econlistquery.go
index cc96d34c1..4131a9eaa 100644
--- a/internal/console/econlistquery.go
+++ b/internal/console/econlistquery.go
@@ -21,9 +21,14 @@ import (
const econAccountPageSize = 50
// econPosWords is every economics.AccountEconomics.FreeTierPos value, in the
-// order the header lists them. A new position joins PriceAccount
-// (internal/economics/compute.go), this list, and nothing else.
-var econPosWords = []string{"inside", "partial", "paying"}
+// order the header lists them — three of them ("inside", "partial",
+// "paying") are economics.PriceAccount's own words; the fourth, "pooled",
+// is never produced by PriceAccount at all (see its doc comment) — it is
+// buildEconomicsView's override for try/pool/"" tier accounts
+// (economicsview.go). A new position joins PriceAccount OR
+// buildEconomicsView's override, this list, and the chip-freetier-<word>
+// CSS rule in layout.html — nothing else.
+var econPosWords = []string{"inside", "partial", "paying", "pooled"}
// econAccountRow is one priced account joined with its own meter readings —
// EconomicsView.Accounts and EconomicsView.Meters, keyed by the same
diff --git a/internal/console/econlistquery_test.go b/internal/console/econlistquery_test.go
index 5ace072e4..f9d4fcf2e 100644
--- a/internal/console/econlistquery_test.go
+++ b/internal/console/econlistquery_test.go
@@ -104,6 +104,46 @@ func TestPageEconAccountsPosFilter(t *testing.T) {
}
}
+// TestPageEconAccountsPooledFilterAndCount: "pooled" (Task 5's fourth
+// FreeTierPos word, IMPORTANT 2 fix round) must filter and count exactly
+// like the other three positions — pos=pooled is a real, accepted filter,
+// not silently cleared by econPosFromParam, and Counts/Links include it.
+func TestPageEconAccountsPooledFilterAndCount(t *testing.T) {
+ rows := econRowsFor(120)
+ rows[0].Econ.FreeTierPos = "pooled"
+ rows[1].Econ.FreeTierPos = "pooled"
+ rows[2].Econ.FreeTierPos = "partial"
+
+ q := econAccountQuery{Pos: econPosFromParam("pooled")}
+ if q.Pos != "pooled" {
+ t.Fatalf("econPosFromParam(%q) = %q, want %q (pos=pooled must be an accepted filter)", "pooled", q.Pos, "pooled")
+ }
+
+ p := pageEconAccounts(rows, q, econAccountPageSize)
+ if p.Total != 2 {
+ t.Fatalf("Pos=pooled: Total = %d, want 2", p.Total)
+ }
+ for _, r := range p.Rows {
+ if r.Econ.FreeTierPos != "pooled" {
+ t.Fatalf("row %q has FreeTierPos %q, want pooled", r.Account, r.Econ.FreeTierPos)
+ }
+ }
+
+ if p.Counts["pooled"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 {
+ t.Fatalf("Counts = %+v, want pooled:2 partial:1 paying:117", p.Counts)
+ }
+
+ var pooledLink *econPosLink
+ for i := range p.Links {
+ if p.Links[i].Word == "pooled" {
+ pooledLink = &p.Links[i]
+ }
+ }
+ if pooledLink == nil || !pooledLink.Active || pooledLink.Count != 2 {
+ t.Fatalf("pooled link = %+v, want Active=true Count=2", pooledLink)
+ }
+}
+
func TestPageEconAccountsGarbageCursorStartsOver(t *testing.T) {
rows := econRowsFor(5)
p := pageEconAccounts(rows, econAccountQuery{Cursor: "!!not-base64!!"}, econAccountPageSize)
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
index a6523538c..81843e5e2 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)
@@ -391,6 +402,36 @@ func TestEconomicsAccountsPageContent(t *testing.T) {
}
}
+// TestEconomicsAccountsPageRendersPooledChip is the fix-round regression for
+// IMPORTANT 2: "pooled" (Task 5's fourth FreeTierPos word) must render as a
+// first-class chip on the accounts page, both on the row itself and as a
+// countable header filter chip — the fixture account here is deliberately
+// left UNTIERED (the default "" tier, pool semantics), so its row lands in
+// "pooled" rather than a priced position, same as a fresh console with no
+// tiers placed yet.
+func TestEconomicsAccountsPageRendersPooledChip(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEconomicsGolden(t, fx)
+
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts")
+ if status != 200 {
+ t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body)
+ }
+ for _, want := range []string{
+ `class="chip chip-freetier-pooled"`, // the row's own free-tier chip
+ ">pooled 1<", // the header filter chip, count 1
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body)
+ }
+ }
+}
+
// TestEconomicsAccountsPageThreadsPeriod is the fix-round regression for
// the silent-wrong-data bug: every list control on this page (search form,
// pos-filter chips, period nav) must carry the SAME period the rows on
@@ -464,3 +505,371 @@ func TestEconomicsAccountsPageThreadsPeriod(t *testing.T) {
t.Fatalf("missing the Month-to-date period link preserving pos=partial with no period/cursor:\n%s", body)
}
}
+
+// ---- Task 6: pool tile, v2 knobs, tier chips -------------------------------
+
+// TestEconomicsPagePoolTile proves the dashboard renders id="tile-pool" with
+// the "POOL $X · draw $Y · headroom $Z" figures straight from
+// EconomicsView.Pool — templates.ExecuteTemplate against a hand-built view
+// (TestEconomicsPageMTDShowsProjection's pattern), so the expected dollar
+// strings are exact, known numbers rather than whatever a real sweep
+// produces.
+func TestEconomicsPagePoolTile(t *testing.T) {
+ view := &EconomicsView{
+ Period: "mtd",
+ Pool: PoolLedger{PoolUSD: 123.45, DrawUSD: 50, HeadroomUSD: 73.45, PayingRevenueUSD: 1000},
+ }
+ p := page{Title: "Fleet economics", CSRF: "tok", Economics: view}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil {
+ t.Fatalf("render economics: %v", err)
+ }
+ html := buf.String()
+ for _, want := range []string{
+ `id="tile-pool"`,
+ "POOL",
+ `id="tile-pool-usd">` + usd(123.45),
+ "draw",
+ `id="tile-pool-draw">` + usd(50),
+ "headroom",
+ `id="tile-pool-headroom">` + usd(73.45),
+ } {
+ if !strings.Contains(html, want) {
+ t.Fatalf("/fleet/economics missing pool tile string %q in:\n%s", want, html)
+ }
+ }
+}
+
+// TestEconomicsPagePoolTileNegativeHeadroomWarns: a negative HeadroomUSD (the
+// pool is overdrawn this period) must render honestly — never clamped to
+// zero (PoolLedger's own doc comment) — AND in the page's existing warning
+// styling, per the brief. The headroom span carries class="error" (the same
+// class every other on-page warning uses — layout.html's ".error { color:
+// var(--bad); }") exactly when HeadroomUSD is negative, never otherwise.
+func TestEconomicsPagePoolTileNegativeHeadroomWarns(t *testing.T) {
+ view := &EconomicsView{
+ Period: "mtd",
+ Pool: PoolLedger{PoolUSD: 10, DrawUSD: 50, HeadroomUSD: -40},
+ }
+ p := page{Title: "Fleet economics", CSRF: "tok", Economics: view}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil {
+ t.Fatalf("render economics: %v", err)
+ }
+ html := buf.String()
+ if !strings.Contains(html, `id="tile-pool-headroom" class="error"`) {
+ t.Fatalf("/fleet/economics: negative headroom must carry the warning class:\n%s", html)
+ }
+ if !strings.Contains(html, `class="error">`+usd(-40)) {
+ t.Fatalf("/fleet/economics missing the honest negative headroom figure %q in:\n%s", usd(-40), html)
+ }
+
+ // Positive headroom: no warning class at all.
+ viewOK := &EconomicsView{Period: "mtd", Pool: PoolLedger{PoolUSD: 50, DrawUSD: 10, HeadroomUSD: 40}}
+ pOK := page{Title: "Fleet economics", CSRF: "tok", Economics: viewOK}
+ var bufOK bytes.Buffer
+ if err := templates.ExecuteTemplate(&bufOK, "economics", pOK); err != nil {
+ t.Fatalf("render economics (positive headroom): %v", err)
+ }
+ if strings.Contains(bufOK.String(), `id="tile-pool-headroom" class="error"`) {
+ t.Fatalf("/fleet/economics: positive headroom must not carry the warning class:\n%s", bufOK.String())
+ }
+}
+
+// TestEconomicsPagePooledMarginIsDash is the carried ruling (Task 6 review,
+// point 3): a pooled row's margin cell renders an em-dash, never "0%" —
+// PriceAccount's MarginFrac is genuinely 0 for the pool override
+// (buildEconomicsView), but "0%" reads as a account LOSING money, not one
+// that simply isn't individually priced. Checked on both /fleet/economics
+// and /fleet/economics/accounts, server-rendered (no JS in this test suite).
+func TestEconomicsPagePooledMarginIsDash(t *testing.T) {
+ pooledRow := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 5, RevenueUSD: 0, MarginFrac: 0, FreeTierPos: "pooled"}
+
+ view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{pooledRow}}
+ p := page{Title: "Fleet economics", CSRF: "tok", Economics: view}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil {
+ t.Fatalf("render economics: %v", err)
+ }
+ html := buf.String()
+ if !strings.Contains(html, `class="js-acct-margin">—<`) {
+ t.Fatalf("/fleet/economics: pooled row's margin cell must be an em-dash, not a percentage:\n%s", html)
+ }
+ if strings.Contains(html, `class="js-acct-margin">0%<`) {
+ t.Fatalf("/fleet/economics: pooled row's margin cell must not render \"0%%\":\n%s", html)
+ }
+
+ rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: pooledRow}}, econAccountQuery{}, econAccountPageSize)
+ pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}}
+ var bufAcct bytes.Buffer
+ if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil {
+ t.Fatalf("render economics_accounts: %v", err)
+ }
+ htmlAcct := bufAcct.String()
+ if !strings.Contains(htmlAcct, `class="js-acct-margin">—<`) {
+ t.Fatalf("/fleet/economics/accounts: pooled row's margin cell must be an em-dash, not a percentage:\n%s", htmlAcct)
+ }
+}
+
+// TestEconomicsPageFleetMarginLegend is carried ruling 4 PLUS the fix-round
+// MINOR 3 (Task 6 review): fleet-only accounts read 100%% margin by design
+// (customer hardware, zero cost to the platform — PriceAccount's cost
+// formula has no fleet term at all), and a number that looks like a
+// modelling error gets an explanation somewhere on the page (dashboard-truth
+// rule, CLAUDE.md). The title= tooltip on the Margin header is invisible on
+// touch/keyboard, so both notes ALSO need a visible one-line legend under
+// each table — this asserts the tooltip AND the legend paragraph, on both
+// pages, not just "the text appears somewhere" (which the tooltip alone
+// would already satisfy and silently let the legend regress).
+func TestEconomicsPageFleetMarginLegend(t *testing.T) {
+ // A margin-column explanation only means anything once the accounts
+ // table itself renders (both pages hide the table entirely with zero
+ // rows), so this seeds one row on each — not a claim that the row
+ // itself is a fleet account, just enough for the header/legend markup
+ // carrying the explanation to be on screen at all.
+ row := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 0, RevenueUSD: 10, MarginFrac: 1, FreeTierPos: "paying"}
+
+ view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{row}}
+ p := page{Title: "Fleet economics", CSRF: "tok", Economics: view}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil {
+ t.Fatalf("render economics: %v", err)
+ }
+ html := buf.String()
+ if !strings.Contains(html, `title="Pooled rows`) {
+ t.Fatalf("/fleet/economics missing the Margin header's title= tooltip:\n%s", html)
+ }
+ if !strings.Contains(html, `<p class="small">Margin: pooled rows show an em-dash`) {
+ t.Fatalf("/fleet/economics missing the VISIBLE margin legend line (MINOR 3 — tooltips alone are invisible on touch/keyboard):\n%s", html)
+ }
+
+ rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: row}}, econAccountQuery{}, econAccountPageSize)
+ pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}}
+ var bufAcct bytes.Buffer
+ if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil {
+ t.Fatalf("render economics_accounts: %v", err)
+ }
+ htmlAcct := bufAcct.String()
+ if !strings.Contains(htmlAcct, `title="Pooled rows`) {
+ t.Fatalf("/fleet/economics/accounts missing the Margin header's title= tooltip:\n%s", htmlAcct)
+ }
+ if !strings.Contains(htmlAcct, `<p class="small">Margin: pooled rows show an em-dash`) {
+ t.Fatalf("/fleet/economics/accounts missing the VISIBLE margin legend line (MINOR 3):\n%s", htmlAcct)
+ }
+}
+
+// TestEconomicsKnobDrawerHasV2Knobs proves the shared knob drawer
+// (econ_knob_drawer) exposes all seven new economics-v2 knobs the brief
+// names, with values sourced from the active scenario — box_month_server_usd
+// and box_month_device_usd and mem_gb_hour_usd (Sheet), fleet_break.min_boxes
+// and fleet_break.discount, pool.revenue_frac and pool.topup_usd.
+func TestEconomicsKnobDrawerHasV2Knobs(t *testing.T) {
+ sc := economics.DefaultScenario()
+ sc.Sheet.BoxMonthServerUSD = 7.5
+ sc.Sheet.BoxMonthDeviceUSD = 1.5
+ sc.Sheet.MemGBHourUSD = 0.02
+ sc.FleetBreak.MinBoxes = 12
+ sc.FleetBreak.Discount = 0.3
+ sc.Pool.RevenueFrac = 0.15
+ sc.Pool.TopupUSD = 250
+
+ view := &EconomicsView{Period: "mtd", Scenario: sc}
+ p := page{Title: "Fleet economics", CSRF: "tok", Economics: view}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil {
+ t.Fatalf("render economics: %v", err)
+ }
+ html := buf.String()
+ for _, want := range []string{
+ `id="knob-sheet-box-month-server-usd" value="7.5"`,
+ `id="knob-sheet-box-month-device-usd" value="1.5"`,
+ `id="knob-sheet-mem-gb-hour-usd" value="0.02"`,
+ `id="knob-fleet-break-min-boxes" value="12"`,
+ `id="knob-fleet-break-discount" value="0.3"`,
+ `id="knob-pool-revenue-frac" value="0.15"`,
+ `id="knob-pool-topup-usd" value="250"`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Fatalf("knob drawer missing %q in:\n%s", want, html)
+ }
+ }
+}
+
+// TestEconomicsJSTwinsShape is a structural stand-in for a browser test (this
+// suite has no headless DOM — same rationale as
+// TestEconomicsPageBillServicesGuardShape): greps the served page's inline
+// script for the exact shapes the carried rulings (Task 6 review) demand, so
+// a future edit that drops one of them fails a test instead of only a manual
+// re-review.
+//
+// Ruling 1 (JS meter guards + fleet formulas): every new meter read is
+// guarded against undefined, the fleet revenue term sits OUTSIDE the
+// cost-plus/rate-sheet branch (mirroring compute.go's PriceAccount exactly),
+// the volume-break discount applies only when min_boxes>0 AND avg boxes
+// clears it, and the rate-sheet arm's mem overage term is present.
+//
+// Ruling 2 (tier-aware JS): a tierOf/isPoolTier pair mirrors
+// economicsview.go's same-named functions, and poolLedger mirrors
+// buildEconomicsView's PoolLedger construction — including the exact
+// linear-scaling topup term (payload.poolTopupUnitAccrual × the live
+// topup_usd knob) rather than any client-side re-derivation of the per-day
+// sum.
+func TestEconomicsJSTwinsShape(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEconomicsGolden(t, fx)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics")
+ if status != 200 {
+ t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body)
+ }
+
+ for _, want := range []string{
+ // Ruling 1: guarded fleet meter reads, fleet term outside the mode
+ // branch, the SAME bMem binding used in the rate-sheet arm's mem
+ // overage term.
+ `(meters.fleet_box_hours_server || 0)`,
+ `(meters.fleet_box_hours_device || 0)`,
+ "bMem * sc.sheet.mem_gb_hour_usd",
+ "revenue += fleetRevenue",
+ "sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes",
+ // Ruling 2: tier helpers + pool ledger twin, exact linear topup scaling.
+ "function tierOf(",
+ "function isPoolTier(",
+ "function poolLedger(",
+ "sc.pool.topup_usd * payload.poolTopupUnitAccrual",
+ // Ruling 3: marginCell renders the em-dash, never "0%", for a pooled row.
+ "function marginCell(",
+ `pos === "pooled"`,
+ // readScenarioFromDOM reads all seven new knobs back into the scenario.
+ "sc.sheet.box_month_server_usd = numVal(",
+ "sc.sheet.box_month_device_usd = numVal(",
+ "sc.sheet.mem_gb_hour_usd = numVal(",
+ "sc.fleet_break.min_boxes = numVal(",
+ "sc.fleet_break.discount = numVal(",
+ "sc.pool.revenue_frac = numVal(",
+ "sc.pool.topup_usd = numVal(",
+ // recompute() applies the tier override AFTER priceAccount, so a knob
+ // tweak can never flip a pooled row back to priced.
+ "var tiers = payload.tiers || {};",
+ "isPoolTier(tierOf(tiers, acct))",
+ `a.freeTierPos = "pooled";`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet/economics inline script missing %q", want)
+ }
+ }
+}
+
+// TestEconomicsAccountsPageTierChipAndForm proves the accounts page renders
+// a chip-tier-<tier> chip for a tiered account and a per-row operator form
+// (progressive: a plain <select>+submit, works without JS) posting to
+// /fleet/economics/tier — the door economicspass.go's
+// handleEconomicsTierSave already serves (Task 4/5).
+func TestEconomicsAccountsPageTierChipAndForm(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEconomicsGolden(t, fx)
+ 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)
+ }
+
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts")
+ if status != 200 {
+ t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body)
+ }
+ for _, want := range []string{
+ `class="chip chip-tier-hosted"`,
+ `action="/fleet/economics/tier"`,
+ fmt.Sprintf(`name="account" value="%s"`, fixtureAcct),
+ `<select name="tier">`,
+ `value="hosted" selected`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body)
+ }
+ }
+}
+
+// TestEconomicsAccountsPageTierFormOnlyForConsoleRows is the fix-round
+// regression for IMPORTANT 2 (Task 6 review): a usage-only account — real
+// traffic (usage.EdgeRow.Account is read directly by sweepUsage,
+// economicspass.go, independent of any registry route) under a sqid that
+// never got a console account row (accounts table) — is absent from
+// EconomicsView.Tiers entirely (Store.AccountTiers only ever selects live
+// accounts-table rows). Its row must still render a plain "unassigned"
+// chip, but WITHOUT the per-row tier-set form: POSTing that form's account
+// sqid to /fleet/economics/tier can only ever fail with "No such account."
+// (handleEconomicsTierSave's AccountBySqid lookup). A REAL console row that
+// simply has no tier assigned yet (the explicit "" default — fixtureAcct
+// here, left untouched) is a different case entirely and must still get
+// the form: index can't tell the two apart, which is exactly why tierKnown
+// (server.go) exists.
+func TestEconomicsAccountsPageTierFormOnlyForConsoleRows(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEconomicsGolden(t, fx)
+
+ // A usage-only account: edge traffic under a sqid with no accounts-table
+ // row at all. A DIFFERENT box than seedEconomicsGolden's "box1", so this
+ // writes its own EdgeDay object rather than colliding with the one
+ // seedEconomicsGolden already created (putStoreJSON is create-only).
+ const ghostAcct = "ghostacct"
+ date := usage.DayOf(time.Now())
+ putStoreJSON(t, fx.rel, usage.EdgeDayKey("boxghost", date), usage.EdgeDay{
+ V: 1, Box: "boxghost", Date: date,
+ Hours: map[string]map[string]usage.EdgeRow{"09": {
+ "ghost.example.com": {Account: ghostAcct, App: "ghostapp", Requests: 100, BytesOut: 1e6},
+ }},
+ })
+
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts")
+ if status != 200 {
+ t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body)
+ }
+
+ // The usage-only ghost account's row: unassigned chip, no form.
+ rowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, ghostAcct))
+ if rowStart < 0 {
+ t.Fatalf("/fleet/economics/accounts missing a row for the usage-only account %q in:\n%s", ghostAcct, body)
+ }
+ rowEnd := strings.Index(body[rowStart:], "</tr>")
+ if rowEnd < 0 {
+ t.Fatalf("unterminated row for %q in:\n%s", ghostAcct, body)
+ }
+ ghostRow := body[rowStart : rowStart+rowEnd]
+ if !strings.Contains(ghostRow, `class="chip chip-tier-unassigned"`) {
+ t.Fatalf("usage-only account %q row missing the unassigned chip:\n%s", ghostAcct, ghostRow)
+ }
+ if strings.Contains(ghostRow, "<form") {
+ t.Fatalf("usage-only account %q row must NOT render the tier-set form (no console row to set a tier on):\n%s", ghostAcct, ghostRow)
+ }
+
+ // A real console-row account with no tier assigned yet (fixtureAcct,
+ // untouched — the explicit "" default) still gets the form.
+ fixtureRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, fixtureAcct))
+ if fixtureRowStart < 0 {
+ t.Fatalf("/fleet/economics/accounts missing a row for %q in:\n%s", fixtureAcct, body)
+ }
+ fixtureRowEnd := strings.Index(body[fixtureRowStart:], "</tr>")
+ if fixtureRowEnd < 0 {
+ t.Fatalf("unterminated row for %q in:\n%s", fixtureAcct, body)
+ }
+ fixtureRow := body[fixtureRowStart : fixtureRowStart+fixtureRowEnd]
+ if !strings.Contains(fixtureRow, `class="chip chip-tier-unassigned"`) {
+ t.Fatalf("console-row account %q (untiered) row missing the unassigned chip:\n%s", fixtureAcct, fixtureRow)
+ }
+ if !strings.Contains(fixtureRow, "<form") || !strings.Contains(fixtureRow, "/fleet/economics/tier") {
+ t.Fatalf("console-row account %q (untiered) row must still render the tier-set form:\n%s", fixtureAcct, fixtureRow)
+ }
+}
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index ed8e45cb2..9e400d3b1 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log"
+ "math"
"net/http"
"os"
"strconv"
@@ -505,19 +506,212 @@ func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, d
return nil
}
+// rollupPresenceHours converts one box's day rollup into billable box-hours
+// under the fleet-hours presence rule (task-3 brief): fleet-box rollups may
+// carry only the host-level aggregate (no per-unit CPU sampling — the box
+// runs no CARLOS-managed unit at all, just the remote-host-agent itself), so
+// presence is derived from the Host aggs rather than Units. For each PRESENT
+// hour (a key in d.Hours, regardless of what it carries):
+// - Host["cpu"].N minutes/60 when the host cpu agg is present;
+// - else the max N across whatever OTHER Host aggs the hour does carry;
+// - else — Host itself has no aggs at all for the hour — a flat 60-minute
+// (1.0h) fallback, since a present-but-metric-less hour key still means
+// the box was up and dialed in for that hour.
+//
+// Each hour's contribution is clamped to at most 1.0 (review, fix round 1):
+// a mid-hour agent restart can land more than 60 samples in one hour bucket
+// — mergeExistingChunk dedups exact-timestamp collisions only, not a
+// restart's fresh cadence landing alongside the old one — and an N above 60
+// must read as "fully present that hour", never as more than one hour billed
+// for one hour of wall time.
+func rollupPresenceHours(d metrics.DayRollup) float64 {
+ var total float64
+ for _, hour := range d.Hours {
+ var minutes float64
+ if cpu, ok := hour.Host["cpu"]; ok {
+ minutes = float64(cpu.N)
+ } else if maxN := maxHostAggN(hour.Host); maxN > 0 {
+ minutes = float64(maxN)
+ } else {
+ minutes = 60
+ }
+ total += math.Min(1.0, minutes/60)
+ }
+ return total
+}
+
+// maxHostAggN returns the largest N across host's Agg entries, 0 for an
+// empty (or nil) map.
+func maxHostAggN(host map[string]metrics.Agg) int {
+ maxN := 0
+ for _, agg := range host {
+ if agg.N > maxN {
+ maxN = agg.N
+ }
+ }
+ return maxN
+}
+
+// hasDeviceTag reports whether tags (FleetBox.Tags' comma-separated list)
+// carries the literal segment "device" — the device-vs-server meter split.
+func hasDeviceTag(tags string) bool {
+ for _, tag := range strings.Split(tags, ",") {
+ if tag == "device" {
+ return true
+ }
+ }
+ return false
+}
+
+// fleetMeters derives per-account fleet box-hours for [from, to]: for every
+// box registered to every fleet (ALL statuses — a detached box's past hours
+// still bill, same posture as a deleted-account's already-written days),
+// account -> {fleet_box_hours_server, fleet_box_hours_device}, split by
+// whether the box's Tags carry the literal "device" tag.
+//
+// Deliberately a SECOND bounded read over metrics day objects, not an
+// extension of sweepUsage's single pass: sweepUsage iterates every box under
+// metrics/boxes/ once and attributes by UNIT (unitOwner), which has no
+// concept of a fleet box at all — a fleet box's day rollup carries no units
+// most of the time (see rollupPresenceHours's doc comment), only a host
+// aggregate, so folding this in there would mean threading a
+// (fleet,label)->owner map through sweepUsage's hot loop and its
+// box-vs-fleet-box branching for a meter family only two of usageSweep's
+// several callers need at all. This keeps sweepUsage and its existing tests
+// completely untouched: the read here is bounded to exactly the boxes that
+// ARE fleet boxes (one Get per (fleet,label) per day in range; a day with no
+// rollup is skipped, same missing-means-skip convention sweepUsage itself
+// uses).
+//
+// Fleet box rollups do NOT live at the platform root's metrics.DayKey(label,
+// date) — a door-backed fleet box's write ACL admits only
+// fleets/<own fleet>/** (internal/edge/storedoor.go's aclRead/aclWrite), and
+// resolveStatusPrefix (internal/host/agent.go) defaults every door-backed
+// box's publish prefix to "fleets/<fleet>/" for exactly that reason. This
+// reads each fleet's rollups through the same
+// release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet+"/") rebase the
+// console's own fleet page already uses for heartbeats
+// (loadFleetPage/fleets.go), with the plain metrics.DayKey(label, date)
+// inside it — so the map below is keyed by (fleet, label), not label alone.
+//
+// (fleet, label) keying also means a same-labeled box in two DIFFERENT
+// fleets is not a collision at all — each lives under its own fleet prefix
+// at a distinct object key, so both are read and billed independently.
+// (Review, fix round 1: an earlier version of this function keyed by label
+// alone and resolved a same-label clash to the alphabetically-first fleet —
+// which, under prefixed reads, would have been silent DATA LOSS for the
+// second fleet's box-hours, not a display-only tie-break. Removed entirely,
+// not just fixed, since the underlying ambiguity no longer exists.)
+//
+// Known gaps, recorded rather than fixed at modelling grade (review, fix
+// round 1):
+// - A fleet box provisioned before this prefixing existed — a grandfathered
+// bucket-backed box publishing straight to the platform root, or one an
+// operator hand-configured with CARLOS_STATUS_PREFIX="" — is invisible to
+// this meter: its rollups never appear under fleets/<fleet>/, so it
+// contributes zero fleet box-hours, and its sampled units (if any) fall
+// through unitOwner's unmatched case to release.OpsAccount like any other
+// unrecognized unit, same as a platform box's stray unit would. This
+// closes itself once the box (re)publishes under its fleet prefix — no
+// separate migration is needed.
+// - fleetMeters failing makes statementsPass itself fail (see
+// statementsPass's doc comment for why that's deliberate here).
+// - Retagging a box "device" mid-month re-prices the box's ENTIRE current
+// month the next time this pass runs, not just days after the retag —
+// statements are a create-OR-REPLACE snapshot of the whole month to
+// date, not an append-only ledger, so there is no "as of" boundary to
+// honor. Modelling-grade, matching every other meter this pass derives.
+func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[string]map[string]float64, error) {
+ reg, err := s.openPassRegistry()
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: %w", err)
+ }
+ fleets, err := reg.ListFleets()
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: %w", err)
+ }
+
+ type boxOwner struct {
+ acct string
+ device bool
+ }
+ type boxKey struct{ fleet, label string }
+ boxes := map[boxKey]boxOwner{}
+
+ for _, f := range fleets {
+ fboxes, err := reg.ListFleetBoxes(f.Name)
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: list boxes %s: %w", f.Name, err)
+ }
+ for _, b := range fboxes {
+ boxes[boxKey{fleet: f.Name, label: b.Label}] = boxOwner{acct: f.Account, device: hasDeviceTag(b.Tags)}
+ }
+ }
+
+ out := map[string]map[string]float64{}
+ touch := func(acct string) map[string]float64 {
+ mm := out[acct]
+ if mm == nil {
+ mm = map[string]float64{}
+ out[acct] = mm
+ }
+ return mm
+ }
+
+ fromDay := from.UTC().Truncate(24 * time.Hour)
+ toDay := to.UTC().Truncate(24 * time.Hour)
+ for bk, owner := range boxes {
+ meter := usage.MeterFleetBoxHoursServer
+ if owner.device {
+ meter = usage.MeterFleetBoxHoursDevice
+ }
+ fleetStore := release.NewPrefixStore(s.cfg.Releases, "fleets/"+bk.fleet+"/")
+ for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
+ date := usage.DayOf(d)
+ key := metrics.DayKey(bk.label, date)
+ raw, _, gerr := fleetStore.Get(ctx, key)
+ if gerr != nil {
+ continue // missing = skip, same convention as sweepUsage
+ }
+ var roll metrics.DayRollup
+ if jerr := json.Unmarshal(raw, &roll); jerr != nil {
+ log.Printf("console: fleet meters: parse fleets/%s/%s: %v", bk.fleet, key, jerr)
+ continue
+ }
+ touch(owner.acct)[meter] += rollupPresenceHours(roll)
+ }
+ }
+ return out, nil
+}
+
// statementsPass folds the month's metrics, edge and storage days (plus
-// census, for app_months) into one usage.Statement per account, create-or-
-// replace via usage.UpsertStatement — safe to run repeatedly as new days'
-// objects appear. It is a thin caller: open the registry (refusing early —
-// see openPassRegistry — since this is the WRITE pass), sweep the whole
-// month to date (sweepUsage), fold in storage/census meters
-// (foldStorageAndCensus), then upsert.
+// census, for app_months, and fleet box-hours, for fleet_box_hours_server/
+// device) into one usage.Statement per account, create-or-replace via
+// usage.UpsertStatement — safe to run repeatedly as new days' objects
+// appear. It is a thin caller: open the registry (refusing early — see
+// openPassRegistry — since this is the WRITE pass), sweep the whole month to
+// date (sweepUsage), fold in storage/census meters (foldStorageAndCensus),
+// fold in fleet meters (fleetMeters — a union merge, since an account with
+// ONLY fleet usage and no platform routes at all must still get every meter
+// key it's owed), then upsert.
//
// Known shortfall, recorded rather than fixed at modelling grade: an account
// deleted mid-month drops out of s.store.Accounts() (its query filters
// deleted_at), so this pass stops upserting its statement from the day of
// deletion onward — the account's last statement object simply stops moving
// forward, rather than the month closing it out explicitly.
+//
+// fleetMeters failing (a bad registry read, a bucket hiccup under a fleet
+// prefix) makes this WHOLE pass return an error rather than upsert
+// statements missing that account's fleet hours — deliberate (review, fix
+// round 1): a billing meter silently going to zero for a tick is worse than
+// this hourly pass simply not moving the statement forward that tick and
+// trying again next hour with the same last-good numbers still standing.
+// This is the WRITE side's posture only — Task 5's read side
+// (buildEconomicsView) must NOT inherit it: a live view degrading to "could
+// not read fleet hours" for one request is fine, refusing the whole
+// economics page over it is not, the same asymmetry sweepUsage's own doc
+// comment draws between its two callers.
func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
reg, err := s.openPassRegistry()
if err != nil {
@@ -542,6 +736,20 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
if err := s.foldStorageAndCensus(ctx, monthStart, nowUTC, meters); err != nil {
return fmt.Errorf("console: statements pass: %w", err)
}
+ fleetMM, err := s.fleetMeters(ctx, monthStart, nowUTC)
+ if err != nil {
+ return fmt.Errorf("console: statements pass: %w", err)
+ }
+ for acct, mm := range fleetMM {
+ dst := meters[acct]
+ if dst == nil {
+ dst = map[string]float64{}
+ meters[acct] = dst
+ }
+ for meter, v := range mm {
+ dst[meter] += v
+ }
+ }
coveredDays := sweep.CoveredDays
// Every account gets a statement this pass, even one with zero usage —
@@ -1032,6 +1240,85 @@ func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request)
back("ok", "Saved the "+month+" bill.")
}
+// handleEconomicsTierSave sets one account's fleet-pool tier (economics-v2,
+// Task 4) — Task 5's placement pass reads it back via Store.AccountTiers.
+// Registered without a method prefix (server.go), same reasoning as
+// handleEconomicsScenarioSave: the method check comes FIRST, before the
+// session/operator gate, so a wrong-method request is byte-identical to an
+// unknown route. Resolves the target account by sqid via AccountBySqid, NOT
+// AccountAnyBySqid: AccountBySqid is already viewer-independent — it
+// filters deleted_at, not membership, so it satisfies "an operator can set
+// the tier on ANY account, including one they don't belong to" just fine —
+// and AccountAnyBySqid's doc comment (store.go) reserves it for
+// materialization's one caller alone. Using AccountBySqid also means a
+// tombstoned account's sqid correctly reports "No such account." instead of
+// a false success that writes an inert tier onto a dead row (review, fix
+// round 1).
+func (s *Server) handleEconomicsTierSave(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: economics tier save: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/fleet/economics/accounts", http.StatusSeeOther)
+ }
+
+ sqid := strings.TrimSpace(r.FormValue("account"))
+ acct, err := s.store.AccountBySqid(sqid)
+ if err != nil {
+ log.Printf("console: economics tier save: lookup %s: %v", sqid, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not look up the account. Please try again.")
+ return
+ }
+ if acct == nil {
+ back("err", "No such account.")
+ return
+ }
+
+ tier := r.FormValue("tier")
+ if err := s.store.SetAccountTier(acct.ID, tier); err != nil {
+ // A fixed message, not err.Error(): sibling doors use human wording,
+ // and echoing arbitrary form input into a Set-Cookie flash risks
+ // exceeding the cookie's size limit and silently dropping the flash
+ // (review, fix round 1). The rejected value still reaches the logs.
+ log.Printf("console: economics tier save: rejected tier %q for %s: %v", tier, sqid, err)
+ back("err", "Tier must be one of: try, pool, hosted, fleet, dedicated — or blank to unset.")
+ return
+ }
+
+ back("ok", "Set "+sqid+"'s tier to "+tierLabel(tier)+".")
+}
+
+// tierLabel renders SetAccountTier's empty-string value as something a flash
+// message can say, rather than a blank space between two words.
+func tierLabel(tier string) string {
+ if tier == "" {
+ return "unassigned"
+ }
+ return tier
+}
+
// handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos
// economics bill`, Task 15). Registered without a method prefix
// (server.go); the method check comes first, same reasoning as
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go
index a363ace3e..5ceaf12fa 100644
--- a/internal/console/economicspass_test.go
+++ b/internal/console/economicspass_test.go
@@ -5,8 +5,11 @@ import (
"encoding/json"
"errors"
"math"
+ "net/http"
+ "net/url"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -450,3 +453,244 @@ func TestStatementsPassRejectsInvalidStorageAccountSegment(t *testing.T) {
t.Fatalf("ops storage_gb_month = %v, want %v (invalid segment's bytes must land here)", got, want)
}
}
+
+// TestRollupPresenceHours pins rollupPresenceHours's exact rule (task-3
+// brief, clamp added in review fix round 1): per present hour (a key in
+// Hours), Host["cpu"].N minutes/60 when present; else the max N across any
+// other Host agg; else — Host itself carrying no aggs at all — a flat
+// 60-minute (1.0h) fallback; each hour's contribution then clamps to at
+// most 1.0. Three hours: Host["cpu"].N=60 (-> 1.0h), an empty Host map
+// (-> 1.0h fallback), and Host["cpu"].N=90 — more than 60 samples in one
+// hour, e.g. a mid-hour agent restart — which must still clamp to 1.0h, not
+// 1.5h; total 3.0.
+func TestRollupPresenceHours(t *testing.T) {
+ d := metrics.DayRollup{V: 1, Box: "bx", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{
+ "09": {Host: map[string]metrics.Agg{"cpu": {N: 60}}},
+ "10": {Host: map[string]metrics.Agg{}},
+ "11": {Host: map[string]metrics.Agg{"cpu": {N: 90}}},
+ }}
+ if got, want := rollupPresenceHours(d), 3.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("rollupPresenceHours = %v, want %v", got, want)
+ }
+}
+
+// TestStatementsPassFleetMeters: a fleet box's day rollups price into the
+// owning account's fleet_box_hours_server/device meters, split by the
+// literal "device" tag — bx-server (untagged) contributes to the server
+// meter, bx-pi (tags "pi,device") to the device meter. Rollups are written
+// under fleets/<fleet>/ — the SAME prefix a door-backed fleet box's write
+// ACL admits (internal/edge/storedoor.go aclWrite) and resolveStatusPrefix
+// (internal/host/agent.go) defaults a fleet box's publishing to — proving
+// fleetMeters reads the real path, not the platform root.
+func TestStatementsPassFleetMeters(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: "bae", Bucket: "fx-data"}); err != nil {
+ t.Fatal(err)
+ }
+ tokenHash := strings.Repeat("a", 64)
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-server", TokenHash: tokenHash}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-pi", TokenHash: tokenHash}); err != nil {
+ t.Fatal(err)
+ }
+ // AddFleetBox's INSERT does not carry tags (schema default '') — a box's
+ // tags are set through the dedicated SetFleetBoxTags writer.
+ if err := reg.SetFleetBoxTags("fx", "bx-pi", "pi,device"); err != nil {
+ t.Fatal(err)
+ }
+
+ fleetPrefix := "fleets/fx/"
+ serverHours := map[string]metrics.HourAgg{}
+ for h := 0; h < 24; h++ {
+ serverHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
+ }
+ putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-server", "2026-08-10"), metrics.DayRollup{
+ V: 1, Box: "bx-server", Date: "2026-08-10", Hours: serverHours,
+ })
+
+ piHours := map[string]metrics.HourAgg{}
+ for h := 0; h < 12; h++ {
+ piHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
+ }
+ putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-pi", "2026-08-10"), metrics.DayRollup{
+ V: 1, Box: "bx-pi", Date: "2026-08-10", Hours: piHours,
+ })
+
+ if err := fx.srv.statementsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, _, err := fx.rel.Get(ctx, usage.StatementKey("bae", "2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st usage.Statement
+ if err := json.Unmarshal(raw, &st); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := st.Meters[usage.MeterFleetBoxHoursServer], 24.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("fleet_box_hours_server = %v, want %v", got, want)
+ }
+ if got, want := st.Meters[usage.MeterFleetBoxHoursDevice], 12.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("fleet_box_hours_device = %v, want %v", got, want)
+ }
+}
+
+// hourKey formats an hour 0-23 as the DayRollup.Hours key shape ("00".."23").
+func hourKey(h int) string {
+ return time.Date(2000, 1, 1, h, 0, 0, 0, time.UTC).Format("15")
+}
+
+// TestAccountTierStore: SetAccountTier writes a validated tier that
+// AccountTiers then reports keyed by sqid; an invalid tier is rejected with
+// no write; an unknown account id is a silent no-op, matching this store's
+// UPDATE...WHERE convention elsewhere (e.g. ReapAccount's deleted_at guard)
+// rather than a distinguishable error a caller would need to branch on.
+func TestAccountTierStore(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ id := testAccountID()
+ sqid := "test-tier-1"
+ if err := s.CreateAccount(id, sqid, "Tierco", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+
+ // Freshly created account defaults to the empty tier.
+ tiers, err := s.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := tiers[sqid], ""; got != want {
+ t.Fatalf("fresh account tier = %q, want %q", got, want)
+ }
+
+ if err := s.SetAccountTier(id, "pool"); err != nil {
+ t.Fatal(err)
+ }
+ tiers, err = s.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := tiers[sqid], "pool"; got != want {
+ t.Fatalf("account tier after SetAccountTier = %q, want %q", got, want)
+ }
+
+ // Invalid tier: rejected, no write.
+ if err := s.SetAccountTier(id, "bogus"); err == nil {
+ t.Fatal("SetAccountTier with an invalid tier returned nil error, want a rejection")
+ }
+ tiers, err = s.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := tiers[sqid], "pool"; got != want {
+ t.Fatalf("account tier after a rejected invalid write = %q, want unchanged %q", got, want)
+ }
+
+ // Unknown account id: no-op, no error.
+ if err := s.SetAccountTier(id+999999, "fleet"); err != nil {
+ t.Fatalf("SetAccountTier on an unknown id returned an error, want a silent no-op: %v", err)
+ }
+
+ // A deleted account is excluded from AccountTiers, same filter as
+ // Accounts().
+ if err := s.DeleteAccount(id, now); err != nil {
+ t.Fatal(err)
+ }
+ tiers, err = s.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := tiers[sqid]; ok {
+ t.Fatalf("AccountTiers still reports a deleted account: %+v", tiers)
+ }
+}
+
+// TestEconomicsTierDoor: POST /fleet/economics/tier is registered without a
+// method prefix (server.go), same reasoning as
+// TestEconomicsWriteDoorsRejectNonPOST — a wrong-method request must be
+// byte-identical to an unknown route, both signed out and signed in as a
+// non-operator, before the operator gate, before CSRF. An operator POST
+// resolves the account by sqid (via AccountBySqid — already
+// viewer-independent, since it filters deleted_at rather than membership),
+// sets its tier and redirects to /fleet/economics/accounts.
+func TestEconomicsTierDoor(t *testing.T) {
+ fx := newConsoleFixture(t)
+ out := newBrowser(t)
+ ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
+
+ const path = "/fleet/economics/tier"
+
+ // Signed out: GET is the ghost 404.
+ if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
+ t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
+ }
+ // Signed out: POST is the same ghost 404 too (method check runs before
+ // the session gate, but a signed-out POST must still be indistinguishable
+ // from an unknown route — no session cookie means no CSRF token to send
+ // at all).
+ signedOutForm := url.Values{"csrf": {""}, "account": {fx.sqid}, "tier": {"pool"}}
+ if status, _, body := postForm(t, out, fx.ts.URL+path, signedOutForm); status != ghostStatus || body != ghostBody {
+ t.Fatalf("signed-out POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
+ }
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // Signed in, not an operator: GET is the ghost 404 (method-first).
+ if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
+ t.Fatalf("non-operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
+ }
+
+ // Signed in, not an operator: POST is the same ghost 404 (gate before
+ // CSRF).
+ opForm := url.Values{"csrf": {csrf}, "account": {fx.sqid}, "tier": {"pool"}}
+ if status, _, body := postForm(t, c, fx.ts.URL+path, opForm); status != ghostStatus || body != ghostBody {
+ t.Fatalf("non-operator POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ // Operator GET: still the ghost 404 (method check runs first).
+ if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
+ t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
+ }
+
+ // Operator POST, wrong CSRF: 403, same shape TestScenarioSaveGateAndValidation
+ // pins for the sibling doors — the gate runs AFTER the operator check,
+ // so this is a real 403, not the ghost 404.
+ badCSRFForm := url.Values{"csrf": {"not-the-real-token"}, "account": {fx.sqid}, "tier": {"pool"}}
+ if status, _, _ := postForm(t, c, fx.ts.URL+path, badCSRFForm); status != http.StatusForbidden {
+ t.Fatalf("operator POST with wrong CSRF = %d, want %d", status, http.StatusForbidden)
+ }
+ tiers, err := fx.st.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := tiers[fx.sqid]; got != "" {
+ t.Fatalf("account tier after a wrong-CSRF POST = %q, want unchanged %q", got, "")
+ }
+
+ // Operator POST, happy path: sets the tier and redirects.
+ status, loc, _ := postForm(t, c, fx.ts.URL+path, opForm)
+ if status != http.StatusSeeOther || loc != "/fleet/economics/accounts" {
+ t.Fatalf("operator tier POST = %d -> %q, want 303 -> /fleet/economics/accounts", status, loc)
+ }
+ tiers, err = fx.st.AccountTiers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := tiers[fx.sqid], "pool"; got != want {
+ t.Fatalf("account tier after operator POST = %q, want %q", got, want)
+ }
+}
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go
index 9b6c08de1..40a74de75 100644
--- a/internal/console/economicsview.go
+++ b/internal/console/economicsview.go
@@ -69,6 +69,31 @@ 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 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.
@@ -107,6 +132,28 @@ 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"`
+ // PoolTopupUnitAccrual is Σ 1/daysInMonth(d) over [from, to] — the same
+ // per-day sum poolTopupAccrual folds against Scenario.Pool.TopupUSD
+ // server-side, but with topupUSD fixed at 1 so it carries no dollar
+ // value of its own: poolTopupAccrual(from, to, x) == x *
+ // PoolTopupUnitAccrual for any x, by that function's own linearity (each
+ // day contributes topupUSD/daysInMonth(that day), so scaling topupUSD
+ // scales the whole sum). The JS twin (econ_maths_js's poolLedger)
+ // multiplies this dimensionless figure by whatever topup_usd the
+ // operator's live knob currently reads, reproducing poolTopupAccrual
+ // EXACTLY under any knob value with no client-side re-derivation of the
+ // per-day loop — the carried ruling (Task 6 review) this field exists to
+ // satisfy: the alternative (shipping the accrued DOLLAR figure for the
+ // knob's STARTING value and trying to rescale it) breaks at
+ // oldTopup==0, this doesn't. Additive key.
+ PoolTopupUnitAccrual float64 `json:"poolTopupUnitAccrual"`
}
// economicsCacheEntry is one period's cached sweep — the metricsCache
@@ -201,9 +248,13 @@ func economicsPeriodBounds(period string, now time.Time) (from, to time.Time) {
// freely sort.Slice(view.Accounts, ...) or add/remove top-level Meters
// entries without racing the cache or another caller doing the same —
// Task 13's dashboard sort is the motivating case. Every OTHER field
-// (Scenario, Boxes, Bills, Scenarios, DataJSON, ...) is still shared: treat
-// those, and the inner per-account meter maps Meters' values point at, as
-// immutable.
+// (Scenario, Boxes, Bills, Scenarios, DataJSON, Tiers, Pool, ...) is still
+// shared: treat those, and the inner per-account meter maps Meters' values
+// point at, as immutable. Tiers (Task 5) is read-only exactly like Scenario
+// or Bills — nothing mutates it after buildEconomicsView returns, so it
+// gets no defensive copy of its own. Pool is a plain value struct
+// (PoolLedger), copied for free by copyEconomicsView's `cp := *v`, same as
+// every other non-pointer, non-slice, non-map field on EconomicsView.
func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error) {
period = normalizeEconomicsPeriod(period)
c := s.economicsCacheEntry(period)
@@ -343,6 +394,48 @@ 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. A registry-less console would otherwise show
+ // TWO banners for the SAME missing registry (loadRoutesForEconomics's
+ // and this one, since fleetMeters opens that identical registry) — see
+ // the warning=="" guard below, which keeps it to one banner, one cause.
+ if fleetMM, ferr := s.fleetMeters(sweepCtx, from, to); ferr != nil {
+ log.Printf("console: load economics: fleet meters unavailable: %v", ferr)
+ // Suppress a second banner when routes are ALREADY degraded
+ // (warning != "" here can only mean loadRoutesForEconomics found no
+ // usable registry, the one thing that precedes this call): fleetMeters
+ // opens that exact same registry, so its failure here is the same
+ // root cause restated, not a second independent fault. One banner,
+ // one cause — still logged above for whoever reads the process log.
+ if warning == "" {
+ warning = fmt.Sprintf("fleet metering unavailable: %v", ferr)
+ }
+ } 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 +451,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 +489,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 +518,42 @@ 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
+ // accrued day-by-day over [from, to] — see poolTopupAccrual — NOT the
+ // single PeriodDays/DaysInMonth fraction the brief states literally:
+ // that fraction is dishonest on period=last, whose PeriodDays counts
+ // LAST month's days while DaysInMonth (EconomicsView.DaysInMonth's own
+ // doc comment) is pinned to THIS month's — a Jan-from-Feb "last" view
+ // would credit 31/28 of a month's top-up, over 100%. 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
+ }
+ }
+ // poolTopupUnitAccrual: see economicsPayload.PoolTopupUnitAccrual's doc
+ // comment on the field this feeds — topupUSD fixed at 1 turns
+ // poolTopupAccrual into the dimensionless per-day sum the JS twin scales
+ // by the live topup knob.
+ poolTopupUnitAccrual := poolTopupAccrual(from, to, 1)
+ poolUSD := sc.Pool.RevenueFrac*payingRevenue + sc.Pool.TopupUSD*poolTopupUnitAccrual
+ 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).
@@ -441,14 +591,16 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom
}
payload := economicsPayload{
- Scenario: sc,
- Meters: meters,
- BoxLoads: sweep.PerBox,
- BoxSpecs: sc.RateCard.Boxes,
- Bills: bills,
- CoveredDays: sweep.CoveredDays,
- PeriodDays: sweep.PeriodDays,
- DaysInMonth: daysInMonth,
+ Scenario: sc,
+ Meters: meters,
+ BoxLoads: sweep.PerBox,
+ BoxSpecs: sc.RateCard.Boxes,
+ Bills: bills,
+ CoveredDays: sweep.CoveredDays,
+ PeriodDays: sweep.PeriodDays,
+ DaysInMonth: daysInMonth,
+ Tiers: tiers,
+ PoolTopupUnitAccrual: poolTopupUnitAccrual,
}
raw, err := json.Marshal(payload)
if err != nil {
@@ -470,9 +622,65 @@ 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
+ }
+}
+
+// poolTopupAccrual accrues Scenario.Pool.TopupUSD — a flat MONTHLY figure —
+// day-by-day over [from, to] (both truncated to UTC midnight, inclusive):
+// precedent foldStorageAndCensus's per-day denominator (economicspass.go),
+// applied here because the naive single-fraction reading of the brief
+// (TopupUSD * PeriodDays/DaysInMonth, using ONE daysInMonth for the whole
+// period) is dishonest whenever the period isn't wholly inside the CURRENT
+// calendar month: period=last's PeriodDays counts last month's days while
+// buildEconomicsView's daysInMonth is pinned to THIS month's (see
+// EconomicsView.DaysInMonth's doc comment) — a Jan-from-Feb "last" view
+// would credit 31/28 of a month's top-up, over 100%. Each day here instead
+// contributes topupUSD/daysInMonth(that day's OWN calendar month), so:
+// - "mtd" (wholly inside the current month) sums to exactly the old
+// PeriodDays/DaysInMonth fraction — unchanged behavior.
+// - "last" (the WHOLE previous calendar month) sums to exactly topupUSD —
+// one full month's top-up, never more or less.
+// - "7d" straddling a month boundary charges each day at its own month's
+// rate rather than borrowing one denominator for days in a different
+// month.
+func poolTopupAccrual(from, to time.Time, topupUSD float64) float64 {
+ fromDay := from.UTC().Truncate(24 * time.Hour)
+ toDay := to.UTC().Truncate(24 * time.Hour)
+ var total float64
+ for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
+ y, mo, _ := d.Date()
+ dim := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
+ total += topupUSD / float64(dim)
+ }
+ return total
+}
+
// 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..4252e60fa 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,196 @@ 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)
+ }
+ // Independent expected value for the topup term: today's day-of-month
+ // and today's calendar month length, derived from the wall clock
+ // directly rather than read back off view.PeriodDays/view.DaysInMonth
+ // (the SUT's own reported fields) — an "mtd" period is wholly inside
+ // the current month, so per-day accrual (poolTopupAccrual) reduces to
+ // exactly this fraction, same as it always did for "mtd".
+ nowUTC := time.Now().UTC()
+ wantDaysElapsed := float64(nowUTC.Day())
+ wantDaysInMonth := float64(time.Date(nowUTC.Year(), nowUTC.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day())
+ wantPoolUSD := sc.Pool.RevenueFrac*view.Pool.PayingRevenueUSD +
+ sc.Pool.TopupUSD*(wantDaysElapsed/wantDaysInMonth)
+ 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)
+ }
+}
+
+// TestLoadEconomicsPoolTopupAccrualLast is the fix-round regression for
+// IMPORTANT 1: the pool's top-up term must accrue day-by-day
+// (poolTopupAccrual), not the brief's literal PeriodDays/DaysInMonth single
+// fraction — dishonest on period=last, since PeriodDays there counts LAST
+// month's days while DaysInMonth is pinned to THIS month's (a Jan-from-Feb
+// "last" view would credit 31/28 of a month, over 100%). No usage is seeded
+// at all — every live account (just the built-in ops account) sits at its
+// default "" pool tier with zero cost, so PayingRevenueUSD is exactly 0 and
+// PoolUSD reduces to the topup term alone. That lets the assertion use a
+// truly INDEPENDENT expected value — sc.Pool.TopupUSD itself, no formula,
+// no day arithmetic re-derived from the view at all — which per-day accrual
+// over the WHOLE previous calendar month must equal exactly: one full
+// month's top-up, never 110% of one.
+func TestLoadEconomicsPoolTopupAccrualLast(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ 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"})
+
+ view, err := fx.srv.LoadEconomics(ctx, "last")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if view.Pool.PayingRevenueUSD != 0 {
+ t.Fatalf("Pool.PayingRevenueUSD = %v, want 0 (no usage seeded, only the pool-tier ops account exists)", view.Pool.PayingRevenueUSD)
+ }
+ if math.Abs(view.Pool.PoolUSD-sc.Pool.TopupUSD) > 1e-9 {
+ t.Fatalf("Pool.PoolUSD = %v, want exactly %v (one full previous month's top-up; PayingRevenueUSD is 0)", view.Pool.PoolUSD, sc.Pool.TopupUSD)
+ }
+}
+
// 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/console/server.go b/internal/console/server.go
index 362bf51f9..5a9868c3f 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -193,6 +193,45 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
}
return fmt.Sprintf("%.1f×", 1/(1-margin))
},
+ // marginCell renders MarginFrac as pctf normally, but an em-dash for a
+ // pooled row (FreeTierPos=="pooled") — Task 6's carried ruling: a
+ // pool/try/""-tier account's revenue is pooled, not individually priced
+ // (buildEconomicsView's tier override), so its MarginFrac is genuinely
+ // 0 — but "0%" reads as an account LOSING money, not one that simply
+ // isn't priced on its own. The JS twin (econ_maths_js's marginCell)
+ // mirrors this exactly.
+ "marginCell": func(frac float64, pos string) string {
+ if pos == "pooled" {
+ return "—"
+ }
+ return fmt.Sprintf("%.0f%%", frac*100)
+ },
+ // tierWord renders a fleet-pool tier (store.go's validAccountTiers) for
+ // display: "" (never assigned) reads "unassigned" rather than a blank
+ // chip label; every other value (try/pool/hosted/fleet/dedicated)
+ // passes through unchanged. Feeds both a tier chip's visible text and
+ // its "chip-tier-<word>" class (economics_accounts.html, Task 6).
+ "tierWord": func(tier string) string {
+ if tier == "" {
+ return "unassigned"
+ }
+ return tier
+ },
+ // tierKnown reports whether acct has a real console account row at all
+ // (present as a key in EconomicsView.Tiers, via the comma-ok form —
+ // html/template's "index" can't distinguish absent from an explicit ""
+ // tier, which is exactly the distinction this exists for). A usage-only
+ // account (fleet or edge traffic with no matching accounts-table row —
+ // e.g. a fleet-only account whose owner never signed up) is absent from
+ // the map entirely: economics_accounts.html renders its tier chip but
+ // WITHOUT the per-row tier-set form, since POSTing that form's account
+ // sqid to /fleet/economics/tier can only ever fail with "No such
+ // account." (handleEconomicsTierSave's AccountBySqid lookup) — fix
+ // round, Task 6 review, IMPORTANT 2.
+ "tierKnown": func(tiers map[string]string, acct string) bool {
+ _, ok := tiers[acct]
+ return ok
+ },
"add": func(vs ...float64) float64 {
var sum float64
for _, v := range vs {
@@ -660,6 +699,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("/fleet/economics/scenarios", s.handleEconomicsScenarioSave)
s.mux.HandleFunc("/fleet/economics/bill", s.handleEconomicsBillSave)
s.mux.HandleFunc("/api/economics/bill", s.handleAPIEconomicsBill)
+ // Account tier (economics-v2, Task 4): same bare-registration/method-
+ // first reasoning as the two doors above.
+ s.mux.HandleFunc("/fleet/economics/tier", s.handleEconomicsTierSave)
// CLI identity: the device-code login `carlos auth login` drives, the
// approval page, and the tokens an owner can see and kill (authcli.go).
s.registerCLIRoutes()
diff --git a/internal/console/store.go b/internal/console/store.go
index eddf67286..daa7b4463 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -217,6 +217,12 @@ func (s *Store) migrate() error {
// existing row defaults to "", identical to "never placed" under the
// old route-only reading.
`ALTER TABLE apps ADD COLUMN placement TEXT NOT NULL DEFAULT '';`,
+ // Fleet pool assignment (economics-v2, Task 4): the operator-set
+ // pricing/capacity tier driving Task 5's placement into a fleet pool.
+ // "" means "never assigned" — every existing row defaults to it,
+ // identical to "unassigned" under the old tier-less reading. See
+ // SetAccountTier for the fixed set of non-empty values.
+ `ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -778,6 +784,56 @@ WHERE deleted_at = 0 ORDER BY name`)
return out, rows.Err()
}
+// validAccountTiers is the fixed set SetAccountTier accepts — "" (never
+// assigned) plus economics-v2's fleet-pool tiers. Kept as a package-level
+// set rather than inlined so a future consumer (Task 5's placement pass) can
+// range over the same set without duplicating it.
+var validAccountTiers = map[string]bool{
+ "": true,
+ "try": true,
+ "pool": true,
+ "hosted": true,
+ "fleet": true,
+ "dedicated": true,
+}
+
+// SetAccountTier records accountID's fleet-pool tier (economics-v2, Task 4)
+// — Task 5's placement pass reads it back via AccountTiers. Rejects any
+// value outside validAccountTiers with a clear error rather than writing an
+// unrecognized string a downstream placement pass would silently ignore. An
+// accountID matching no row is a silent no-op (the UPDATE simply touches
+// zero rows) — this store's existing convention for a keyed UPDATE with no
+// distinguishable "not found" case (see ReapAccount's deleted_at-guarded
+// UPDATE).
+func (s *Store) SetAccountTier(accountID int64, tier string) error {
+ if !validAccountTiers[tier] {
+ return fmt.Errorf("console: invalid account tier %q", tier)
+ }
+ _, err := s.db.Exec(`UPDATE accounts SET tier = ? WHERE id = ?`, tier, accountID)
+ return err
+}
+
+// AccountTiers returns every live account's tier keyed by sqid — Task 5's
+// placement pass input. Filtered by deleted_at like Accounts(): a retired
+// account carries no tier into fleet-pool placement, same posture as it
+// carrying no usage into the economics pass.
+func (s *Store) AccountTiers() (map[string]string, error) {
+ rows, err := s.db.Query(`SELECT sqid, tier FROM accounts WHERE deleted_at = 0`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[string]string{}
+ for rows.Next() {
+ var sqid, tier string
+ if err := rows.Scan(&sqid, &tier); err != nil {
+ return nil, err
+ }
+ out[sqid] = tier
+ }
+ return out, rows.Err()
+}
+
// Role is address's role in the account, "" when they are not a member. It is
// the one permission check every account-scoped route makes.
func (s *Store) Role(accountID int64, address string) (string, error) {
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html
index f204a2962..d3ebb05c6 100644
--- a/internal/console/templates/economics.html
+++ b/internal/console/templates/economics.html
@@ -23,7 +23,16 @@
and its own recompute() share one source for both the editable knobs
and the maths, instead of a second ~300-line copy. See
economics_accounts.html's header comment for that page's (smaller)
- recompute(). */}}
+ recompute().
+
+ DataJSON payload keys (economicsPayload, economicsview.go) this page's
+ JS reads — every key here must match that struct's json tag exactly,
+ the same rule the struct's own doc comment states from the Go side:
+ scenario, meters, boxLoads, boxSpecs, bills, coveredDays, periodDays,
+ daysInMonth, tiers, poolTopupUnitAccrual (Task 6 — the dimensionless
+ Σ 1/daysInMonth(d) over the period; econ_maths_js's poolLedger scales
+ it by the live pool.topup_usd knob rather than re-deriving the per-day
+ sum client-side — see that field's own doc comment for why). */}}
{{define "economics"}}{{template "top" .}}
<h1>Fleet economics</h1>
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
@@ -83,24 +92,40 @@
<p class="tile-value" id="tile-margin">{{pctf $margin}}</p>
<p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p>
</div>
+ {{/* Fleet pool tile (Task 6): PoolLedger's own doc comment (economicsview.go)
+ for the exact formula — a share of paying revenue plus a day-accrued
+ monthly top-up, against the pool-tier accounts' own real spend. Headroom
+ MAY be negative (the pool is overdrawn this period) and renders honestly,
+ never clamped — the negative case carries the page's existing warning
+ styling (.error, same class every other on-page warning uses) on the
+ headroom figure alone, not the whole tile. */}}
+ <div class="tile" id="tile-pool">
+ <h2>Fleet pool</h2>
+ <p class="tile-value">POOL <span id="tile-pool-usd">{{usd .Economics.Pool.PoolUSD}}</span> &middot; draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> &middot; headroom <span id="tile-pool-headroom"{{if lt .Economics.Pool.HeadroomUSD 0.0}} class="error"{{end}}>{{usd .Economics.Pool.HeadroomUSD}}</span></p>
+ <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p>
+ </div>
</section>
<h2>Accounts</h2>
{{if .Economics.Accounts}}
<div class="tablewrap">
<table>
- <tr><th>Account</th><th>Cost</th><th>Revenue</th><th>Margin</th><th>Free tier</th></tr>
+ <tr><th>Account</th><th>Cost</th><th>Revenue</th><th title="Pooled rows (free-tier position &quot;pooled&quot;) show an em-dash: their revenue is shared through the fleet pool, not individually priced, so a margin fraction has nothing to divide. Fleet-tier rows can legitimately show 100%: fleet boxes are customer-owned hardware, zero cost to the platform, by design.">Margin</th><th>Free tier</th></tr>
{{range .Economics.Accounts}}
<tr data-account="{{.Account}}">
<td>{{.Account}}</td>
<td class="js-acct-cost">{{usd .CostUSD}}</td>
<td class="js-acct-revenue">{{usd .RevenueUSD}}</td>
- <td class="js-acct-margin">{{pctf .MarginFrac}}</td>
+ <td class="js-acct-margin">{{marginCell .MarginFrac .FreeTierPos}}</td>
<td class="js-acct-freetier">{{.FreeTierPos}}</td>
</tr>
{{end}}
</table>
</div>
+{{/* MINOR 3 (fix round, Task 6 review): the Margin column's title=
+ tooltip is invisible on touch/keyboard — this legend carries the
+ same two notes visibly, for everyone. */}}
+<p class="small">Margin: pooled rows show an em-dash — their revenue is shared through the fleet pool, not individually priced. Fleet-tier rows can legitimately show 100% margin — fleet boxes are customer-owned hardware, zero cost to the platform, by design.</p>
{{else}}<p class="small">No accounts to price this period.</p>{{end}}
<p class="small"><a href="/fleet/economics/accounts">Every account, searchable →</a></p>
@@ -245,9 +270,21 @@
var acctSet = {};
Object.keys(payload.meters).forEach(function (a) { acctSet[a] = true; });
Object.keys(totalAllocated).forEach(function (a) { acctSet[a] = true; });
+ var tiers = payload.tiers || {};
var accounts = {};
Object.keys(acctSet).forEach(function (acct) {
- accounts[acct] = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0);
+ var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0);
+ // Tier-aware view-layer override (economics-v2 Task 5/Task 6),
+ // applied AFTER priceAccount exactly like buildEconomicsView applies
+ // it after PriceAccount: a pool/try/""-tier account's revenue is
+ // pooled, not individually priced, so a knob tweak must never flip a
+ // pooled row back to priced by recomputing it "inside" the free tier.
+ if (isPoolTier(tierOf(tiers, acct))) {
+ a.revenueUSD = 0;
+ a.marginFrac = 0;
+ a.freeTierPos = "pooled";
+ }
+ accounts[acct] = a;
});
var revenueTotal = 0, acctCostTotal = 0, idleTotal = 0;
@@ -284,10 +321,23 @@
if (!a) { return; }
setCellText(row, ".js-acct-cost", usd(a.costUSD));
setCellText(row, ".js-acct-revenue", usd(a.revenueUSD));
- setCellText(row, ".js-acct-margin", pctf(a.marginFrac));
+ setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos));
setCellText(row, ".js-acct-freetier", a.freeTierPos);
});
+ // Fleet pool tile (Task 6): recomputed from the SAME accounts map the
+ // account rows above just used, so a knob tweak (revenue_frac,
+ // topup_usd, or anything upstream of an account's own revenue/cost)
+ // keeps the tile in lockstep with the account table on screen.
+ var pool = poolLedger(sc, tiers, accounts);
+ setText("tile-pool-usd", usd(pool.poolUSD));
+ setText("tile-pool-draw", usd(pool.drawUSD));
+ var poolHeadroomEl = document.getElementById("tile-pool-headroom");
+ if (poolHeadroomEl) {
+ poolHeadroomEl.textContent = usd(pool.headroomUSD);
+ poolHeadroomEl.classList.toggle("error", pool.headroomUSD < 0);
+ }
+
document.querySelectorAll("[data-box]").forEach(function (row) {
var box = row.getAttribute("data-box");
var load = payload.boxLoads[box];
@@ -425,6 +475,20 @@
<div class="knob-row"><label for="knob-sheet-egress-gb-usd">$/GB egress</label><input type="number" step="0.01" min="0" id="knob-sheet-egress-gb-usd" value="{{.Economics.Scenario.Sheet.EgressGBUSD}}"></div>
<div class="knob-row"><label for="knob-sheet-storage-gb-month-usd">$/GB-month storage</label><input type="number" step="0.01" min="0" id="knob-sheet-storage-gb-month-usd" value="{{.Economics.Scenario.Sheet.StorageGBMonthUSD}}"></div>
<div class="knob-row"><label for="knob-sheet-instance-hour-usd">$/instance-hour</label><input type="number" step="0.001" min="0" id="knob-sheet-instance-hour-usd" value="{{.Economics.Scenario.Sheet.InstanceHourUSD}}"></div>
+{{/* economics-v2 (Task 6): fleet box + mem-overage rate-sheet knobs — the
+ same three RateSheet fields compute.go's PriceAccount reads under
+ fleetRevenue and the rate-sheet mem-overage term, both modes. */}}
+<div class="knob-row"><label for="knob-sheet-box-month-server-usd">$/box-month (server)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-server-usd" value="{{.Economics.Scenario.Sheet.BoxMonthServerUSD}}"></div>
+<div class="knob-row"><label for="knob-sheet-box-month-device-usd">$/box-month (device)</label><input type="number" step="0.01" min="0" id="knob-sheet-box-month-device-usd" value="{{.Economics.Scenario.Sheet.BoxMonthDeviceUSD}}"></div>
+<div class="knob-row"><label for="knob-sheet-mem-gb-hour-usd">$/GB-hour mem overage (rate-sheet)</label><input type="number" step="0.001" min="0" id="knob-sheet-mem-gb-hour-usd" value="{{.Economics.Scenario.Sheet.MemGBHourUSD}}"></div>
+
+<h3>Fleet volume break</h3>
+<div class="knob-row"><label for="knob-fleet-break-min-boxes">Min avg boxes/month</label><input type="number" step="0.1" min="0" id="knob-fleet-break-min-boxes" value="{{.Economics.Scenario.FleetBreak.MinBoxes}}"></div>
+<div class="knob-row"><label for="knob-fleet-break-discount">Discount at/above min</label><input type="number" step="0.01" min="0" max="0.99" id="knob-fleet-break-discount" value="{{.Economics.Scenario.FleetBreak.Discount}}"></div>
+
+<h3>Fleet pool</h3>
+<div class="knob-row"><label for="knob-pool-revenue-frac">Revenue share to pool</label><input type="number" step="0.01" min="0" max="1" id="knob-pool-revenue-frac" value="{{.Economics.Scenario.Pool.RevenueFrac}}"></div>
+<div class="knob-row"><label for="knob-pool-topup-usd">Monthly top-up $</label><input type="number" step="0.01" min="0" id="knob-pool-topup-usd" value="{{.Economics.Scenario.Pool.TopupUSD}}"></div>
<h3>Free tier</h3>
<div class="knob-row"><label for="knob-ft-apps">Apps</label><input type="number" step="1" min="0" id="knob-ft-apps" value="{{.Economics.Scenario.FreeTier.Apps}}"></div>
@@ -605,7 +669,11 @@
revenue = bApps * sc.sheet.app_month_usd +
bEgress * sc.sheet.egress_gb_usd +
bStorage * sc.sheet.storage_gb_month_usd +
- (meters.instance_hours || 0) * sc.sheet.instance_hour_usd;
+ (meters.instance_hours || 0) * sc.sheet.instance_hour_usd +
+ // Mem overage, rate-sheet mode only — the SAME bMem binding used
+ // above for computeFrac and the clipped check below, per
+ // compute.go's PriceAccount (economics-v2 Task 2/Task 6).
+ bMem * sc.sheet.mem_gb_hour_usd;
} else { // cost-plus
var billableCost = allocatedUSD * computeFrac +
bEgress * rc.egress_gb_usd +
@@ -614,6 +682,20 @@
revenue = billableCost / (1 - sc.margin);
}
+ // Fleet box revenue (both modes) — economics-v2 (Task 2/Task 6),
+ // compute.go's PriceAccount transliterated verbatim: the fleet term
+ // sits OUTSIDE the cost-plus/rate-sheet branch above, meter reads
+ // guarded against undefined (an account with no fleet usage never
+ // wrote these keys), volume break applied only when a floor is set AND
+ // cleared.
+ var fleetRevenue = ((meters.fleet_box_hours_server || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_server_usd +
+ ((meters.fleet_box_hours_device || 0) / HOURS_PER_MONTH) * sc.sheet.box_month_device_usd;
+ var avgBoxes = ((meters.fleet_box_hours_server || 0) + (meters.fleet_box_hours_device || 0)) / HOURS_PER_MONTH;
+ if (sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes) {
+ fleetRevenue *= (1 - sc.fleet_break.discount);
+ }
+ revenue += fleetRevenue;
+
var margin = 0;
if (revenue > 0) { margin = (revenue - cost) / revenue; }
@@ -630,6 +712,51 @@
return {account: account, costUSD: cost, revenueUSD: revenue, marginFrac: margin, freeTierPos: pos};
}
+ // tierOf/isPoolTier mirror economicsview.go's same-named functions
+ // (economics-v2 Task 5/Task 6): "" — whether from an explicit blank tier
+ // or the account simply being absent from payload.tiers entirely (a
+ // fleet-only account whose owner never signed up) — is pool semantics,
+ // same as the explicit "try" and "pool" tiers.
+ function tierOf(tiers, acct) {
+ return tiers[acct] || "";
+ }
+ function isPoolTier(tier) {
+ return tier === "" || tier === "try" || tier === "pool";
+ }
+
+ // marginCell is the JS twin of server.go's "marginCell" template func: an
+ // em-dash for a pooled row, never "0%" — see that func's doc comment.
+ function marginCell(frac, pos) {
+ return pos === "pooled" ? "—" : pctf(frac);
+ }
+
+ // poolLedger mirrors buildEconomicsView's PoolLedger construction
+ // (economicsview.go, economics-v2 Task 5/Task 6): paying revenue sums
+ // every non-pool-tier row's revenue, draw sums every pool-tier row's
+ // cost, and the topup term is payload.poolTopupUnitAccrual (the
+ // dimensionless Σ 1/daysInMonth(d) the server accrued this period —
+ // see economicsPayload's doc comment) times the CURRENT topup_usd knob,
+ // reproducing poolTopupAccrual exactly under any knob value without
+ // re-deriving the per-day sum client-side. `rows` is the accounts map
+ // this page's recompute() already built (account -> priceAccount's
+ // result, AFTER the tier override below has been applied) — its
+ // revenueUSD is already 0 for a pool-tier row, but this still checks the
+ // tier explicitly for draw/paying-revenue attribution, exactly like the
+ // Go it mirrors, rather than relying on that zero.
+ function poolLedger(sc, tiers, rows) {
+ var payingRevenue = 0, poolDraw = 0;
+ Object.keys(rows).forEach(function (acct) {
+ var r = rows[acct];
+ if (isPoolTier(tierOf(tiers, acct))) {
+ poolDraw += r.costUSD;
+ } else {
+ payingRevenue += r.revenueUSD;
+ }
+ });
+ var poolUSD = sc.pool.revenue_frac * payingRevenue + sc.pool.topup_usd * payload.poolTopupUnitAccrual;
+ return {poolUSD: poolUSD, drawUSD: poolDraw, headroomUSD: poolUSD - poolDraw, payingRevenueUSD: payingRevenue};
+ }
+
// overheadUSD mirrors the server.go template func of the same name: the
// rate card's fixed monthly line items, prorated over the period by
// periodDays, not coveredDays (see that func's doc comment for why).
@@ -692,6 +819,15 @@
sc.sheet.egress_gb_usd = numVal("knob-sheet-egress-gb-usd");
sc.sheet.storage_gb_month_usd = numVal("knob-sheet-storage-gb-month-usd");
sc.sheet.instance_hour_usd = numVal("knob-sheet-instance-hour-usd");
+ // economics-v2 (Task 6): the seven new knobs, read back exactly like
+ // every knob above — one place, matching the drawer's ids one-to-one.
+ sc.sheet.box_month_server_usd = numVal("knob-sheet-box-month-server-usd");
+ sc.sheet.box_month_device_usd = numVal("knob-sheet-box-month-device-usd");
+ sc.sheet.mem_gb_hour_usd = numVal("knob-sheet-mem-gb-hour-usd");
+ sc.fleet_break.min_boxes = numVal("knob-fleet-break-min-boxes");
+ sc.fleet_break.discount = numVal("knob-fleet-break-discount");
+ sc.pool.revenue_frac = numVal("knob-pool-revenue-frac");
+ sc.pool.topup_usd = numVal("knob-pool-topup-usd");
sc.free_tier.apps = numVal("knob-ft-apps");
sc.free_tier.egress_gb = numVal("knob-ft-egress-gb");
sc.free_tier.mem_gb_hours = numVal("knob-ft-mem-gb-hours");
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html
index f8c693f67..163d9d4e4 100644
--- a/internal/console/templates/economics_accounts.html
+++ b/internal/console/templates/economics_accounts.html
@@ -16,13 +16,22 @@
see economics.html's header comment for why splicing econ_maths_js
into THIS page's own <script> IIFE keeps one scope. This page's own
recompute() (below) is deliberately smaller than economics.html's: no
- tiles, no box table, no model-vs-bill — it only re-prices the account
- rows currently in the DOM (this page's one table, one page of up to
+ tiles, no box table, no model-vs-bill, no pool-tile update (that lives
+ only on /fleet/economics) — it only re-prices the account rows
+ currently in the DOM (this page's one table, one page of up to
50 accounts), via the same allocateBox/priceAccount over the FULL
payload.boxLoads (a row's allocated cost depends on every box it
- touched, not just the rows on screen). The meter columns (CPU-h, GB-h,
- egress, storage) are raw readings, not knob-derived, so they never
- change under recompute. */}}
+ touched, not just the rows on screen), plus the SAME tier-aware
+ view-layer override economics.html's recompute() applies (Task 5/
+ Task 6): payload.tiers is read, and a pool/try/""-tier row's revenue,
+ margin and free-tier chip are overridden to pooled AFTER priceAccount
+ runs, exactly matching buildEconomicsView's server-side override — a
+ knob tweak must never flip a pooled row back to priced. The meter
+ columns (CPU-h, GB-h, egress, storage) are raw readings, not
+ knob-derived, so they never change under recompute; neither does the
+ Tier column (Task 6) — an account's fleet-pool tier is an operator
+ assignment via the per-row form below, not something a scenario knob
+ can move. */}}
{{define "economics_accounts"}}{{template "top" .}}
<h1>Fleet economics: accounts</h1>
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
@@ -77,10 +86,26 @@
<th scope="col">Storage GB-month</th>
<th scope="col">Cost</th>
<th scope="col">Would-be revenue</th>
- <th scope="col">Margin</th>
+ <th scope="col" title="Pooled rows (free-tier position &quot;pooled&quot;) show an em-dash: their revenue is shared through the fleet pool, not individually priced, so a margin fraction has nothing to divide. Fleet-tier rows can legitimately show 100%: fleet boxes are customer-owned hardware, zero cost to the platform, by design.">Margin</th>
<th scope="col">Free tier</th>
+ {{/* Tier (Task 6): the operator's fleet-pool ASSIGNMENT for this
+ account (store.go's validAccountTiers), distinct from the Free
+ tier column's PRICED POSITION — "pooled" over there can come from
+ any of try/pool/"" here. */}}
+ <th scope="col">Tier</th>
</tr>
{{range .EconAccounts.Rows}}
+ {{$tier := index $.Economics.Tiers .Account}}
+ {{/* tierKnown (fix round, Task 6 review IMPORTANT 2): "index" above
+ can't distinguish "absent from the map entirely" (a usage-only
+ account with NO console/accounts-table row — e.g. a fleet-only
+ account whose owner never signed up) from "present with the
+ explicit "" tier" (a real console account nobody has tiered yet) —
+ both read $tier=="". Only the second case gets the tier-set form:
+ POSTing the first's sqid to /fleet/economics/tier can only ever
+ fail with "No such account." (handleEconomicsTierSave's
+ AccountBySqid lookup). */}}
+ {{$known := tierKnown $.Economics.Tiers .Account}}
<tr data-account="{{.Account}}">
<td>{{.Account}}</td>
<td>{{printf "%.2f" (index .Meters "app_months")}}</td>
@@ -91,8 +116,30 @@
<td>{{printf "%.2f" (index .Meters "storage_gb_month")}}</td>
<td class="js-acct-cost">{{usd .Econ.CostUSD}}</td>
<td class="js-acct-revenue">{{usd .Econ.RevenueUSD}}</td>
- <td class="js-acct-margin">{{pctf .Econ.MarginFrac}}</td>
+ <td class="js-acct-margin">{{marginCell .Econ.MarginFrac .Econ.FreeTierPos}}</td>
<td class="js-acct-freetier"><span class="chip chip-freetier-{{.Econ.FreeTierPos}}">{{.Econ.FreeTierPos}}</span></td>
+ <td>
+ <span class="chip chip-tier-{{tierWord $tier}}">{{tierWord $tier}}</span>
+ {{if $known}}
+ {{/* Progressive: a plain <select> + submit button, no JS required —
+ the door (POST /fleet/economics/tier, handleEconomicsTierSave)
+ already exists (Task 4/5). Only rendered for a REAL console
+ account (see $known's doc comment above). */}}
+ <form method="post" action="/fleet/economics/tier">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="account" value="{{.Account}}">
+ <select name="tier">
+ <option value=""{{if eq $tier ""}} selected{{end}}>unassigned</option>
+ <option value="try"{{if eq $tier "try"}} selected{{end}}>try</option>
+ <option value="pool"{{if eq $tier "pool"}} selected{{end}}>pool</option>
+ <option value="hosted"{{if eq $tier "hosted"}} selected{{end}}>hosted</option>
+ <option value="fleet"{{if eq $tier "fleet"}} selected{{end}}>fleet</option>
+ <option value="dedicated"{{if eq $tier "dedicated"}} selected{{end}}>dedicated</option>
+ </select>
+ <button type="submit">Set</button>
+ </form>
+ {{end}}
+ </td>
</tr>
{{end}}
</table>
@@ -102,6 +149,10 @@ a fraction of a calendar month and a sum of hours run, the meters this platform
actually keeps. CPU-h/GB-h are box-side compute meters; egress and storage are
edge/S3 meters. Cost and would-be revenue are this account's own share plus its
allocated box cost, under the active scenario.</p>
+{{/* MINOR 3 (fix round, Task 6 review): the Margin column's title=
+ tooltip is invisible on touch/keyboard — this legend carries the
+ same two notes visibly, for everyone. */}}
+<p class="small">Margin: pooled rows show an em-dash — their revenue is shared through the fleet pool, not individually priced. Fleet-tier rows can legitimately show 100% margin — fleet boxes are customer-owned hardware, zero cost to the platform, by design.</p>
{{if .EconAccounts.NextURL}}<p class="small"><a href="{{.EconAccounts.NextURL}}">Next {{len .EconAccounts.Rows}} shown of {{.EconAccounts.Total}} &rarr;</a></p>{{end}}
{{else if or .EconQuery.Q .EconQuery.Pos}}
<p>No accounts match.</p>
@@ -145,12 +196,21 @@ allocated box cost, under the active scenario.</p>
});
});
+ var tiers = payload.tiers || {};
document.querySelectorAll("[data-account]").forEach(function (row) {
var acct = row.getAttribute("data-account");
var a = priceAccount(sc, acct, payload.meters[acct] || {}, totalAllocated[acct] || 0);
+ // Tier-aware view-layer override (economics-v2 Task 5/Task 6), the
+ // SAME override economics.html's recompute() applies — see this
+ // page's header comment.
+ if (isPoolTier(tierOf(tiers, acct))) {
+ a.revenueUSD = 0;
+ a.marginFrac = 0;
+ a.freeTierPos = "pooled";
+ }
setCellText(row, ".js-acct-cost", usd(a.costUSD));
setCellText(row, ".js-acct-revenue", usd(a.revenueUSD));
- setCellText(row, ".js-acct-margin", pctf(a.marginFrac));
+ setCellText(row, ".js-acct-margin", marginCell(a.marginFrac, a.freeTierPos));
var chip = row.querySelector(".js-acct-freetier .chip");
if (chip) {
chip.textContent = a.freeTierPos;
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 12fb4dc7a..386a384a8 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -194,10 +194,29 @@
chip-stale/chip-health-unknown — a fact to watch, not yet an outage.
"partial" (some usage billable, some free-tier-covered — the ordinary
lifecycle state) is a neutral accent border, chip-canary's tone.
- "paying" (nothing left free) is chip-health-ok's bordered good tone. */
+ "paying" (nothing left free) is chip-health-ok's bordered good tone.
+ "pooled" (Task 5: a try/pool/"" tier account, priced at zero revenue by
+ design, drawing from the fleet pool instead) is a STATUS, not a
+ warning — chip-env's muted neutral tone, the same posture as an
+ ordinary route fact nobody needs to act on. */
.chip-freetier-inside { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
.chip-freetier-partial { border: 1px solid var(--accent); color: var(--accent); }
.chip-freetier-paying { border: 1px solid var(--good); color: var(--good); }
+ .chip-freetier-pooled { border: 1px solid var(--rule); color: var(--muted); }
+ /* /fleet/economics/accounts (Task 6): fleet-pool TIER chips (store.go's
+ validAccountTiers) — the operator's ASSIGNMENT, distinct from the
+ freetier chips above (a PRICED POSITION). "unassigned" (never set) and
+ "try"/"pool" (both draw from the shared pool, same as an unassigned
+ account) share chip-freetier-pooled's muted, nothing-to-act-on tone.
+ "hosted"/"fleet"/"dedicated" are individually priced, each its own
+ bordered tone so the three read apart at a glance without implying a
+ ranking between them. */
+ .chip-tier-unassigned { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-try { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-pool { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-hosted { border: 1px solid var(--good); color: var(--good); }
+ .chip-tier-fleet { border: 1px solid var(--accent); color: var(--accent); }
+ .chip-tier-dedicated { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
.chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }
diff --git a/internal/economics/compute.go b/internal/economics/compute.go
index 35912fc55..cf98f26a0 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 {
@@ -83,9 +91,10 @@ func billable(meters map[string]float64, meter string, allowance float64) float6
}
// PriceAccount prices one account's meters plus its allocated box cost
-// under sc. These formulas are mirrored verbatim by the dashboard's inline
-// knob JS (economics.html) — change both together or the page will disagree
-// with the server it loaded from.
+// under sc. These formulas — including the fleet box revenue and rate-sheet
+// mem-overage terms — are mirrored verbatim by the dashboard's inline knob
+// JS (priceAccount, econ_maths_js define, templates/economics.html): change
+// both together, or the page will disagree with the server it loaded from.
func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics {
rc := sc.RateCard
cost := allocatedUSD +
@@ -108,7 +117,8 @@ func PriceAccount(sc Scenario, account string, meters map[string]float64, alloca
revenue = bApps*sc.Sheet.AppMonthUSD +
bEgress*sc.Sheet.EgressGBUSD +
bStorage*sc.Sheet.StorageGBMonthUSD +
- meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD
+ meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD +
+ bMem*sc.Sheet.MemGBHourUSD
} else { // cost-plus
billableCost := allocatedUSD*computeFrac +
bEgress*rc.EgressGBUSD +
@@ -117,6 +127,17 @@ func PriceAccount(sc Scenario, account string, meters map[string]float64, alloca
revenue = billableCost / (1 - sc.Margin)
}
+ // Fleet box revenue (both modes)
+ fleetRevenue := (meters[usage.MeterFleetBoxHoursServer]/HoursPerMonth)*sc.Sheet.BoxMonthServerUSD +
+ (meters[usage.MeterFleetBoxHoursDevice]/HoursPerMonth)*sc.Sheet.BoxMonthDeviceUSD
+
+ // Apply volume break if applicable
+ avgBoxes := (meters[usage.MeterFleetBoxHoursServer] + meters[usage.MeterFleetBoxHoursDevice]) / HoursPerMonth
+ if sc.FleetBreak.MinBoxes > 0 && avgBoxes >= sc.FleetBreak.MinBoxes {
+ fleetRevenue *= (1 - sc.FleetBreak.Discount)
+ }
+ revenue += fleetRevenue
+
margin := 0.0
if revenue > 0 {
margin = (revenue - cost) / revenue
diff --git a/internal/economics/compute_test.go b/internal/economics/compute_test.go
index 6494ab562..5d3206aec 100644
--- a/internal/economics/compute_test.go
+++ b/internal/economics/compute_test.go
@@ -105,3 +105,58 @@ func TestProject(t *testing.T) {
approx(t, "project", Project(10, 10, 30), 30)
approx(t, "zero covered", Project(10, 0, 30), 0)
}
+
+func TestPriceAccountFleetBoxes(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5, BoxMonthDeviceUSD: 1}
+ sc.FreeTier = FreeTier{}
+ // 2 server boxes + 5 device boxes, full month.
+ meters := map[string]float64{
+ usage.MeterFleetBoxHoursServer: 2 * 730,
+ usage.MeterFleetBoxHoursDevice: 5 * 730,
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "fleet revenue", ae.RevenueUSD, 2*5+5*1) // $15
+}
+
+func TestPriceAccountFleetVolumeBreak(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5}
+ sc.FleetBreak = FleetBreak{MinBoxes: 10, Discount: 0.4}
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{usage.MeterFleetBoxHoursServer: 12 * 730}
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "discounted", ae.RevenueUSD, 12*5*0.6) // $36
+ // 9.99 avg boxes: no break.
+ meters[usage.MeterFleetBoxHoursServer] = 9.99 * 730
+ ae = PriceAccount(sc, "bae", meters, 0)
+ approx(t, "no break", ae.RevenueUSD, 9.99*5)
+}
+
+func TestPriceAccountFleetAddsInCostPlusToo(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "cost-plus"
+ sc.Margin = 0.5
+ sc.Sheet.BoxMonthServerUSD = 5
+ sc.RateCard.EgressGBUSD = 0.10
+ sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{
+ usage.MeterEgressGB: 10, // $1 cost -> $2 cost-plus revenue
+ usage.MeterFleetBoxHoursServer: 1 * 730, // + $5 box-month
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "combined", ae.RevenueUSD, 2+5)
+}
+
+func TestPriceAccountMemOverage(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{MemGBHourUSD: 0.01}
+ sc.FreeTier = FreeTier{MemGBHours: 100}
+ meters := map[string]float64{usage.MeterMemGBHours: 400}
+ ae := PriceAccount(sc, "bab", meters, 0)
+ approx(t, "overage", ae.RevenueUSD, 3.0) // 300 over * $0.01
+}
diff --git a/internal/economics/scenario.go b/internal/economics/scenario.go
index cdc52f7d5..911c0ddd9 100644
--- a/internal/economics/scenario.go
+++ b/internal/economics/scenario.go
@@ -47,6 +47,9 @@ type RateSheet struct {
EgressGBUSD float64 `json:"egress_gb_usd"`
StorageGBMonthUSD float64 `json:"storage_gb_month_usd"`
InstanceHourUSD float64 `json:"instance_hour_usd"`
+ BoxMonthServerUSD float64 `json:"box_month_server_usd"`
+ BoxMonthDeviceUSD float64 `json:"box_month_device_usd"`
+ MemGBHourUSD float64 `json:"mem_gb_hour_usd"`
}
// FreeTier is per-account monthly allowances, subtracted from meters before
@@ -58,6 +61,18 @@ type FreeTier struct {
StorageGB float64 `json:"storage_gb"`
}
+// FleetBreak is a volume discount on fleet box pricing.
+type FleetBreak struct {
+ MinBoxes float64 `json:"min_boxes"`
+ Discount float64 `json:"discount"`
+}
+
+// Pool is a revenue-sharing pool for fleet deployments.
+type Pool struct {
+ RevenueFrac float64 `json:"revenue_frac"`
+ TopupUSD float64 `json:"topup_usd"`
+}
+
// Scenario is one complete knob-set, stored at ScenarioKey(Name).
type Scenario struct {
Schema int `json:"schema"` // 1
@@ -68,6 +83,8 @@ type Scenario struct {
Margin float64 `json:"margin"` // cost-plus: (price-cost)/price, so 0.8 = 5x cost
Sheet RateSheet `json:"sheet"`
FreeTier FreeTier `json:"free_tier"`
+ FleetBreak FleetBreak `json:"fleet_break"`
+ Pool Pool `json:"pool"`
// BillServices names which of a bill's Services lines the dashboard's
// model-vs-bill reconciliation (Task 13) counts as CARLOS spend — the
// "counts as CARLOS" checkbox state, round-tripped through the saved
@@ -129,8 +146,13 @@ func DefaultScenario() Scenario {
EgressGBUSD: 0.15,
StorageGBMonthUSD: 0.05,
InstanceHourUSD: 0.01,
+ BoxMonthServerUSD: 5,
+ BoxMonthDeviceUSD: 1,
+ MemGBHourUSD: 0,
},
- FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1},
+ FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1},
+ FleetBreak: FleetBreak{MinBoxes: 10, Discount: 0.4},
+ Pool: Pool{RevenueFrac: 0.10, TopupUSD: 200},
}
}
@@ -157,5 +179,26 @@ func (sc *Scenario) Validate() error {
if _, ok := sc.RateCard.Boxes[""]; !ok {
return fmt.Errorf("economics: rate card needs a %q default box spec", "")
}
+ if sc.FleetBreak.Discount < 0 || sc.FleetBreak.Discount >= 1 {
+ return fmt.Errorf("economics: fleet_break.discount %v: want [0,1)", sc.FleetBreak.Discount)
+ }
+ if sc.FleetBreak.MinBoxes < 0 {
+ return fmt.Errorf("economics: fleet_break.min_boxes %v: want >= 0", sc.FleetBreak.MinBoxes)
+ }
+ if sc.Pool.RevenueFrac < 0 || sc.Pool.RevenueFrac > 1 {
+ return fmt.Errorf("economics: pool.revenue_frac %v: want [0,1]", sc.Pool.RevenueFrac)
+ }
+ if sc.Pool.TopupUSD < 0 {
+ return fmt.Errorf("economics: pool.topup_usd %v: want >= 0", sc.Pool.TopupUSD)
+ }
+ if sc.Sheet.BoxMonthServerUSD < 0 {
+ return fmt.Errorf("economics: sheet.box_month_server_usd %v: want >= 0", sc.Sheet.BoxMonthServerUSD)
+ }
+ if sc.Sheet.BoxMonthDeviceUSD < 0 {
+ return fmt.Errorf("economics: sheet.box_month_device_usd %v: want >= 0", sc.Sheet.BoxMonthDeviceUSD)
+ }
+ if sc.Sheet.MemGBHourUSD < 0 {
+ return fmt.Errorf("economics: sheet.mem_gb_hour_usd %v: want >= 0", sc.Sheet.MemGBHourUSD)
+ }
return nil
}
diff --git a/internal/economics/scenario_test.go b/internal/economics/scenario_test.go
index b07355aa1..d2742a7c8 100644
--- a/internal/economics/scenario_test.go
+++ b/internal/economics/scenario_test.go
@@ -1,6 +1,9 @@
package economics
-import "testing"
+import (
+ "encoding/json"
+ "testing"
+)
func TestKeys(t *testing.T) {
if got := ScenarioKey("launch-pricing"); got != "economics/scenarios/launch-pricing.json" {
@@ -58,3 +61,49 @@ func TestValidScenarioName(t *testing.T) {
}
}
}
+
+func TestDefaultScenarioV2Knobs(t *testing.T) {
+ sc := DefaultScenario()
+ if sc.Sheet.BoxMonthServerUSD != 5 || sc.Sheet.BoxMonthDeviceUSD != 1 {
+ t.Fatalf("box prices = %v/%v", sc.Sheet.BoxMonthServerUSD, sc.Sheet.BoxMonthDeviceUSD)
+ }
+ if sc.FleetBreak.MinBoxes != 10 || sc.FleetBreak.Discount != 0.4 {
+ t.Fatalf("fleet break = %+v", sc.FleetBreak)
+ }
+ if sc.Pool.RevenueFrac != 0.10 || sc.Pool.TopupUSD != 200 {
+ t.Fatalf("pool = %+v", sc.Pool)
+ }
+ if err := sc.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateRejectsV2(t *testing.T) {
+ cases := []func(*Scenario){
+ func(s *Scenario) { s.FleetBreak.Discount = 1.0 },
+ func(s *Scenario) { s.FleetBreak.Discount = -0.1 },
+ func(s *Scenario) { s.FleetBreak.MinBoxes = -1 },
+ func(s *Scenario) { s.Pool.RevenueFrac = 1.5 },
+ func(s *Scenario) { s.Pool.TopupUSD = -5 },
+ func(s *Scenario) { s.Sheet.MemGBHourUSD = -0.01 },
+ }
+ for i, mutate := range cases {
+ sc := DefaultScenario()
+ sc.Name = "test"
+ mutate(&sc)
+ if err := sc.Validate(); err == nil {
+ t.Fatalf("case %d validated", i)
+ }
+ }
+}
+
+func TestOldScenarioStillParses(t *testing.T) {
+ old := `{"schema":1,"name":"x","rate_card":{"boxes":{"":{"hour_usd":0.02,"cores":2,"mem_gb":2}}},"allocation":{"policy":"cpu"},"mode":"cost-plus","margin":0.8}`
+ var sc Scenario
+ if err := json.Unmarshal([]byte(old), &sc); err != nil {
+ t.Fatal(err)
+ }
+ if sc.Pool.RevenueFrac != 0 || sc.FleetBreak.MinBoxes != 0 {
+ t.Fatalf("zero-defaults broken: %+v %+v", sc.Pool, sc.FleetBreak)
+ }
+}
diff --git a/internal/usage/usage.go b/internal/usage/usage.go
index 70a7695e8..65c586505 100644
--- a/internal/usage/usage.go
+++ b/internal/usage/usage.go
@@ -14,13 +14,15 @@ import "time"
// Meter identifiers — the canonical billing vocabulary.
const (
- MeterCPUCoreHours = "cpu_core_hours"
- MeterMemGBHours = "mem_gb_hours"
- MeterEgressGB = "egress_gb"
- MeterRequests1k = "requests_1k"
- MeterStorageGBMonth = "storage_gb_month"
- MeterAppMonths = "app_months"
- MeterInstanceHours = "instance_hours"
+ MeterCPUCoreHours = "cpu_core_hours"
+ MeterMemGBHours = "mem_gb_hours"
+ MeterEgressGB = "egress_gb"
+ MeterRequests1k = "requests_1k"
+ MeterStorageGBMonth = "storage_gb_month"
+ MeterAppMonths = "app_months"
+ MeterInstanceHours = "instance_hours"
+ MeterFleetBoxHoursServer = "fleet_box_hours_server"
+ MeterFleetBoxHoursDevice = "fleet_box_hours_device"
)
// DayOf is the UTC date label used in every usage key.