economics: fleet box-month pricing with volume break, mem overage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+71
−1
internal/economics/compute.go+16 −1internal/economics/compute_test.go+55 −0
diff --git a/internal/economics/compute.go b/internal/economics/compute.go| index 35912fc55..5b389b24b 100644 |
| --- a/internal/economics/compute.go |
| +++ b/internal/economics/compute.go |
| @@ -85,7 +85,8 @@ func billable(meters map[string]float64, meter string, allowance float64) float6 |
| // 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. |
| +// with the server it loaded from. Fleet box and mem overage formulas are |
| +// also mirrored in the JS (Task 6). |
| func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics { |
| rc := sc.RateCard |
| cost := allocatedUSD + |
| @@ -109,6 +110,9 @@ func PriceAccount(sc Scenario, account string, meters map[string]float64, alloca |
| bEgress*sc.Sheet.EgressGBUSD + |
| bStorage*sc.Sheet.StorageGBMonthUSD + |
| meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD |
| + // Mem overage (rate-sheet mode only) |
| + memOverage := billable(meters, usage.MeterMemGBHours, ft.MemGBHours) |
| + revenue += memOverage * sc.Sheet.MemGBHourUSD |
| } else { // cost-plus |
| billableCost := allocatedUSD*computeFrac + |
| bEgress*rc.EgressGBUSD + |
| @@ -117,6 +121,17 @@ func PriceAccount(sc Scenario, account string, meters map[string]float64, alloca |
| revenue = billableCost / (1 - sc.Margin) |
| } |
| + // Fleet box revenue (both modes) |
| + fleetRevenue := (meters[usage.MeterFleetBoxHoursServer]/HoursPerMonth)*sc.Sheet.BoxMonthServerUSD + |
| + (meters[usage.MeterFleetBoxHoursDevice]/HoursPerMonth)*sc.Sheet.BoxMonthDeviceUSD |
| + |
| + // Apply volume break if applicable |
| + avgBoxes := (meters[usage.MeterFleetBoxHoursServer] + meters[usage.MeterFleetBoxHoursDevice]) / HoursPerMonth |
| + if sc.FleetBreak.MinBoxes > 0 && avgBoxes >= sc.FleetBreak.MinBoxes { |
| + fleetRevenue *= (1 - sc.FleetBreak.Discount) |
| + } |
| + revenue += fleetRevenue |
| + |
| margin := 0.0 |
| if revenue > 0 { |
| margin = (revenue - cost) / revenue |
diff --git a/internal/economics/compute_test.go b/internal/economics/compute_test.go| index 6494ab562..5d3206aec 100644 |
| --- a/internal/economics/compute_test.go |
| +++ b/internal/economics/compute_test.go |
| @@ -105,3 +105,58 @@ func TestProject(t *testing.T) { |
| approx(t, "project", Project(10, 10, 30), 30) |
| approx(t, "zero covered", Project(10, 0, 30), 0) |
| } |
| + |
| +func TestPriceAccountFleetBoxes(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "rate-sheet" |
| + sc.Sheet = RateSheet{BoxMonthServerUSD: 5, BoxMonthDeviceUSD: 1} |
| + sc.FreeTier = FreeTier{} |
| + // 2 server boxes + 5 device boxes, full month. |
| + meters := map[string]float64{ |
| + usage.MeterFleetBoxHoursServer: 2 * 730, |
| + usage.MeterFleetBoxHoursDevice: 5 * 730, |
| + } |
| + ae := PriceAccount(sc, "bae", meters, 0) |
| + approx(t, "fleet revenue", ae.RevenueUSD, 2*5+5*1) // $15 |
| +} |
| + |
| +func TestPriceAccountFleetVolumeBreak(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "rate-sheet" |
| + sc.Sheet = RateSheet{BoxMonthServerUSD: 5} |
| + sc.FleetBreak = FleetBreak{MinBoxes: 10, Discount: 0.4} |
| + sc.FreeTier = FreeTier{} |
| + meters := map[string]float64{usage.MeterFleetBoxHoursServer: 12 * 730} |
| + ae := PriceAccount(sc, "bae", meters, 0) |
| + approx(t, "discounted", ae.RevenueUSD, 12*5*0.6) // $36 |
| + // 9.99 avg boxes: no break. |
| + meters[usage.MeterFleetBoxHoursServer] = 9.99 * 730 |
| + ae = PriceAccount(sc, "bae", meters, 0) |
| + approx(t, "no break", ae.RevenueUSD, 9.99*5) |
| +} |
| + |
| +func TestPriceAccountFleetAddsInCostPlusToo(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "cost-plus" |
| + sc.Margin = 0.5 |
| + sc.Sheet.BoxMonthServerUSD = 5 |
| + sc.RateCard.EgressGBUSD = 0.10 |
| + sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0 |
| + sc.FreeTier = FreeTier{} |
| + meters := map[string]float64{ |
| + usage.MeterEgressGB: 10, // $1 cost -> $2 cost-plus revenue |
| + usage.MeterFleetBoxHoursServer: 1 * 730, // + $5 box-month |
| + } |
| + ae := PriceAccount(sc, "bae", meters, 0) |
| + approx(t, "combined", ae.RevenueUSD, 2+5) |
| +} |
| + |
| +func TestPriceAccountMemOverage(t *testing.T) { |
| + sc := DefaultScenario() |
| + sc.Mode = "rate-sheet" |
| + sc.Sheet = RateSheet{MemGBHourUSD: 0.01} |
| + sc.FreeTier = FreeTier{MemGBHours: 100} |
| + meters := map[string]float64{usage.MeterMemGBHours: 400} |
| + ae := PriceAccount(sc, "bab", meters, 0) |
| + approx(t, "overage", ae.RevenueUSD, 3.0) // 300 over * $0.01 |
| +} |