| 1 | /** |
| 2 | * Costs & margin: the arithmetic behind the page, apart from the SVG and |
| 3 | * the Workers runtime so it can be tested under Node. Money is in micros. |
| 4 | */ |
| 5 | import type { BillRead, CloudflareCycle, CostDay, CostMappingInput, CostSettings, CycleMeter, OverallMargin, PauseLevel, PlatformGuard, SpendCaps } from "@g1t/contracts"; |
| 6 | |
| 7 | import { parseDollars, usd } from "./money.ts"; |
| 8 | |
| 9 | /** Buckets Cloudflare does not bill: their cost is g1t's own figure. */ |
| 10 | export const NOT_CLOUDFLARE = new Set(["models"]); |
| 11 | |
| 12 | /** A day's cost: Cloudflare's bill, or g1t's own figure where Cloudflare does not bill it. */ |
| 13 | export function dayCost(day: CostDay): number { |
| 14 | return NOT_CLOUDFLARE.has(day.bucket) ? day.ownCostMicros : day.cfCostMicros; |
| 15 | } |
| 16 | |
| 17 | export type DayFigures = { day: string; revenueMicros: number; costMicros: number }; |
| 18 | |
| 19 | /** Every day from `since` to `until`, inclusive (YYYY-MM-DD, UTC). */ |
| 20 | export function daysBetween(since: string, until: string): string[] { |
| 21 | const start = Date.parse(`${since}T00:00:00Z`); |
| 22 | const end = Date.parse(`${until}T00:00:00Z`); |
| 23 | if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return []; |
| 24 | const out: string[] = []; |
| 25 | for (let at = start; at <= end && out.length < 400; at += 86_400_000) out.push(new Date(at).toISOString().slice(0, 10)); |
| 26 | return out; |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * A day per day of the range, every day there even with nothing on it. |
| 31 | * All of g1t: money in (what workspaces paid, the plan too) against every |
| 32 | * cost. One product: what customers were charged for it at price against |
| 33 | * what it cost. |
| 34 | */ |
| 35 | export function daySeries(days: CostDay[], since: string, until: string, bucket: string | null): DayFigures[] { |
| 36 | const totals = new Map<string, DayFigures>(daysBetween(since, until).map((day) => [day, { day, revenueMicros: 0, costMicros: 0 }])); |
| 37 | for (const row of days) { |
| 38 | if (bucket && row.bucket !== bucket) continue; |
| 39 | const figures = totals.get(row.day); |
| 40 | if (!figures) continue; |
| 41 | figures.revenueMicros += bucket ? row.valueMicros : row.cashMicros; |
| 42 | figures.costMicros += dayCost(row); |
| 43 | } |
| 44 | return [...totals.values()]; |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * The margin on the price a markup gives, in percent: cost plus 20% is a |
| 49 | * 16.7% margin, since the 20% is of the cost and the margin of the price. |
| 50 | */ |
| 51 | export function marginOnPrice(markupPercent: number): number { |
| 52 | return markupPercent > -100 ? (markupPercent / (100 + markupPercent)) * 100 : 0; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Who g1t paid over the range: Cloudflare's usage bill, its subscriptions |
| 57 | * (a month's, over the range) and the model providers. The same total as |
| 58 | * the statement's All in. |
| 59 | */ |
| 60 | export function whoPaid( |
| 61 | overall: { costMicros: number; cloudflareCostMicros?: number; modelsCostMicros?: number }, |
| 62 | subscriptionsMicros: number, |
| 63 | ): { totalMicros: number; cloudflareMicros: number; subscriptionsMicros: number; modelsMicros: number } { |
| 64 | const modelsMicros = overall.modelsCostMicros ?? 0; |
| 65 | const cloudflareMicros = overall.cloudflareCostMicros ?? overall.costMicros - modelsMicros; |
| 66 | return { totalMicros: overall.costMicros + subscriptionsMicros, cloudflareMicros, subscriptionsMicros, modelsMicros }; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Cloudflare's subscriptions over the range: billing's figure (each day its |
| 71 | * billing cycle's share, the same accrual as the month view), or from a |
| 72 | * billing that does not send one, a month's over 30 days. |
| 73 | */ |
| 74 | export function subscriptionsOver(monthlyMicros: number, days: number, overall?: Pick<OverallMargin, "subscriptionsMicros">): number { |
| 75 | if (typeof overall?.subscriptionsMicros === "number") return overall.subscriptionsMicros; |
| 76 | return Math.round((monthlyMicros * days) / 30); |
| 77 | } |
| 78 | |
| 79 | /** What was given away, by why, for the statement: only the ones with any. */ |
| 80 | export function givenParts(o: OverallMargin): [string, number][] { |
| 81 | return ( |
| 82 | [ |
| 83 | ["100% discounts", o.givenCompedMicros ?? 0], |
| 84 | ["free use", o.givenFreeMicros ?? 0], |
| 85 | ["trial", o.givenTrialMicros ?? 0], |
| 86 | ["open-source pool", o.givenPoolMicros ?? 0], |
| 87 | ["partial discounts", o.givenDiscountMicros ?? 0], |
| 88 | ["promotional credit", o.givenCreditPromotionalMicros ?? 0], |
| 89 | ["goodwill credit", o.givenCreditGoodwillMicros ?? 0], |
| 90 | ["testing resets", o.givenResetMicros ?? 0], |
| 91 | ["charged without real money", o.givenUnpaidMicros ?? 0], |
| 92 | ] as [string, number][] |
| 93 | ).filter(([, micros]) => micros > 0); |
| 94 | } |
| 95 | |
| 96 | /** How a cycle meter's cost was arrived at, in words. */ |
| 97 | export function basisLabel(basis: string): string { |
| 98 | if (basis === "cloudflare") return "Cloudflare's cost"; |
| 99 | if (basis === "list") return "List price past the included amount"; |
| 100 | return "No list price: counted at $0"; |
| 101 | } |
| 102 | |
| 103 | /** A cycle's headline: cost so far, the projection and the average day, as Cloudflare's Billable usage page puts them. */ |
| 104 | export function cycleHeadline(cycle: CloudflareCycle): { title: string; detail: string } { |
| 105 | return { |
| 106 | title: `${cycle.start} to ${cycle.end}, day ${cycle.daysElapsed} of ${cycle.days}`, |
| 107 | detail: `${usd(cycle.usageMicros, { cents: true })} so far, ${usd(cycle.averageDailyMicros, { cents: true })} a day; projected ${usd(cycle.projectedMicros, { cents: true })} for the cycle, and ${usd(cycle.subscriptionsMicros, { cents: true })} of subscriptions`, |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | /** Meters with no list price that were used: costed at $0 until one is added. */ |
| 112 | export function unpricedMeters(meters: CycleMeter[]): CycleMeter[] { |
| 113 | return meters.filter((m) => m.basis === "none" && m.quantity > 0); |
| 114 | } |
| 115 | |
| 116 | /** What the last read of the bill got, in a sentence, and whether it looks incomplete. */ |
| 117 | export function billReadNote(read: BillRead): { text: string; warn: boolean } { |
| 118 | const parts = [`${read.rows.toLocaleString("en-US")} rows in ${read.pages} ${read.pages === 1 ? "page" : "pages"} for ${read.since} to ${read.until}`]; |
| 119 | if (read.pricingOnlyRows > 0) parts.push(`${read.pricingOnlyRows} with only a pricing quantity, which can be in blocks`); |
| 120 | parts.push(read.costedRows > 0 ? `${read.costedRows} with Cloudflare's own cost` : "none with a cost of Cloudflare's, so the list prices apply"); |
| 121 | return { text: `${parts.join("; ")}.`, warn: read.rows === 0 || read.pricingOnlyRows > 0 }; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * A price version's cost and price as the table shows them. A rate g1t |
| 126 | * sets has no cost behind it; a weight is a multiplier, not money. |
| 127 | */ |
| 128 | export function versionCells(v: { costMicros: number; priceMicros: number; basis?: string }): { cost: string; price: string; note: string | null } { |
| 129 | if (v.basis === "weight") { |
| 130 | const weight = Number((v.costMicros / 1_000_000).toFixed(6)); |
| 131 | return { cost: "—", price: `×${weight}`, note: "A weight on the agent rate's tokens, not money" }; |
| 132 | } |
| 133 | if (v.basis === "rate") return { cost: "—", price: unitDollars(v.priceMicros), note: "g1t's own rate: no cost behind it" }; |
| 134 | return { cost: unitDollars(v.costMicros), price: unitDollars(v.priceMicros), note: null }; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * What became of a proposal, in words, for its line: who decided it, and |
| 139 | * when one never took effect because a later measurement replaced it. |
| 140 | */ |
| 141 | export function proposalOutcome(p: { status: string; decidedBy: string | null; effectiveAt: string | null }): string | null { |
| 142 | const by = p.decidedBy ? `${p.status === "superseded" ? "applied" : p.status} by ${p.decidedBy}` : null; |
| 143 | const replaced = "replaced by a later measurement before it took effect; nothing was charged at it"; |
| 144 | if (p.status === "superseded") return by ? `${by}, then ${replaced}` : "replaced by a later measurement"; |
| 145 | if ((p.status === "applied" || p.status === "approved") && !p.effectiveAt) return by ? `${by}, then ${replaced}` : replaced; |
| 146 | return by; |
| 147 | } |
| 148 | |
| 149 | /** Margin as a whole percent of revenue; none when there was none. */ |
| 150 | export function marginPercent(revenueMicros: number, costMicros: number): number | null { |
| 151 | return revenueMicros > 0 ? ((revenueMicros - costMicros) / revenueMicros) * 100 : null; |
| 152 | } |
| 153 | |
| 154 | /** `12.3%`, `−4.0%`, or a dash. */ |
| 155 | export function percentLabel(percent: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string { |
| 156 | if (percent == null || !Number.isFinite(percent)) return "—"; |
| 157 | const text = `${Math.abs(percent).toFixed(1)}%`; |
| 158 | if (percent < 0) return `−${text}`; |
| 159 | return signed && percent > 0 ? `+${text}` : text; |
| 160 | } |
| 161 | |
| 162 | /** How a margin reads against the floor: under it is danger, close to it a warning. */ |
| 163 | export function marginTone(percent: number | null | undefined, floor: number): "danger" | "warn" | "mint" | undefined { |
| 164 | if (percent == null) return undefined; |
| 165 | if (percent < floor) return "danger"; |
| 166 | if (percent < floor + 5) return "warn"; |
| 167 | return "mint"; |
| 168 | } |
| 169 | |
| 170 | /** A count: `1,234` or `1.2M`. */ |
| 171 | export function countLabel(value: number): string { |
| 172 | if (!Number.isFinite(value)) return "—"; |
| 173 | if (Math.abs(value) >= 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`; |
| 174 | return Math.round(value).toLocaleString("en-US"); |
| 175 | } |
| 176 | |
| 177 | /** What a kind of drift is called. */ |
| 178 | export function driftLabel(kind: string): string { |
| 179 | return { count: "Count", cost: "Cost", leak: "Leak" }[kind] ?? kind; |
| 180 | } |
| 181 | |
| 182 | /** A cost per unit in micros, as dollars with as many places as it needs: `$0.000016`, `$0.15`. */ |
| 183 | export function unitDollars(micros: number): string { |
| 184 | const dollars = micros / 1_000_000; |
| 185 | if (dollars === 0) return "$0"; |
| 186 | if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`; |
| 187 | const places = Math.min(10, Math.max(2, 2 - Math.floor(Math.log10(Math.abs(dollars))))); |
| 188 | return `$${dollars.toFixed(places)}`; |
| 189 | } |
| 190 | |
| 191 | /** The range shown: 7 to 90 days, 30 when not said. */ |
| 192 | export function parseRange(raw: string | null): number { |
| 193 | const days = Number(raw); |
| 194 | return Number.isInteger(days) && days >= 7 && days <= 90 ? days : 30; |
| 195 | } |
| 196 | |
| 197 | /** The product to chart, if it is one the page has. */ |
| 198 | export function parseBucket(raw: string | null, known: string[]): string | null { |
| 199 | return raw && known.includes(raw) ? raw : null; |
| 200 | } |
| 201 | |
| 202 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; |
| 203 | |
| 204 | function numberField(form: FormData, name: string): number { |
| 205 | const raw = String(form.get(name) ?? "").trim(); |
| 206 | return raw === "" ? Number.NaN : Number(raw); |
| 207 | } |
| 208 | |
| 209 | /** The guardrails from the settings form. */ |
| 210 | export function parseCostSettings(form: FormData): Parsed<CostSettings> { |
| 211 | const autoApplyPercent = numberField(form, "autoApplyPercent"); |
| 212 | const noticeDays = numberField(form, "noticeDays"); |
| 213 | const marginFloorPercent = numberField(form, "marginFloorPercent"); |
| 214 | const alertDays = numberField(form, "alertDays"); |
| 215 | const anomalyFactor = numberField(form, "anomalyFactor"); |
| 216 | const minDaily = parseDollars(String(form.get("minDailyCost") ?? "")); |
| 217 | const anomalyFloor = parseDollars(String(form.get("anomalyFloor") ?? "")); |
| 218 | if (!(autoApplyPercent >= 0 && autoApplyPercent <= 100)) return { ok: false, error: "The guardrail is a percentage from 0 to 100." }; |
| 219 | if (!(Number.isInteger(noticeDays) && noticeDays >= 0 && noticeDays <= 90)) return { ok: false, error: "Notice is 0 to 90 days." }; |
| 220 | if (!(marginFloorPercent >= -100 && marginFloorPercent <= 100)) return { ok: false, error: "The margin floor is a percentage." }; |
| 221 | if (!(Number.isInteger(alertDays) && alertDays >= 1 && alertDays <= 30)) return { ok: false, error: "Alert after 1 to 30 days." }; |
| 222 | if (!(anomalyFactor > 0 && anomalyFactor <= 100)) return { ok: false, error: "The factor is a positive number." }; |
| 223 | if (minDaily == null || anomalyFloor == null) return { ok: false, error: "Amounts are dollars to the cent." }; |
| 224 | return { |
| 225 | ok: true, |
| 226 | value: { |
| 227 | autoApply: form.get("autoApply") === "on", |
| 228 | autoApplyPercent, |
| 229 | noticeDays, |
| 230 | marginFloorPercent, |
| 231 | alertDays, |
| 232 | minDailyCostMicros: minDaily, |
| 233 | anomalyFactor, |
| 234 | anomalyFloorMicros: anomalyFloor, |
| 235 | cardFee: form.get("cardFee") === "on", |
| 236 | }, |
| 237 | }; |
| 238 | } |
| 239 | |
| 240 | const NAME = /^(\*|[a-z0-9][a-z0-9_]{0,79})$/; |
| 241 | |
| 242 | /** A mapping from the mapping form: Cloudflare's product and meter (or `*`) to one of g1t's products. */ |
| 243 | export function parseMapping(form: FormData): Parsed<CostMappingInput> { |
| 244 | const value = (name: string) => String(form.get(name) ?? "").trim(); |
| 245 | const product = value("product").toLowerCase(); |
| 246 | const meter = value("meter").toLowerCase() || "*"; |
| 247 | const remove = form.get("remove") === "1"; |
| 248 | if (!NAME.test(product) || product === "*") return { ok: false, error: "Cloudflare's product, as the lines table names it." }; |
| 249 | if (!NAME.test(meter)) return { ok: false, error: "A meter prefix as the lines table names it, or * for all of the product." }; |
| 250 | if (remove) return { ok: true, value: { product, meter, remove: true } }; |
| 251 | const bucket = value("bucket").toLowerCase(); |
| 252 | if (!NAME.test(bucket) || bucket === "*") return { ok: false, error: "Which of g1t's products it is a cost of." }; |
| 253 | const drift = value("driftPercent"); |
| 254 | const driftPercent = drift === "" ? null : Number(drift); |
| 255 | if (driftPercent != null && !(driftPercent > 0 && driftPercent <= 1000)) return { ok: false, error: "Drift is a percentage above zero." }; |
| 256 | return { |
| 257 | ok: true, |
| 258 | value: { |
| 259 | product, |
| 260 | meter, |
| 261 | bucket, |
| 262 | priceMeter: value("priceMeter") || null, |
| 263 | ownMeter: value("ownMeter") || null, |
| 264 | scaleToOwn: form.get("scaleToOwn") === "on", |
| 265 | driftPercent, |
| 266 | note: value("note").slice(0, 200), |
| 267 | }, |
| 268 | }; |
| 269 | } |
| 270 | |
| 271 | // --- g1t's own spend (billing's budget) --------------------------------------- |
| 272 | |
| 273 | /** |
| 274 | * The red bar on every sudo page: the daily breaker open, or a 100%-discount |
| 275 | * account's monthly budget used up. Null when neither. |
| 276 | */ |
| 277 | export function spendBanner(caps: SpendCaps): string | null { |
| 278 | const parts: string[] = []; |
| 279 | if (caps.tripped) { |
| 280 | parts.push( |
| 281 | `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new hosted-model agent runs g1t pays for wait until 00:00 UTC`, |
| 282 | ); |
| 283 | } |
| 284 | for (const budget of caps.comped) { |
| 285 | if (budget.ceilingMicros > 0 && budget.usedMicros >= budget.ceilingMicros) { |
| 286 | parts.push(`${budget.name} used its ${usd(budget.ceilingMicros)} monthly budget, so new work on it is refused`); |
| 287 | } |
| 288 | } |
| 289 | if (parts.length === 0) return null; |
| 290 | const text = parts.join("; and "); |
| 291 | return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; |
| 292 | } |
| 293 | |
| 294 | /** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */ |
| 295 | export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } { |
| 296 | const notes: Record<string, string> = { |
| 297 | comped: "Work on accounts with a 100% discount: what it cost g1t, not its price", |
| 298 | trial: "Trial credit, at what it cost g1t", |
| 299 | oss: "Checks and workflows on public repositories, at what they cost g1t", |
| 300 | given: "Free workspaces' overruns past their trial, at what they cost g1t", |
| 301 | unpaid: "Usage charged while payments are in Stripe's test mode: what it cost g1t, not what was charged", |
| 302 | }; |
| 303 | const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" })); |
| 304 | rows.push({ key: "free", title: "Free tier", micros: caps.freeTierMicros, note: "Free workspaces' share of git, storage and platform, as last reconciled" }); |
| 305 | const monthly = usd(caps.fixedMonthlyMicros, { cents: true }); |
| 306 | const accrued = typeof caps.fixedMonthMicros === "number"; |
| 307 | rows.push({ |
| 308 | key: "fixed", |
| 309 | title: "Cloudflare subscriptions", |
| 310 | micros: accrued ? (caps.fixedMonthMicros as number) : caps.fixedMonthlyMicros, |
| 311 | note: accrued |
| 312 | ? `This month's days so far, each its billing cycle's share of ${monthly} a month${caps.fixedSource === "cloudflare" ? ", as Cloudflare lists them" : ", estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)"}` |
| 313 | : caps.fixedSource === "cloudflare" |
| 314 | ? "The whole month, as Cloudflare lists them" |
| 315 | : "The whole month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)", |
| 316 | }); |
| 317 | return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) }; |
| 318 | } |
| 319 | |
| 320 | /** How far a cap is used, 0 to 100, for a meter. */ |
| 321 | export function capPercent(usedMicros: number, capMicros: number): number { |
| 322 | if (capMicros <= 0) return 0; |
| 323 | return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100)); |
| 324 | } |
| 325 | |
| 326 | // --- Platform pause and usage watch (billing's platform.rs) ----------------- |
| 327 | |
| 328 | /** What each level of the platform pause stops, for the page and the banner. */ |
| 329 | export const PAUSE_LEVELS: { level: PauseLevel; title: string; stops: string }[] = [ |
| 330 | { level: "compute", title: "Compute", stops: "New agent runs, checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." }, |
| 331 | { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs, and the runner's sweep that starts queued agents." }, |
| 332 | { level: "indexing", title: "Indexing", stops: "Context embeddings and backfills, and search's backfills (they go on from where they were when resumed)." }, |
| 333 | { level: "renders", title: "Renders", stops: "Social card images: a cache miss gets the brand card or the static logo." }, |
| 334 | ]; |
| 335 | |
| 336 | /** Whether a form's level is one of the four. */ |
| 337 | export function parsePauseLevel(value: unknown): PauseLevel | null { |
| 338 | const level = String(value ?? ""); |
| 339 | return PAUSE_LEVELS.some((l) => l.level === level) ? (level as PauseLevel) : null; |
| 340 | } |
| 341 | |
| 342 | /** The red bar on every sudo page while any level is paused. Null when none is. */ |
| 343 | export function pauseBanner(guard: PlatformGuard): string | null { |
| 344 | const paused = guard.levels.filter((l) => l.paused); |
| 345 | if (paused.length === 0) return null; |
| 346 | const names = paused.map((l) => (l.auto ? `${l.level} (by the usage watcher)` : l.level)); |
| 347 | const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; |
| 348 | return `Paused across g1t: ${list}.`; |
| 349 | } |
| 350 | |
| 351 | /** `1.2M`, `240k`, `2.0B`: a count in a few characters. */ |
| 352 | export function count(value: number): string { |
| 353 | const n = Math.abs(value); |
| 354 | if (n >= 1e9) return `${(value / 1e9).toFixed(1)}B`; |
| 355 | if (n >= 1e6) return `${(value / 1e6).toFixed(1)}M`; |
| 356 | if (n >= 1e3) return `${Math.round(value / 1e3)}k`; |
| 357 | return `${Math.round(value)}`; |
| 358 | } |
| 359 | |
| 360 | /** An hour's value as a share of its threshold, rounded; null with no threshold. */ |
| 361 | export function thresholdShare(value: number, threshold: number): number | null { |
| 362 | return threshold > 0 ? Math.round((value / threshold) * 100) : null; |
| 363 | } |