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. // json tags are additive: nothing currently persists this shape to a // bucket, but console/economicsview.go embeds it (inside BoxLoad, keyed // under "boxLoads") in the dashboard's DataJSON payload, so it carries the // same snake_case convention as every other field in that payload. type BoxAccountLoad struct { CPUCoreHours float64 `json:"cpu_core_hours"` MemGBHours float64 `json:"mem_gb_hours"` } // BoxLoad is one box over the period: metered hours and per-account load. // See BoxAccountLoad's doc comment for why it carries json tags. type BoxLoad struct { Box string `json:"box"` Hours float64 `json:"hours"` ByAccount map[string]BoxAccountLoad `json:"by_account"` } // 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 is "inside" | "partial" | "paying" as computed by this // function — plus a fourth word, "pooled", that PriceAccount itself // never produces: economics-v2's fleet-pool tiers (try/pool/"") draw // from a shared pool rather than being individually priced, so the // console view layer (buildEconomicsView, internal/console/ // economicsview.go) overrides FreeTierPos to "pooled" for those tiers // AFTER calling PriceAccount, rather than this function knowing about // tiers at all — PriceAccount stays tier-unaware by design (Task 5). FreeTierPos string } 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 — including the fleet box revenue and rate-sheet // mem-overage terms — are mirrored verbatim by the dashboard's inline knob // JS (priceAccount, econ_maths_js define, templates/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 + bMem*sc.Sheet.MemGBHourUSD } else { // cost-plus billableCost := allocatedUSD*computeFrac + bEgress*rc.EgressGBUSD + meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD + bStorage*rc.StorageGBMonthUSD 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 } anyUsage := false for k, v := range meters { // The backhaul meters are data, not usage: a subset of egress, // priced nowhere (#288). Skipped so that an account cannot look // "inside the free tier" on their account alone. if k == usage.MeterBackhaulGB || k == usage.MeterBackhaulRequests1k { continue } 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) }