// Package economics prices metered usage: rate cards (what things cost us), // scenarios (tweakable pricing/margin/free-tier knob-sets), and the maths // that turns an account's statement into cost, would-be revenue and margin. // Pure computation + bucket shapes; collection lives in internal/usage. package economics import ( "fmt" "regexp" ) // LineItem is one fixed monthly overhead (domains, DNSimple, ...). type LineItem struct { Name string `json:"name"` USDMonth float64 `json:"usd_month"` } // BoxSpec is one box's cost and capacity in the rate card. The "" key is // the default spec a box without its own entry falls back to. type BoxSpec struct { HourUSD float64 `json:"hour_usd"` Cores float64 `json:"cores"` MemGB float64 `json:"mem_gb"` EBSGB float64 `json:"ebs_gb"` } // RateCard is what the platform pays, per unit. type RateCard struct { Boxes map[string]BoxSpec `json:"boxes"` // box label -> spec; "" = default EBSGBMonthUSD float64 `json:"ebs_gb_month_usd"` StorageGBMonthUSD float64 `json:"storage_gb_month_usd"` RequestsPer1kUSD float64 `json:"requests_per_1k_usd"` EgressGBUSD float64 `json:"egress_gb_usd"` // InterRegionGBUSD is what a byte costs to move between the platform's // own regions — the backhaul leg (#288): a request served through a // region edge leaves the home box for that edge (this rate) and then // the edge for the client (EgressGBUSD, which is what the home box's // metered bytes are priced at). Additive; a scenario saved before it // existed reads as 0 and the model line shows $0 until the operator // sets it — never a silent default price for a cost that is real. InterRegionGBUSD float64 `json:"inter_region_gb_usd,omitempty"` Overheads []LineItem `json:"overheads"` } // Allocation says how a box's shared cost is split across its tenants. type Allocation struct { Policy string `json:"policy"` // "cpu" | "mem" | "blend" CPUWeight float64 `json:"cpu_weight"` // blend only, 0..1 (weight of CPU share) } // RateSheet is explicit customer pricing (mode "rate-sheet") — and the // shape a promoted scenario publishes when billing arrives. type RateSheet struct { AppMonthUSD float64 `json:"app_month_usd"` 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 // revenue is computed (spec: a subtraction, not a flag). type FreeTier struct { Apps float64 `json:"apps"` EgressGB float64 `json:"egress_gb"` MemGBHours float64 `json:"mem_gb_hours"` 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 Name string `json:"name"` RateCard RateCard `json:"rate_card"` Allocation Allocation `json:"allocation"` Mode string `json:"mode"` // "cost-plus" | "rate-sheet" 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 // scenario so a knob change survives a reload. Additive (omitempty): // every scenario stored before this field existed simply has none, and // per the dashboard's own default rule (documented where it reads this // field) an empty list means "count every service", not "count none" — // so an old scenario's reconciliation is unchanged by this field's // absence. Schema stays 1: this is a new optional key, not a shape // change existing readers must refuse. BillServices []string `json:"bill_services,omitempty"` } // Active is economics/active.json: which scenario the dashboards load. type Active struct { Schema int `json:"schema"` // 1 Name string `json:"name"` } const ( ScenariosPrefix = "economics/scenarios/" ActiveKey = "economics/active.json" ) // ScenarioKey is the bucket key for a named scenario. func ScenarioKey(name string) string { return ScenariosPrefix + name + ".json" } var scenarioNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) // ValidScenarioName bounds names to the account-name shape: 1-63 chars of // a-z 0-9 hyphen, no leading/trailing hyphen — safe in a bucket key and a URL. func ValidScenarioName(name string) bool { return scenarioNameRe.MatchString(name) } // DefaultScenario is the built-in fallback: seeded from real 2026-08 AWS // pricing for the flagship/tito boxes (t4g.small on-demand eu-west-1 ≈ // $0.0184/h, 2 vCPU, 2 GB; gp3 $0.088/GB-month eu-west-1; S3 standard // $0.023/GB-month, PUT-heavy blended requests ≈ $0.005/1k; egress // $0.09/GB), an 80% target margin, and a free tier of one small always-on // app. Every number is a starting point for the knobs, not a commitment. func DefaultScenario() Scenario { return Scenario{ Schema: 1, Name: "default", RateCard: RateCard{ Boxes: map[string]BoxSpec{ "": {HourUSD: 0.0184, Cores: 2, MemGB: 2, EBSGB: 30}, }, EBSGBMonthUSD: 0.088, StorageGBMonthUSD: 0.023, RequestsPer1kUSD: 0.005, EgressGBUSD: 0.09, InterRegionGBUSD: 0.02, Overheads: []LineItem{{Name: "domains + DNS", USDMonth: 5}}, }, Allocation: Allocation{Policy: "blend", CPUWeight: 0.5}, Mode: "cost-plus", Margin: 0.8, Sheet: RateSheet{ AppMonthUSD: 5, 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}, FleetBreak: FleetBreak{MinBoxes: 10, Discount: 0.4}, Pool: Pool{RevenueFrac: 0.10, TopupUSD: 200}, } } // Validate rejects a scenario the maths cannot price. func (sc *Scenario) Validate() error { if !ValidScenarioName(sc.Name) { return fmt.Errorf("economics: scenario name %q: 1-63 chars of a-z 0-9 hyphen", sc.Name) } if sc.Mode != "cost-plus" && sc.Mode != "rate-sheet" { return fmt.Errorf("economics: mode %q: want cost-plus or rate-sheet", sc.Mode) } if sc.Margin < 0 || sc.Margin >= 1 { return fmt.Errorf("economics: margin %v: want [0,1) — margin is (price-cost)/price", sc.Margin) } switch sc.Allocation.Policy { case "cpu", "mem": case "blend": if sc.Allocation.CPUWeight < 0 || sc.Allocation.CPUWeight > 1 { return fmt.Errorf("economics: blend cpu_weight %v: want [0,1]", sc.Allocation.CPUWeight) } default: return fmt.Errorf("economics: allocation policy %q: want cpu, mem or blend", sc.Allocation.Policy) } 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 }