Merge pull request #197 from carlosframework/business-model
Carloku business model: the ladder, the pool, fleet pricing
19 files changed,
+2434
−54
docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md+291 −0docs/superpowers/specs/2026-08-13-carloku-business-model-design.md+214 −0internal/console/econlistquery.go+8 −3internal/console/econlistquery_test.go+40 −0internal/console/economicspage_test.go+409 −0internal/console/economicspass.go+293 −6internal/console/economicspass_test.go+244 −0internal/console/economicsview.go+220 −12internal/console/economicsview_test.go+199 −0internal/console/server.go+42 −0internal/console/store.go+56 −0internal/console/templates/economics.html+142 −6internal/console/templates/economics_accounts.html+68 −8internal/console/templates/layout.html+20 −1internal/economics/compute.go+30 −9internal/economics/compute_test.go+55 −0internal/economics/scenario.go+44 −1internal/economics/scenario_test.go+50 −1internal/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> · draw <span id="tile-pool-draw">{{usd .Economics.Pool.DrawUSD}}</span> · 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 "pooled") 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 "pooled") 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}} →</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. |