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