| 1 | package economics |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/carlosframework/platform/internal/usage" |
| 8 | ) |
| 9 | |
| 10 | func approx(t *testing.T, name string, got, want float64) { |
| 11 | t.Helper() |
| 12 | if math.Abs(got-want) > 1e-9 { |
| 13 | t.Fatalf("%s = %v, want %v", name, got, want) |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | // A 2-core/2GB box at $0.10/h with no EBS, 100h period, one tenant using |
| 18 | // 50 core-hours (25% of 200 available) and 100 GB-hours (50% of 200). |
| 19 | func testBox() (RateCard, BoxLoad) { |
| 20 | rc := RateCard{Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2}}} |
| 21 | b := BoxLoad{Box: "box1", Hours: 100, ByAccount: map[string]BoxAccountLoad{ |
| 22 | "bab": {CPUCoreHours: 50, MemGBHours: 100}, |
| 23 | }} |
| 24 | return rc, b |
| 25 | } |
| 26 | |
| 27 | func TestAllocateBoxPolicies(t *testing.T) { |
| 28 | rc, b := testBox() |
| 29 | per, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b) |
| 30 | approx(t, "total", total, 10.0) |
| 31 | approx(t, "cpu share", per["bab"], 2.5) // 25% of $10 |
| 32 | approx(t, "cpu idle", idle, 7.5) |
| 33 | |
| 34 | per, idle, _ = AllocateBox(rc, Allocation{Policy: "mem"}, b) |
| 35 | approx(t, "mem share", per["bab"], 5.0) // 50% of $10 |
| 36 | approx(t, "mem idle", idle, 5.0) |
| 37 | |
| 38 | per, idle, _ = AllocateBox(rc, Allocation{Policy: "blend", CPUWeight: 0.5}, b) |
| 39 | approx(t, "blend share", per["bab"], 3.75) // (0.5*25% + 0.5*50%) of $10 |
| 40 | approx(t, "blend idle", idle, 6.25) |
| 41 | } |
| 42 | |
| 43 | func TestAllocateBoxEBSAndFallbackSpec(t *testing.T) { |
| 44 | rc := RateCard{ |
| 45 | Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2, EBSGB: 73}}, |
| 46 | EBSGBMonthUSD: 0.10, |
| 47 | } |
| 48 | b := BoxLoad{Box: "unlisted-box", Hours: 730, ByAccount: nil} |
| 49 | _, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b) |
| 50 | // 730h of $0.10 = $73, plus 73GB*$0.10/GB-month * (730/730) = $7.30. |
| 51 | approx(t, "total", total, 80.3) |
| 52 | approx(t, "all idle", idle, 80.3) |
| 53 | } |
| 54 | |
| 55 | func TestPriceAccountCostPlusEightyMarginIsFiveX(t *testing.T) { |
| 56 | sc := DefaultScenario() |
| 57 | sc.RateCard.EgressGBUSD = 0.10 |
| 58 | sc.RateCard.RequestsPer1kUSD = 0 |
| 59 | sc.RateCard.StorageGBMonthUSD = 0 |
| 60 | sc.FreeTier = FreeTier{} // no free tier: pure margin check |
| 61 | meters := map[string]float64{usage.MeterEgressGB: 10} // $1 of egress cost |
| 62 | ae := PriceAccount(sc, "bab", meters, 0) |
| 63 | approx(t, "cost", ae.CostUSD, 1.0) |
| 64 | approx(t, "revenue", ae.RevenueUSD, 5.0) // 80% margin = 5x cost |
| 65 | approx(t, "margin", ae.MarginFrac, 0.8) |
| 66 | if ae.FreeTierPos != "paying" { |
| 67 | t.Fatalf("pos = %q", ae.FreeTierPos) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestPriceAccountFreeTierSubtraction(t *testing.T) { |
| 72 | sc := DefaultScenario() |
| 73 | sc.Mode = "rate-sheet" |
| 74 | sc.Sheet = RateSheet{AppMonthUSD: 5, EgressGBUSD: 0.15} |
| 75 | sc.FreeTier = FreeTier{Apps: 1, EgressGB: 10} |
| 76 | meters := map[string]float64{usage.MeterAppMonths: 2, usage.MeterEgressGB: 14} |
| 77 | ae := PriceAccount(sc, "bab", meters, 0) |
| 78 | // Billable: 1 app-month, 4 GB egress = $5 + $0.60. |
| 79 | approx(t, "revenue", ae.RevenueUSD, 5.6) |
| 80 | if ae.FreeTierPos != "partial" { |
| 81 | t.Fatalf("pos = %q", ae.FreeTierPos) |
| 82 | } |
| 83 | |
| 84 | inside := PriceAccount(sc, "tiny", map[string]float64{usage.MeterAppMonths: 1, usage.MeterEgressGB: 2}, 0) |
| 85 | approx(t, "inside revenue", inside.RevenueUSD, 0) |
| 86 | if inside.FreeTierPos != "inside" { |
| 87 | t.Fatalf("inside pos = %q", inside.FreeTierPos) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestPriceAccountComputeFraction(t *testing.T) { |
| 92 | sc := DefaultScenario() |
| 93 | sc.Mode = "cost-plus" |
| 94 | sc.Margin = 0.5 |
| 95 | sc.RateCard.EgressGBUSD, sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0, 0 |
| 96 | sc.FreeTier = FreeTier{MemGBHours: 100} |
| 97 | meters := map[string]float64{usage.MeterMemGBHours: 400} |
| 98 | // $8 allocated, 300/400 billable -> $6 billable cost -> $12 revenue at 50%. |
| 99 | ae := PriceAccount(sc, "bab", meters, 8) |
| 100 | approx(t, "cost", ae.CostUSD, 8) |
| 101 | approx(t, "revenue", ae.RevenueUSD, 12) |
| 102 | } |
| 103 | |
| 104 | func TestProject(t *testing.T) { |
| 105 | approx(t, "project", Project(10, 10, 30), 30) |
| 106 | approx(t, "zero covered", Project(10, 0, 30), 0) |
| 107 | } |
| 108 | |
| 109 | func TestPriceAccountFleetBoxes(t *testing.T) { |
| 110 | sc := DefaultScenario() |
| 111 | sc.Mode = "rate-sheet" |
| 112 | sc.Sheet = RateSheet{BoxMonthServerUSD: 5, BoxMonthDeviceUSD: 1} |
| 113 | sc.FreeTier = FreeTier{} |
| 114 | // 2 server boxes + 5 device boxes, full month. |
| 115 | meters := map[string]float64{ |
| 116 | usage.MeterFleetBoxHoursServer: 2 * 730, |
| 117 | usage.MeterFleetBoxHoursDevice: 5 * 730, |
| 118 | } |
| 119 | ae := PriceAccount(sc, "bae", meters, 0) |
| 120 | approx(t, "fleet revenue", ae.RevenueUSD, 2*5+5*1) // $15 |
| 121 | } |
| 122 | |
| 123 | func TestPriceAccountFleetVolumeBreak(t *testing.T) { |
| 124 | sc := DefaultScenario() |
| 125 | sc.Mode = "rate-sheet" |
| 126 | sc.Sheet = RateSheet{BoxMonthServerUSD: 5} |
| 127 | sc.FleetBreak = FleetBreak{MinBoxes: 10, Discount: 0.4} |
| 128 | sc.FreeTier = FreeTier{} |
| 129 | meters := map[string]float64{usage.MeterFleetBoxHoursServer: 12 * 730} |
| 130 | ae := PriceAccount(sc, "bae", meters, 0) |
| 131 | approx(t, "discounted", ae.RevenueUSD, 12*5*0.6) // $36 |
| 132 | // 9.99 avg boxes: no break. |
| 133 | meters[usage.MeterFleetBoxHoursServer] = 9.99 * 730 |
| 134 | ae = PriceAccount(sc, "bae", meters, 0) |
| 135 | approx(t, "no break", ae.RevenueUSD, 9.99*5) |
| 136 | } |
| 137 | |
| 138 | func TestPriceAccountFleetAddsInCostPlusToo(t *testing.T) { |
| 139 | sc := DefaultScenario() |
| 140 | sc.Mode = "cost-plus" |
| 141 | sc.Margin = 0.5 |
| 142 | sc.Sheet.BoxMonthServerUSD = 5 |
| 143 | sc.RateCard.EgressGBUSD = 0.10 |
| 144 | sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0 |
| 145 | sc.FreeTier = FreeTier{} |
| 146 | meters := map[string]float64{ |
| 147 | usage.MeterEgressGB: 10, // $1 cost -> $2 cost-plus revenue |
| 148 | usage.MeterFleetBoxHoursServer: 1 * 730, // + $5 box-month |
| 149 | } |
| 150 | ae := PriceAccount(sc, "bae", meters, 0) |
| 151 | approx(t, "combined", ae.RevenueUSD, 2+5) |
| 152 | } |
| 153 | |
| 154 | func TestPriceAccountMemOverage(t *testing.T) { |
| 155 | sc := DefaultScenario() |
| 156 | sc.Mode = "rate-sheet" |
| 157 | sc.Sheet = RateSheet{MemGBHourUSD: 0.01} |
| 158 | sc.FreeTier = FreeTier{MemGBHours: 100} |
| 159 | meters := map[string]float64{usage.MeterMemGBHours: 400} |
| 160 | ae := PriceAccount(sc, "bab", meters, 0) |
| 161 | approx(t, "overage", ae.RevenueUSD, 3.0) // 300 over * $0.01 |
| 162 | } |
| 163 | |