| 1 | // Package economics prices metered usage: rate cards (what things cost us), |
| 2 | // scenarios (tweakable pricing/margin/free-tier knob-sets), and the maths |
| 3 | // that turns an account's statement into cost, would-be revenue and margin. |
| 4 | // Pure computation + bucket shapes; collection lives in internal/usage. |
| 5 | package economics |
| 6 | |
| 7 | import ( |
| 8 | "fmt" |
| 9 | "regexp" |
| 10 | ) |
| 11 | |
| 12 | // LineItem is one fixed monthly overhead (domains, DNSimple, ...). |
| 13 | type LineItem struct { |
| 14 | Name string `json:"name"` |
| 15 | USDMonth float64 `json:"usd_month"` |
| 16 | } |
| 17 | |
| 18 | // BoxSpec is one box's cost and capacity in the rate card. The "" key is |
| 19 | // the default spec a box without its own entry falls back to. |
| 20 | type BoxSpec struct { |
| 21 | HourUSD float64 `json:"hour_usd"` |
| 22 | Cores float64 `json:"cores"` |
| 23 | MemGB float64 `json:"mem_gb"` |
| 24 | EBSGB float64 `json:"ebs_gb"` |
| 25 | } |
| 26 | |
| 27 | // RateCard is what the platform pays, per unit. |
| 28 | type RateCard struct { |
| 29 | Boxes map[string]BoxSpec `json:"boxes"` // box label -> spec; "" = default |
| 30 | EBSGBMonthUSD float64 `json:"ebs_gb_month_usd"` |
| 31 | StorageGBMonthUSD float64 `json:"storage_gb_month_usd"` |
| 32 | RequestsPer1kUSD float64 `json:"requests_per_1k_usd"` |
| 33 | EgressGBUSD float64 `json:"egress_gb_usd"` |
| 34 | // InterRegionGBUSD is what a byte costs to move between the platform's |
| 35 | // own regions — the backhaul leg (#288): a request served through a |
| 36 | // region edge leaves the home box for that edge (this rate) and then |
| 37 | // the edge for the client (EgressGBUSD, which is what the home box's |
| 38 | // metered bytes are priced at). Additive; a scenario saved before it |
| 39 | // existed reads as 0 and the model line shows $0 until the operator |
| 40 | // sets it — never a silent default price for a cost that is real. |
| 41 | InterRegionGBUSD float64 `json:"inter_region_gb_usd,omitempty"` |
| 42 | Overheads []LineItem `json:"overheads"` |
| 43 | } |
| 44 | |
| 45 | // Allocation says how a box's shared cost is split across its tenants. |
| 46 | type Allocation struct { |
| 47 | Policy string `json:"policy"` // "cpu" | "mem" | "blend" |
| 48 | CPUWeight float64 `json:"cpu_weight"` // blend only, 0..1 (weight of CPU share) |
| 49 | } |
| 50 | |
| 51 | // RateSheet is explicit customer pricing (mode "rate-sheet") — and the |
| 52 | // shape a promoted scenario publishes when billing arrives. |
| 53 | type RateSheet struct { |
| 54 | AppMonthUSD float64 `json:"app_month_usd"` |
| 55 | EgressGBUSD float64 `json:"egress_gb_usd"` |
| 56 | StorageGBMonthUSD float64 `json:"storage_gb_month_usd"` |
| 57 | InstanceHourUSD float64 `json:"instance_hour_usd"` |
| 58 | BoxMonthServerUSD float64 `json:"box_month_server_usd"` |
| 59 | BoxMonthDeviceUSD float64 `json:"box_month_device_usd"` |
| 60 | MemGBHourUSD float64 `json:"mem_gb_hour_usd"` |
| 61 | } |
| 62 | |
| 63 | // FreeTier is per-account monthly allowances, subtracted from meters before |
| 64 | // revenue is computed (spec: a subtraction, not a flag). |
| 65 | type FreeTier struct { |
| 66 | Apps float64 `json:"apps"` |
| 67 | EgressGB float64 `json:"egress_gb"` |
| 68 | MemGBHours float64 `json:"mem_gb_hours"` |
| 69 | StorageGB float64 `json:"storage_gb"` |
| 70 | } |
| 71 | |
| 72 | // FleetBreak is a volume discount on fleet box pricing. |
| 73 | type FleetBreak struct { |
| 74 | MinBoxes float64 `json:"min_boxes"` |
| 75 | Discount float64 `json:"discount"` |
| 76 | } |
| 77 | |
| 78 | // Pool is a revenue-sharing pool for fleet deployments. |
| 79 | type Pool struct { |
| 80 | RevenueFrac float64 `json:"revenue_frac"` |
| 81 | TopupUSD float64 `json:"topup_usd"` |
| 82 | } |
| 83 | |
| 84 | // Scenario is one complete knob-set, stored at ScenarioKey(Name). |
| 85 | type Scenario struct { |
| 86 | Schema int `json:"schema"` // 1 |
| 87 | Name string `json:"name"` |
| 88 | RateCard RateCard `json:"rate_card"` |
| 89 | Allocation Allocation `json:"allocation"` |
| 90 | Mode string `json:"mode"` // "cost-plus" | "rate-sheet" |
| 91 | Margin float64 `json:"margin"` // cost-plus: (price-cost)/price, so 0.8 = 5x cost |
| 92 | Sheet RateSheet `json:"sheet"` |
| 93 | FreeTier FreeTier `json:"free_tier"` |
| 94 | FleetBreak FleetBreak `json:"fleet_break"` |
| 95 | Pool Pool `json:"pool"` |
| 96 | // BillServices names which of a bill's Services lines the dashboard's |
| 97 | // model-vs-bill reconciliation (Task 13) counts as CARLOS spend — the |
| 98 | // "counts as CARLOS" checkbox state, round-tripped through the saved |
| 99 | // scenario so a knob change survives a reload. Additive (omitempty): |
| 100 | // every scenario stored before this field existed simply has none, and |
| 101 | // per the dashboard's own default rule (documented where it reads this |
| 102 | // field) an empty list means "count every service", not "count none" — |
| 103 | // so an old scenario's reconciliation is unchanged by this field's |
| 104 | // absence. Schema stays 1: this is a new optional key, not a shape |
| 105 | // change existing readers must refuse. |
| 106 | BillServices []string `json:"bill_services,omitempty"` |
| 107 | } |
| 108 | |
| 109 | // Active is economics/active.json: which scenario the dashboards load. |
| 110 | type Active struct { |
| 111 | Schema int `json:"schema"` // 1 |
| 112 | Name string `json:"name"` |
| 113 | } |
| 114 | |
| 115 | const ( |
| 116 | ScenariosPrefix = "economics/scenarios/" |
| 117 | ActiveKey = "economics/active.json" |
| 118 | ) |
| 119 | |
| 120 | // ScenarioKey is the bucket key for a named scenario. |
| 121 | func ScenarioKey(name string) string { return ScenariosPrefix + name + ".json" } |
| 122 | |
| 123 | var scenarioNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| 124 | |
| 125 | // ValidScenarioName bounds names to the account-name shape: 1-63 chars of |
| 126 | // a-z 0-9 hyphen, no leading/trailing hyphen — safe in a bucket key and a URL. |
| 127 | func ValidScenarioName(name string) bool { return scenarioNameRe.MatchString(name) } |
| 128 | |
| 129 | // DefaultScenario is the built-in fallback: seeded from real 2026-08 AWS |
| 130 | // pricing for the flagship/tito boxes (t4g.small on-demand eu-west-1 ≈ |
| 131 | // $0.0184/h, 2 vCPU, 2 GB; gp3 $0.088/GB-month eu-west-1; S3 standard |
| 132 | // $0.023/GB-month, PUT-heavy blended requests ≈ $0.005/1k; egress |
| 133 | // $0.09/GB), an 80% target margin, and a free tier of one small always-on |
| 134 | // app. Every number is a starting point for the knobs, not a commitment. |
| 135 | func DefaultScenario() Scenario { |
| 136 | return Scenario{ |
| 137 | Schema: 1, |
| 138 | Name: "default", |
| 139 | RateCard: RateCard{ |
| 140 | Boxes: map[string]BoxSpec{ |
| 141 | "": {HourUSD: 0.0184, Cores: 2, MemGB: 2, EBSGB: 30}, |
| 142 | }, |
| 143 | EBSGBMonthUSD: 0.088, |
| 144 | StorageGBMonthUSD: 0.023, |
| 145 | RequestsPer1kUSD: 0.005, |
| 146 | EgressGBUSD: 0.09, |
| 147 | InterRegionGBUSD: 0.02, |
| 148 | Overheads: []LineItem{{Name: "domains + DNS", USDMonth: 5}}, |
| 149 | }, |
| 150 | Allocation: Allocation{Policy: "blend", CPUWeight: 0.5}, |
| 151 | Mode: "cost-plus", |
| 152 | Margin: 0.8, |
| 153 | Sheet: RateSheet{ |
| 154 | AppMonthUSD: 5, |
| 155 | EgressGBUSD: 0.15, |
| 156 | StorageGBMonthUSD: 0.05, |
| 157 | InstanceHourUSD: 0.01, |
| 158 | BoxMonthServerUSD: 5, |
| 159 | BoxMonthDeviceUSD: 1, |
| 160 | MemGBHourUSD: 0, |
| 161 | }, |
| 162 | FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1}, |
| 163 | FleetBreak: FleetBreak{MinBoxes: 10, Discount: 0.4}, |
| 164 | Pool: Pool{RevenueFrac: 0.10, TopupUSD: 200}, |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Validate rejects a scenario the maths cannot price. |
| 169 | func (sc *Scenario) Validate() error { |
| 170 | if !ValidScenarioName(sc.Name) { |
| 171 | return fmt.Errorf("economics: scenario name %q: 1-63 chars of a-z 0-9 hyphen", sc.Name) |
| 172 | } |
| 173 | if sc.Mode != "cost-plus" && sc.Mode != "rate-sheet" { |
| 174 | return fmt.Errorf("economics: mode %q: want cost-plus or rate-sheet", sc.Mode) |
| 175 | } |
| 176 | if sc.Margin < 0 || sc.Margin >= 1 { |
| 177 | return fmt.Errorf("economics: margin %v: want [0,1) — margin is (price-cost)/price", sc.Margin) |
| 178 | } |
| 179 | switch sc.Allocation.Policy { |
| 180 | case "cpu", "mem": |
| 181 | case "blend": |
| 182 | if sc.Allocation.CPUWeight < 0 || sc.Allocation.CPUWeight > 1 { |
| 183 | return fmt.Errorf("economics: blend cpu_weight %v: want [0,1]", sc.Allocation.CPUWeight) |
| 184 | } |
| 185 | default: |
| 186 | return fmt.Errorf("economics: allocation policy %q: want cpu, mem or blend", sc.Allocation.Policy) |
| 187 | } |
| 188 | if _, ok := sc.RateCard.Boxes[""]; !ok { |
| 189 | return fmt.Errorf("economics: rate card needs a %q default box spec", "") |
| 190 | } |
| 191 | if sc.FleetBreak.Discount < 0 || sc.FleetBreak.Discount >= 1 { |
| 192 | return fmt.Errorf("economics: fleet_break.discount %v: want [0,1)", sc.FleetBreak.Discount) |
| 193 | } |
| 194 | if sc.FleetBreak.MinBoxes < 0 { |
| 195 | return fmt.Errorf("economics: fleet_break.min_boxes %v: want >= 0", sc.FleetBreak.MinBoxes) |
| 196 | } |
| 197 | if sc.Pool.RevenueFrac < 0 || sc.Pool.RevenueFrac > 1 { |
| 198 | return fmt.Errorf("economics: pool.revenue_frac %v: want [0,1]", sc.Pool.RevenueFrac) |
| 199 | } |
| 200 | if sc.Pool.TopupUSD < 0 { |
| 201 | return fmt.Errorf("economics: pool.topup_usd %v: want >= 0", sc.Pool.TopupUSD) |
| 202 | } |
| 203 | if sc.Sheet.BoxMonthServerUSD < 0 { |
| 204 | return fmt.Errorf("economics: sheet.box_month_server_usd %v: want >= 0", sc.Sheet.BoxMonthServerUSD) |
| 205 | } |
| 206 | if sc.Sheet.BoxMonthDeviceUSD < 0 { |
| 207 | return fmt.Errorf("economics: sheet.box_month_device_usd %v: want >= 0", sc.Sheet.BoxMonthDeviceUSD) |
| 208 | } |
| 209 | if sc.Sheet.MemGBHourUSD < 0 { |
| 210 | return fmt.Errorf("economics: sheet.mem_gb_hour_usd %v: want >= 0", sc.Sheet.MemGBHourUSD) |
| 211 | } |
| 212 | return nil |
| 213 | } |
| 214 | |