| new file mode 100644 |
| index 000000000..f9406c8cb |
| --- /dev/null |
| +++ b/internal/economics/scenario.go |
| @@ -0,0 +1,150 @@ |
| +// 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"` |
| + 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"` |
| +} |
| + |
| +// 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"` |
| +} |
| + |
| +// 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"` |
| +} |
| + |
| +// 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, |
| + 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, |
| + }, |
| + FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1}, |
| + } |
| +} |
| + |
| +// 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", "") |
| + } |
| + return nil |
| +} |