carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
5package economics
6
7import (
8 "fmt"
9 "regexp"
10)
11
12// LineItem is one fixed monthly overhead (domains, DNSimple, ...).
13type 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.
20type 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.
28type 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.
46type 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.
53type 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).
65type 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.
73type 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.
79type 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).
85type 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.
110type Active struct {
111 Schema int `json:"schema"` // 1
112 Name string `json:"name"`
113}
114
115const (
116 ScenariosPrefix = "economics/scenarios/"
117 ActiveKey = "economics/active.json"
118)
119
120// ScenarioKey is the bucket key for a named scenario.
121func ScenarioKey(name string) string { return ScenariosPrefix + name + ".json" }
122
123var 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.
127func 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.
135func 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.
169func (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