| 1 | package economics |
| 2 | |
| 3 | import "github.com/carlosframework/platform/internal/usage" |
| 4 | |
| 5 | // HoursPerMonth prorates GB-month rates over hour-denominated periods. |
| 6 | const HoursPerMonth = 730.0 |
| 7 | |
| 8 | // BoxAccountLoad is one account's consumption on one box over the period. |
| 9 | // json tags are additive: nothing currently persists this shape to a |
| 10 | // bucket, but console/economicsview.go embeds it (inside BoxLoad, keyed |
| 11 | // under "boxLoads") in the dashboard's DataJSON payload, so it carries the |
| 12 | // same snake_case convention as every other field in that payload. |
| 13 | type BoxAccountLoad struct { |
| 14 | CPUCoreHours float64 `json:"cpu_core_hours"` |
| 15 | MemGBHours float64 `json:"mem_gb_hours"` |
| 16 | } |
| 17 | |
| 18 | // BoxLoad is one box over the period: metered hours and per-account load. |
| 19 | // See BoxAccountLoad's doc comment for why it carries json tags. |
| 20 | type BoxLoad struct { |
| 21 | Box string `json:"box"` |
| 22 | Hours float64 `json:"hours"` |
| 23 | ByAccount map[string]BoxAccountLoad `json:"by_account"` |
| 24 | } |
| 25 | |
| 26 | // AllocateBox splits a box's period cost across accounts by resource share. |
| 27 | // The unallocated remainder — idle headroom — is returned separately and |
| 28 | // must be shown as its own line, never smeared across tenants (spec). |
| 29 | func AllocateBox(rc RateCard, al Allocation, b BoxLoad) (perAccount map[string]float64, idleUSD, totalUSD float64) { |
| 30 | spec, ok := rc.Boxes[b.Box] |
| 31 | if !ok { |
| 32 | spec = rc.Boxes[""] |
| 33 | } |
| 34 | totalUSD = spec.HourUSD*b.Hours + spec.EBSGB*rc.EBSGBMonthUSD*(b.Hours/HoursPerMonth) |
| 35 | perAccount = map[string]float64{} |
| 36 | if b.Hours <= 0 { |
| 37 | return perAccount, totalUSD, totalUSD |
| 38 | } |
| 39 | allocated := 0.0 |
| 40 | for acct, load := range b.ByAccount { |
| 41 | var cpuShare, memShare float64 |
| 42 | if spec.Cores > 0 { |
| 43 | cpuShare = load.CPUCoreHours / (spec.Cores * b.Hours) |
| 44 | } |
| 45 | if spec.MemGB > 0 { |
| 46 | memShare = load.MemGBHours / (spec.MemGB * b.Hours) |
| 47 | } |
| 48 | var share float64 |
| 49 | switch al.Policy { |
| 50 | case "cpu": |
| 51 | share = cpuShare |
| 52 | case "mem": |
| 53 | share = memShare |
| 54 | default: // blend |
| 55 | share = al.CPUWeight*cpuShare + (1-al.CPUWeight)*memShare |
| 56 | } |
| 57 | usd := share * totalUSD |
| 58 | perAccount[acct] = usd |
| 59 | allocated += usd |
| 60 | } |
| 61 | idleUSD = totalUSD - allocated |
| 62 | if idleUSD < 0 { |
| 63 | idleUSD = 0 |
| 64 | } |
| 65 | return perAccount, idleUSD, totalUSD |
| 66 | } |
| 67 | |
| 68 | // AccountEconomics is one account priced under one scenario. |
| 69 | type AccountEconomics struct { |
| 70 | Account string |
| 71 | CostUSD float64 |
| 72 | RevenueUSD float64 |
| 73 | MarginFrac float64 // (revenue-cost)/revenue, 0 when revenue is 0 |
| 74 | // FreeTierPos is "inside" | "partial" | "paying" as computed by this |
| 75 | // function — plus a fourth word, "pooled", that PriceAccount itself |
| 76 | // never produces: economics-v2's fleet-pool tiers (try/pool/"") draw |
| 77 | // from a shared pool rather than being individually priced, so the |
| 78 | // console view layer (buildEconomicsView, internal/console/ |
| 79 | // economicsview.go) overrides FreeTierPos to "pooled" for those tiers |
| 80 | // AFTER calling PriceAccount, rather than this function knowing about |
| 81 | // tiers at all — PriceAccount stays tier-unaware by design (Task 5). |
| 82 | FreeTierPos string |
| 83 | } |
| 84 | |
| 85 | func billable(meters map[string]float64, meter string, allowance float64) float64 { |
| 86 | v := meters[meter] - allowance |
| 87 | if v < 0 { |
| 88 | return 0 |
| 89 | } |
| 90 | return v |
| 91 | } |
| 92 | |
| 93 | // PriceAccount prices one account's meters plus its allocated box cost |
| 94 | // under sc. These formulas — including the fleet box revenue and rate-sheet |
| 95 | // mem-overage terms — are mirrored verbatim by the dashboard's inline knob |
| 96 | // JS (priceAccount, econ_maths_js define, templates/economics.html): change |
| 97 | // both together, or the page will disagree with the server it loaded from. |
| 98 | func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics { |
| 99 | rc := sc.RateCard |
| 100 | cost := allocatedUSD + |
| 101 | meters[usage.MeterEgressGB]*rc.EgressGBUSD + |
| 102 | meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD + |
| 103 | meters[usage.MeterStorageGBMonth]*rc.StorageGBMonthUSD |
| 104 | |
| 105 | ft := sc.FreeTier |
| 106 | bEgress := billable(meters, usage.MeterEgressGB, ft.EgressGB) |
| 107 | bStorage := billable(meters, usage.MeterStorageGBMonth, ft.StorageGB) |
| 108 | bApps := billable(meters, usage.MeterAppMonths, ft.Apps) |
| 109 | bMem := billable(meters, usage.MeterMemGBHours, ft.MemGBHours) |
| 110 | computeFrac := 1.0 |
| 111 | if m := meters[usage.MeterMemGBHours]; m > 0 { |
| 112 | computeFrac = bMem / m |
| 113 | } |
| 114 | |
| 115 | var revenue float64 |
| 116 | if sc.Mode == "rate-sheet" { |
| 117 | revenue = bApps*sc.Sheet.AppMonthUSD + |
| 118 | bEgress*sc.Sheet.EgressGBUSD + |
| 119 | bStorage*sc.Sheet.StorageGBMonthUSD + |
| 120 | meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD + |
| 121 | bMem*sc.Sheet.MemGBHourUSD |
| 122 | } else { // cost-plus |
| 123 | billableCost := allocatedUSD*computeFrac + |
| 124 | bEgress*rc.EgressGBUSD + |
| 125 | meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD + |
| 126 | bStorage*rc.StorageGBMonthUSD |
| 127 | revenue = billableCost / (1 - sc.Margin) |
| 128 | } |
| 129 | |
| 130 | // Fleet box revenue (both modes) |
| 131 | fleetRevenue := (meters[usage.MeterFleetBoxHoursServer]/HoursPerMonth)*sc.Sheet.BoxMonthServerUSD + |
| 132 | (meters[usage.MeterFleetBoxHoursDevice]/HoursPerMonth)*sc.Sheet.BoxMonthDeviceUSD |
| 133 | |
| 134 | // Apply volume break if applicable |
| 135 | avgBoxes := (meters[usage.MeterFleetBoxHoursServer] + meters[usage.MeterFleetBoxHoursDevice]) / HoursPerMonth |
| 136 | if sc.FleetBreak.MinBoxes > 0 && avgBoxes >= sc.FleetBreak.MinBoxes { |
| 137 | fleetRevenue *= (1 - sc.FleetBreak.Discount) |
| 138 | } |
| 139 | revenue += fleetRevenue |
| 140 | |
| 141 | margin := 0.0 |
| 142 | if revenue > 0 { |
| 143 | margin = (revenue - cost) / revenue |
| 144 | } |
| 145 | |
| 146 | anyUsage := false |
| 147 | for k, v := range meters { |
| 148 | // The backhaul meters are data, not usage: a subset of egress, |
| 149 | // priced nowhere (#288). Skipped so that an account cannot look |
| 150 | // "inside the free tier" on their account alone. |
| 151 | if k == usage.MeterBackhaulGB || k == usage.MeterBackhaulRequests1k { |
| 152 | continue |
| 153 | } |
| 154 | if v > 0 { |
| 155 | anyUsage = true |
| 156 | break |
| 157 | } |
| 158 | } |
| 159 | clipped := bEgress < meters[usage.MeterEgressGB] || |
| 160 | bStorage < meters[usage.MeterStorageGBMonth] || |
| 161 | bApps < meters[usage.MeterAppMonths] || |
| 162 | bMem < meters[usage.MeterMemGBHours] |
| 163 | pos := "paying" |
| 164 | switch { |
| 165 | case revenue == 0 && anyUsage: |
| 166 | pos = "inside" |
| 167 | case clipped: |
| 168 | pos = "partial" |
| 169 | } |
| 170 | |
| 171 | return AccountEconomics{Account: account, CostUSD: cost, RevenueUSD: revenue, MarginFrac: margin, FreeTierPos: pos} |
| 172 | } |
| 173 | |
| 174 | // Project scales a month-to-date figure to a full month by covered days. |
| 175 | func Project(mtd float64, coveredDays, daysInMonth int) float64 { |
| 176 | if coveredDays <= 0 { |
| 177 | return 0 |
| 178 | } |
| 179 | return mtd * float64(daysInMonth) / float64(coveredDays) |
| 180 | } |
| 181 | |