carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package economics
2
3import "github.com/carlosframework/platform/internal/usage"
4
5// HoursPerMonth prorates GB-month rates over hour-denominated periods.
6const 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.
13type 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.
20type 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).
29func 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.
69type 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
85func 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.
98func 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.
175func Project(mtd float64, coveredDays, daysInMonth int) float64 {
176 if coveredDays <= 0 {
177 return 0
178 }
179 return mtd * float64(daysInMonth) / float64(coveredDays)
180}
181