economics: allocation, pricing and free-tier maths with golden tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+255
−0
internal/economics/compute.go+148 −0internal/economics/compute_test.go+107 −0
diff --git a/internal/economics/compute.go b/internal/economics/compute.go| new file mode 100644 |
| index 000000000..d72adc3ee |
| --- /dev/null |
| +++ b/internal/economics/compute.go |
| @@ -0,0 +1,148 @@ |
| +package economics |
| + |
| +import "github.com/carlosframework/platform/internal/usage" |
| + |
| +// HoursPerMonth prorates GB-month rates over hour-denominated periods. |
| +const HoursPerMonth = 730.0 |
| + |
| +// BoxAccountLoad is one account's consumption on one box over the period. |
| +type BoxAccountLoad struct { |
| + CPUCoreHours float64 |
| + MemGBHours float64 |
| +} |
| + |
| +// BoxLoad is one box over the period: metered hours and per-account load. |
| +type BoxLoad struct { |
| + Box string |
| + Hours float64 |
| + ByAccount map[string]BoxAccountLoad |
| +} |
| + |
| +// AllocateBox splits a box's period cost across accounts by resource share. |
| +// The unallocated remainder — idle headroom — is returned separately and |
| +// must be shown as its own line, never smeared across tenants (spec). |
| +func AllocateBox(rc RateCard, al Allocation, b BoxLoad) (perAccount map[string]float64, idleUSD, totalUSD float64) { |
| + spec, ok := rc.Boxes[b.Box] |
| + if !ok { |
| + spec = rc.Boxes[""] |
| + } |
| + totalUSD = spec.HourUSD*b.Hours + spec.EBSGB*rc.EBSGBMonthUSD*(b.Hours/HoursPerMonth) |
| + perAccount = map[string]float64{} |
| + if b.Hours <= 0 { |
| + return perAccount, totalUSD, totalUSD |
| + } |
| + allocated := 0.0 |
| + for acct, load := range b.ByAccount { |
| + var cpuShare, memShare float64 |
| + if spec.Cores > 0 { |
| + cpuShare = load.CPUCoreHours / (spec.Cores * b.Hours) |
| + } |
| + if spec.MemGB > 0 { |
| + memShare = load.MemGBHours / (spec.MemGB * b.Hours) |
| + } |
| + var share float64 |
| + switch al.Policy { |
| + case "cpu": |
| + share = cpuShare |
| + case "mem": |
| + share = memShare |
| + default: // blend |
| + share = al.CPUWeight*cpuShare + (1-al.CPUWeight)*memShare |
| + } |
| + usd := share * totalUSD |
| + perAccount[acct] = usd |
| + allocated += usd |
| + } |
| + idleUSD = totalUSD - allocated |
| + if idleUSD < 0 { |
| + idleUSD = 0 |
| + } |
| + return perAccount, idleUSD, totalUSD |
| +} |
| + |
| +// AccountEconomics is one account priced under one scenario. |
| +type AccountEconomics struct { |
| + Account string |
| + CostUSD float64 |
| + RevenueUSD float64 |
| + MarginFrac float64 // (revenue-cost)/revenue, 0 when revenue is 0 |
| + FreeTierPos string // "inside" | "partial" | "paying" |
| +} |
| + |
| +func billable(meters map[string]float64, meter string, allowance float64) float64 { |
| + v := meters[meter] - allowance |
| + if v < 0 { |
| + return 0 |
| + } |
| + return v |
| +} |
| + |
| +// PriceAccount prices one account's meters plus its allocated box cost |
| +// under sc. These formulas are mirrored verbatim by the dashboard's inline |
| +// knob JS (economics.html) — change both together or the page will disagree |
| +// with the server it loaded from. |
| +func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics { |
| + rc := sc.RateCard |
| + cost := allocatedUSD + |
| + meters[usage.MeterEgressGB]*rc.EgressGBUSD + |
| + meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD + |
| + meters[usage.MeterStorageGBMonth]*rc.StorageGBMonthUSD |
| + |
| + ft := sc.FreeTier |
| + bEgress := billable(meters, usage.MeterEgressGB, ft.EgressGB) |
| + bStorage := billable(meters, usage.MeterStorageGBMonth, ft.StorageGB) |
| + bApps := billable(meters, usage.MeterAppMonths, ft.Apps) |
| + bMem := billable(meters, usage.MeterMemGBHours, ft.MemGBHours) |
| + computeFrac := 1.0 |
| + if m := meters[usage.MeterMemGBHours]; m > 0 { |
| + computeFrac = bMem / m |
| + } |
| + |
| + var revenue float64 |
| + if sc.Mode == "rate-sheet" { |
| + revenue = bApps*sc.Sheet.AppMonthUSD + |
| + bEgress*sc.Sheet.EgressGBUSD + |
| + bStorage*sc.Sheet.StorageGBMonthUSD + |
| + meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD |
| + } else { // cost-plus |
| + billableCost := allocatedUSD*computeFrac + |
| + bEgress*rc.EgressGBUSD + |
| + meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD + |
| + bStorage*rc.StorageGBMonthUSD |
| + revenue = billableCost / (1 - sc.Margin) |
| + } |
| + |
| + margin := 0.0 |
| + if revenue > 0 { |
| + margin = (revenue - cost) / revenue |
| + } |
| + |
| + anyUsage := false |
| + for _, v := range meters { |
| + if v > 0 { |
| + anyUsage = true |
| + break |
| + } |
| + } |
| + clipped := bEgress < meters[usage.MeterEgressGB] || |
| + bStorage < meters[usage.MeterStorageGBMonth] || |
| + bApps < meters[usage.MeterAppMonths] || |
| + bMem < meters[usage.MeterMemGBHours] |
| + pos := "paying" |
| + switch { |
| + case revenue == 0 && anyUsage: |
| + pos = "inside" |
| + case clipped: |
| + pos = "partial" |
| + } |
| + |
| + return AccountEconomics{Account: account, CostUSD: cost, RevenueUSD: revenue, MarginFrac: margin, FreeTierPos: pos} |
| +} |
| + |
| +// Project scales a month-to-date figure to a full month by covered days. |
| +func Project(mtd float64, coveredDays, daysInMonth int) float64 { |
| + if coveredDays <= 0 { |
| + return 0 |
| + } |
| + return mtd * float64(daysInMonth) / float64(coveredDays) |
| +} |
diff --git a/internal/economics/compute_test.go b/internal/economics/compute_test.go| new file mode 100644 |
| index 000000000..6494ab562 |
| --- /dev/null |
| +++ b/internal/economics/compute_test.go |
| @@ -0,0 +1,107 @@ |
| +package economics |
| + |
| +import ( |
| + "math" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func approx(t *testing.T, name string, got, want float64) { |
| + t.Helper() |
| + if math.Abs(got-want) > 1e-9 { |
| + t.Fatalf("%s = %v, want %v", name, got, want) |
| + } |
| +} |
| + |
| +// A 2-core/2GB box at $0.10/h with no EBS, 100h period, one tenant using |
| +// 50 core-hours (25% of 200 available) and 100 GB-hours (50% of 200). |
| +func testBox() (RateCard, BoxLoad) { |
| + rc := RateCard{Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2}}} |
| + b := BoxLoad{Box: "box1", Hours: 100, ByAccount: map[string]BoxAccountLoad{ |
| + "bab": {CPUCoreHours: 50, MemGBHours: 100}, |
| + }} |
| + return rc, b |
| +} |
| + |
| +func TestAllocateBoxPolicies(t *testing.T) { |
| + rc, b := testBox() |
| + per, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b) |
| + approx(t, "total", total, 10.0) |
| + approx(t, "cpu share", per["bab"], 2.5) // 25% of $10 |
| + approx(t, "cpu idle", idle, 7.5) |
| + |
| + per, idle, _ = AllocateBox(rc, Allocation{Policy: "mem"}, b) |
| + approx(t, "mem share", per["bab"], 5.0) // 50% of $10 |
| + approx(t, "mem idle", idle, 5.0) |
| + |
| + per, idle, _ = AllocateBox(rc, Allocation{Policy: "blend", CPUWeight: 0.5}, b) |
| + approx(t, "blend share", per["bab"], 3.75) // (0.5*25% + 0.5*50%) of $10 |
| + approx(t, "blend idle", idle, 6.25) |
| +} |
| + |
| +func TestAllocateBoxEBSAndFallbackSpec(t *testing.T) { |
| + rc := RateCard{ |
| + Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2, EBSGB: 73}}, |
| + EBSGBMonthUSD: 0.10, |
| + } |
| + b := BoxLoad{Box: "unlisted-box", Hours: 730, ByAccount: nil} |
| + _, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b) |
| + // 730h of $0.10 = $73, plus 73GB*$0.10/GB-month * (730/730) = $7.30. |
| + approx(t, "total", total, 80.3) |
| + approx(t, "all idle", idle, 80.3) |
| +} |
| + |
| +func TestPriceAccountCostPlusEightyMarginIsFiveX(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.RateCard.EgressGBUSD = 0.10 |
| + sc.RateCard.RequestsPer1kUSD = 0 |
| + sc.RateCard.StorageGBMonthUSD = 0 |
| + sc.FreeTier = FreeTier{} // no free tier: pure margin check |
| + meters := map[string]float64{usage.MeterEgressGB: 10} // $1 of egress cost |
| + ae := PriceAccount(sc, "bab", meters, 0) |
| + approx(t, "cost", ae.CostUSD, 1.0) |
| + approx(t, "revenue", ae.RevenueUSD, 5.0) // 80% margin = 5x cost |
| + approx(t, "margin", ae.MarginFrac, 0.8) |
| + if ae.FreeTierPos != "paying" { |
| + t.Fatalf("pos = %q", ae.FreeTierPos) |
| + } |
| +} |
| + |
| +func TestPriceAccountFreeTierSubtraction(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "rate-sheet" |
| + sc.Sheet = RateSheet{AppMonthUSD: 5, EgressGBUSD: 0.15} |
| + sc.FreeTier = FreeTier{Apps: 1, EgressGB: 10} |
| + meters := map[string]float64{usage.MeterAppMonths: 2, usage.MeterEgressGB: 14} |
| + ae := PriceAccount(sc, "bab", meters, 0) |
| + // Billable: 1 app-month, 4 GB egress = $5 + $0.60. |
| + approx(t, "revenue", ae.RevenueUSD, 5.6) |
| + if ae.FreeTierPos != "partial" { |
| + t.Fatalf("pos = %q", ae.FreeTierPos) |
| + } |
| + |
| + inside := PriceAccount(sc, "tiny", map[string]float64{usage.MeterAppMonths: 1, usage.MeterEgressGB: 2}, 0) |
| + approx(t, "inside revenue", inside.RevenueUSD, 0) |
| + if inside.FreeTierPos != "inside" { |
| + t.Fatalf("inside pos = %q", inside.FreeTierPos) |
| + } |
| +} |
| + |
| +func TestPriceAccountComputeFraction(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "cost-plus" |
| + sc.Margin = 0.5 |
| + sc.RateCard.EgressGBUSD, sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0, 0 |
| + sc.FreeTier = FreeTier{MemGBHours: 100} |
| + meters := map[string]float64{usage.MeterMemGBHours: 400} |
| + // $8 allocated, 300/400 billable -> $6 billable cost -> $12 revenue at 50%. |
| + ae := PriceAccount(sc, "bab", meters, 8) |
| + approx(t, "cost", ae.CostUSD, 8) |
| + approx(t, "revenue", ae.RevenueUSD, 12) |
| +} |
| + |
| +func TestProject(t *testing.T) { |
| + approx(t, "project", Project(10, 10, 30), 30) |
| + approx(t, "zero covered", Project(10, 0, 30), 0) |
| +} |