carlos / platform Public

economics: scenario, rate card and bill shapes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6aa3dc6a623fea902d0fac87c52e9815eb5d6a77 parent 8bba0d6
3 files changed, +237 −0
  • internal/economics/bill.go +27 −0
  • internal/economics/scenario.go +150 −0
  • internal/economics/scenario_test.go +60 −0
diff --git a/internal/economics/bill.go b/internal/economics/bill.go
new file mode 100644
index 000000000..5c37914f4
--- /dev/null
+++ b/internal/economics/bill.go
@@ -0,0 +1,27 @@
+package economics
+
+import "time"
+
+// Bill sources.
+const (
+ SourceManual = "manual"
+ SourceCostExplorer = "costexplorer"
+)
+
+// Bill is economics/bills/<month>.json — the real AWS bill for one month,
+// whole-account by-service (attribution to CARLOS is a dashboard knob, not
+// a pull-time filter — both AWS accounts hold non-CARLOS resources).
+type Bill struct {
+ Schema int `json:"schema"` // 1
+ Month string `json:"month"` // "2006-01"
+ TotalUSD float64 `json:"total_usd"`
+ Services map[string]float64 `json:"services"`
+ Source string `json:"source"` // SourceManual | SourceCostExplorer
+ PulledAt time.Time `json:"pulled_at,omitempty"`
+}
+
+// BillsPrefix lists stored bills.
+const BillsPrefix = "economics/bills/"
+
+// BillKey is the bucket key for a month's bill.
+func BillKey(month string) string { return BillsPrefix + month + ".json" }
diff --git a/internal/economics/scenario.go b/internal/economics/scenario.go
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
+}
diff --git a/internal/economics/scenario_test.go b/internal/economics/scenario_test.go
new file mode 100644
index 000000000..b07355aa1
--- /dev/null
+++ b/internal/economics/scenario_test.go
@@ -0,0 +1,60 @@
+package economics
+
+import "testing"
+
+func TestKeys(t *testing.T) {
+ if got := ScenarioKey("launch-pricing"); got != "economics/scenarios/launch-pricing.json" {
+ t.Fatalf("ScenarioKey = %q", got)
+ }
+ if got := BillKey("2026-08"); got != "economics/bills/2026-08.json" {
+ t.Fatalf("BillKey = %q", got)
+ }
+ if ActiveKey != "economics/active.json" {
+ t.Fatalf("ActiveKey = %q", ActiveKey)
+ }
+}
+
+func TestDefaultScenarioValidates(t *testing.T) {
+ sc := DefaultScenario()
+ if err := sc.Validate(); err != nil {
+ t.Fatalf("default scenario invalid: %v", err)
+ }
+ if sc.Mode != "cost-plus" || sc.Margin != 0.8 {
+ t.Fatalf("default = mode %q margin %v", sc.Mode, sc.Margin)
+ }
+ if _, ok := sc.RateCard.Boxes[""]; !ok {
+ t.Fatal("default rate card must carry a \"\" default box spec")
+ }
+}
+
+func TestValidateRejects(t *testing.T) {
+ cases := []func(*Scenario){
+ func(s *Scenario) { s.Margin = 1.0 }, // price would be infinite
+ func(s *Scenario) { s.Margin = -0.1 },
+ func(s *Scenario) { s.Mode = "surge" },
+ func(s *Scenario) { s.Allocation.Policy = "vibes" },
+ func(s *Scenario) { s.Allocation.Policy = "blend"; s.Allocation.CPUWeight = 1.5 },
+ func(s *Scenario) { s.Name = "Bad Name!" },
+ func(s *Scenario) { s.RateCard.Boxes = nil },
+ }
+ for i, mutate := range cases {
+ sc := DefaultScenario()
+ sc.Name = "test"
+ mutate(&sc)
+ if err := sc.Validate(); err == nil {
+ t.Fatalf("case %d: mutation validated", i)
+ }
+ }
+}
+
+func TestValidScenarioName(t *testing.T) {
+ for name, want := range map[string]bool{
+ "launch-pricing": true, "v2": true, "": false, "UP": false,
+ "has space": false, "-lead": false, "trail-": false,
+ "waaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay-too-long": false,
+ } {
+ if got := ValidScenarioName(name); got != want {
+ t.Fatalf("ValidScenarioName(%q) = %v", name, got)
+ }
+ }
+}