Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)
16 files+1589−1190/16 viewed
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 4 | + | import type { CloudflareCycle, CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | + | basisLabel, | |
| 8 | + | billReadNote, | |
| 7 | 9 | count, | |
| 10 | + | cycleHeadline, | |
| 8 | 11 | daySeries, | |
| 9 | 12 | daysBetween, | |
| 13 | + | givenParts, | |
| 10 | 14 | marginOnPrice, | |
| 11 | 15 | marginPercent, | |
| 12 | 16 | marginTone, | |
| ⋯ | |||
| 23 | 27 | subscriptionsOver, | |
| 24 | 28 | thresholdShare, | |
| 25 | 29 | unitDollars, | |
| 30 | + | unpricedMeters, | |
| 26 | 31 | versionCells, | |
| 27 | 32 | whoPaid, | |
| 28 | 33 | } from "./costs.ts"; | |
| ⋯ | |||
| 237 | 242 | assert.equal(thresholdShare(240_000, 200_000), 120); | |
| 238 | 243 | assert.equal(thresholdShare(5, 0), null); | |
| 239 | 244 | }); | |
| 245 | + | ||
| 246 | + | test("subscriptions over the range are billing's, day by day by the billing cycle", () => { | |
| 247 | + | // Billing's accrual wins over a month's over 30 days. | |
| 248 | + | assert.equal(subscriptionsOver(30_000_000, 30, { subscriptionsMicros: 29_032_258 }), 29_032_258); | |
| 249 | + | assert.equal(subscriptionsOver(30_000_000, 30, {}), 30_000_000); | |
| 250 | + | // The month view: this month's days so far, not the whole month. | |
| 251 | + | const { rows, totalMicros } = spendRows(caps({ fixedMonthMicros: 9_000_000 })); | |
| 252 | + | const fixed = rows.find((r) => r.key === "fixed")!; | |
| 253 | + | assert.equal(fixed.micros, 9_000_000); | |
| 254 | + | assert.match(fixed.note, /days so far, each its billing cycle's share of \$30\.00 a month/); | |
| 255 | + | assert.equal(totalMicros, 55_000_000); | |
| 256 | + | }); | |
| 257 | + | ||
| 258 | + | test("test-mode charges are given away, never money in", () => { | |
| 259 | + | const parts = givenParts({ usageMicros: 0, plansMicros: 0, costMicros: 0, marginMicros: 0, marginPercent: null, givenUnpaidMicros: 7_610_000, givenCompedMicros: 2_370_000 }); | |
| 260 | + | assert.deepEqual(parts, [ | |
| 261 | + | ["100% discounts", 2_370_000], | |
| 262 | + | ["charged without real money", 7_610_000], | |
| 263 | + | ]); | |
| 264 | + | }); | |
| 265 | + | ||
| 266 | + | const cycle = (over: Partial<CloudflareCycle> = {}): CloudflareCycle => ({ | |
| 267 | + | start: "2026-09-28", | |
| 268 | + | end: "2026-10-27", | |
| 269 | + | days: 30, | |
| 270 | + | daysElapsed: 12, | |
| 271 | + | usageMicros: 292_175, | |
| 272 | + | projectedMicros: 730_438, | |
| 273 | + | averageDailyMicros: 24_347, | |
| 274 | + | subscriptionsMicros: 30_000_000, | |
| 275 | + | meters: [ | |
| 276 | + | { product: "workers", meter: "workers_cpu_ms", rawName: "Workers / Workers CPU ms", unit: "ms", quantity: 39_160_000, included: 30_000_000, billableQuantity: 9_160_000, costMicros: 200_000, basis: "list" }, | |
| 277 | + | { product: "email", meter: "email_service_emails_sent", rawName: "Email / Emails sent", unit: "Count", quantity: 7, included: null, billableQuantity: 0, costMicros: 0, basis: "none" }, | |
| 278 | + | { product: "kv", meter: "kv_list_operations", rawName: "KV list", unit: "Count", quantity: 0, included: null, billableQuantity: 0, costMicros: 0, basis: "none" }, | |
| 279 | + | ], | |
| 280 | + | ...over, | |
| 281 | + | }); | |
| 282 | + | ||
| 283 | + | test("the billing cycle reads as Cloudflare's Billable usage page does", () => { | |
| 284 | + | const headline = cycleHeadline(cycle()); | |
| 285 | + | assert.equal(headline.title, "2026-09-28 to 2026-10-27, day 12 of 30"); | |
| 286 | + | assert.equal(headline.detail, "$0.29 so far, $0.02 a day; projected $0.73 for the cycle, and $30.00 of subscriptions"); | |
| 287 | + | // Used with no list price: said, not hidden. Unused ones are no news. | |
| 288 | + | assert.deepEqual(unpricedMeters(cycle().meters).map((m) => m.meter), ["email_service_emails_sent"]); | |
| 289 | + | assert.equal(basisLabel("list"), "List price past the included amount"); | |
| 290 | + | assert.equal(basisLabel("cloudflare"), "Cloudflare's cost"); | |
| 291 | + | assert.equal(basisLabel("none"), "No list price: counted at $0"); | |
| 292 | + | }); | |
| 293 | + | ||
| 294 | + | test("the last read of the bill says when it may be incomplete", () => { | |
| 295 | + | const read = { readAt: "2026-10-09T04:17:00Z", since: "2026-09-28", until: "2026-10-09", rows: 412, pages: 3, consumedRows: 412, pricingOnlyRows: 0, costedRows: 0 }; | |
| 296 | + | assert.deepEqual(billReadNote(read), { | |
| 297 | + | text: "412 rows in 3 pages for 2026-09-28 to 2026-10-09; none with a cost of Cloudflare's, so the list prices apply.", | |
| 298 | + | warn: false, | |
| 299 | + | }); | |
| 300 | + | assert.equal(billReadNote({ ...read, pricingOnlyRows: 5 }).warn, true); | |
| 301 | + | assert.equal(billReadNote({ ...read, rows: 0, pages: 1, consumedRows: 0 }).warn, true); | |
| 302 | + | }); | |
| 2 | 2 | * Costs & margin: the arithmetic behind the page, apart from the SVG and | |
| 3 | 3 | * the Workers runtime so it can be tested under Node. Money is in micros. | |
| 4 | 4 | */ | |
| 5 | − | import type { CostDay, CostMappingInput, CostSettings, PauseLevel, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 5 | + | import type { BillRead, CloudflareCycle, CostDay, CostMappingInput, CostSettings, CycleMeter, OverallMargin, PauseLevel, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { parseDollars, usd } from "./money.ts"; | |
| 8 | 8 | ||
| ⋯ | |||
| 66 | 66 | return { totalMicros: overall.costMicros + subscriptionsMicros, cloudflareMicros, subscriptionsMicros, modelsMicros }; | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | − | /** Cloudflare's subscriptions over a range of days: a month's, pro rata. */ | |
| 70 | − | export function subscriptionsOver(monthlyMicros: number, days: number): number { | |
| 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; | |
| 71 | 76 | return Math.round((monthlyMicros * days) / 30); | |
| 72 | 77 | } | |
| 73 | 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 | + | ||
| 74 | 124 | /** | |
| 75 | 125 | * A price version's cost and price as the table shows them. A rate g1t | |
| 76 | 126 | * sets has no cost behind it; a weight is a multiplier, not money. | |
| ⋯ | |||
| 252 | 302 | }; | |
| 253 | 303 | const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" })); | |
| 254 | 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"; | |
| 255 | 307 | rows.push({ | |
| 256 | 308 | key: "fixed", | |
| 257 | 309 | title: "Cloudflare subscriptions", | |
| 258 | − | micros: caps.fixedMonthlyMicros, | |
| 259 | − | note: caps.fixedSource === "cloudflare" ? "The whole month, as Cloudflare lists them" : "The whole month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)", | |
| 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)", | |
| 260 | 316 | }); | |
| 261 | 317 | return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) }; | |
| 262 | 318 | } | |
| 5 | 5 | import type { Route } from "./+types/costs-bill"; | |
| 6 | 6 | import { CostsHeader } from "~/components/costs-header"; | |
| 7 | 7 | import { Badge, Button, EmptyState, Field, Input, Notice, Section, When } from "~/components/ui"; | |
| 8 | − | import { countLabel, driftLabel, percentLabel, proposalOutcome, unitDollars, versionCells } from "~/lib/costs"; | |
| 8 | + | import { basisLabel, billReadNote, countLabel, cycleHeadline, driftLabel, percentLabel, proposalOutcome, unitDollars, unpricedMeters, versionCells } from "~/lib/costs"; | |
| 9 | 9 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; | |
| 10 | 10 | import { dollarsField, usd } from "~/lib/money"; | |
| 11 | 11 | ||
| ⋯ | |||
| 37 | 37 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 38 | 38 | <CostsHeader page="bill" range={range} report={report} done={done} runError={failed?.section === "run" ? failed.error : null} description={description} /> | |
| 39 | 39 | ||
| 40 | + | <CycleSection report={report} /> | |
| 41 | + | ||
| 40 | 42 | <Section | |
| 41 | − | className="mt-5" | |
| 43 | + | className="mt-6" | |
| 42 | 44 | id="drift" | |
| 43 | 45 | title="Drift" | |
| 44 | 46 | description="Over the last 7 days: counts g1t and Cloudflare disagree on past a mapping's threshold, a bill far from the price book's cost of the same usage, and leaks (cost nothing charges for, or a Cloudflare meter no one mapped)." | |
| ⋯ | |||
| 335 | 337 | ); | |
| 336 | 338 | } | |
| 337 | 339 | ||
| 340 | + | /** | |
| 341 | + | * Cloudflare's current billing cycle, the way its Billable usage page shows | |
| 342 | + | * it: each meter's use, what the cycle includes, what is past it and what | |
| 343 | + | * that costs, with the total so far and the projection. | |
| 344 | + | */ | |
| 345 | + | function CycleSection({ report }: { report: CostsReport }) { | |
| 346 | + | const cycle = report.cycle; | |
| 347 | + | const read = report.billRead ? billReadNote(report.billRead) : null; | |
| 348 | + | const unpriced = cycle ? unpricedMeters(cycle.meters) : []; | |
| 349 | + | const headline = cycle ? cycleHeadline(cycle) : null; | |
| 350 | + | return ( | |
| 351 | + | <Section | |
| 352 | + | className="mt-5" | |
| 353 | + | id="cycle" | |
| 354 | + | title="This billing cycle" | |
| 355 | + | description="Cloudflare's usage bill for the cycle so far, meter by meter. Each meter's included amount is the account's, once a cycle; the cost is Cloudflare's own where its lines carry one, else the list price past the included amount (per-million meters in whole millions), on the days the cycle's total passed it. Subscriptions are apart." | |
| 356 | + | > | |
| 357 | + | {!cycle || !headline ? ( | |
| 358 | + | <EmptyState title="No cycle read yet">The bill is read once a day at 04:17 UTC, or now with “Run the analysis now”.</EmptyState> | |
| 359 | + | ) : ( | |
| 360 | + | <> | |
| 361 | + | <p className="text-sm"> | |
| 362 | + | <span className="font-medium text-fg">{headline.title}</span> | |
| 363 | + | <span className="block text-muted">{headline.detail}.</span> | |
| 364 | + | </p> | |
| 365 | + | {unpriced.length > 0 && ( | |
| 366 | + | <div className="mt-3"> | |
| 367 | + | <Notice tone="warn"> | |
| 368 | + | Used with no list price, so counted at $0 until Cloudflare puts a cost on it or a price is added (src/cycle.rs):{" "} | |
| 369 | + | {unpriced.map((m) => `${m.product} / ${m.meter}`).join(", ")}. | |
| 370 | + | </Notice> | |
| 371 | + | </div> | |
| 372 | + | )} | |
| 373 | + | <div className="-mx-4 mt-3 overflow-x-auto sm:-mx-5"> | |
| 374 | + | <table className="w-full min-w-[48rem] text-sm"> | |
| 375 | + | <thead> | |
| 376 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 377 | + | <th className="px-4 py-2 font-medium sm:px-5">Meter</th> | |
| 378 | + | <th className="px-4 py-2 text-right font-medium">Used</th> | |
| 379 | + | <th className="px-4 py-2 text-right font-medium">Included</th> | |
| 380 | + | <th className="px-4 py-2 text-right font-medium">Billable</th> | |
| 381 | + | <th className="px-4 py-2 text-right font-medium sm:pr-5">Cost</th> | |
| 382 | + | </tr> | |
| 383 | + | </thead> | |
| 384 | + | <tbody> | |
| 385 | + | {cycle.meters.map((m) => ( | |
| 386 | + | <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0"> | |
| 387 | + | <td className="px-4 py-2.5 sm:px-5"> | |
| 388 | + | {m.rawName} | |
| 389 | + | <span className="block text-xs text-faint">{basisLabel(m.basis)}</span> | |
| 390 | + | </td> | |
| 391 | + | <td className="tabular px-4 py-2.5 text-right"> | |
| 392 | + | {countLabel(m.quantity)} <span className="text-xs text-faint">{m.unit}</span> | |
| 393 | + | </td> | |
| 394 | + | <td className="tabular px-4 py-2.5 text-right text-muted">{m.included == null ? "—" : countLabel(m.included)}</td> | |
| 395 | + | <td className="tabular px-4 py-2.5 text-right">{countLabel(m.billableQuantity)}</td> | |
| 396 | + | <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(m.costMicros)}</td> | |
| 397 | + | </tr> | |
| 398 | + | ))} | |
| 399 | + | <tr> | |
| 400 | + | <td className="px-4 py-2.5 font-medium sm:px-5" colSpan={4}> | |
| 401 | + | Usage so far | |
| 402 | + | </td> | |
| 403 | + | <td className="tabular px-4 py-2.5 text-right font-medium sm:pr-5">{usd(cycle.usageMicros, { cents: true })}</td> | |
| 404 | + | </tr> | |
| 405 | + | </tbody> | |
| 406 | + | </table> | |
| 407 | + | </div> | |
| 408 | + | </> | |
| 409 | + | )} | |
| 410 | + | {read && ( | |
| 411 | + | <p className={`mt-3 text-xs ${read.warn ? "text-warn" : "text-faint"}`}> | |
| 412 | + | Last read <When at={report.billRead?.readAt ?? null} time />: {read.text} | |
| 413 | + | </p> | |
| 414 | + | )} | |
| 415 | + | </Section> | |
| 416 | + | ); | |
| 417 | + | } | |
| 418 | + | ||
| 338 | 419 | function MappingsTable({ report }: { report: CostsReport }) { | |
| 339 | 420 | return ( | |
| 340 | 421 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> | |
| 7 | 7 | import { DaysChart } from "~/components/costs"; | |
| 8 | 8 | import { CostsHeader, chip, costsHref } from "~/components/costs-header"; | |
| 9 | 9 | import { Badge, Button, Field, Input, Notice, Section, Stat, When } from "~/components/ui"; | |
| 10 | − | import { PAUSE_LEVELS, capPercent, count, daySeries, marginOnPrice, marginTone, parseBucket, percentLabel, spendRows, subscriptionsOver, thresholdShare, whoPaid } from "~/lib/costs"; | |
| 10 | + | import { PAUSE_LEVELS, capPercent, count, daySeries, givenParts as givenBy, marginOnPrice, marginTone, parseBucket, percentLabel, spendRows, subscriptionsOver, thresholdShare, whoPaid } from "~/lib/costs"; | |
| 11 | 11 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; | |
| 12 | 12 | import { usd } from "~/lib/money"; | |
| 13 | 13 | ||
| ⋯ | |||
| 173 | 173 | </table> | |
| 174 | 174 | </div> | |
| 175 | 175 | )} | |
| 176 | + | {(report.unattributedMicros ?? 0) > 0 && ( | |
| 177 | + | <p className="mt-3 text-xs text-muted"> | |
| 178 | + | And {usd(report.unattributedMicros ?? 0, { cents: true })} of running g1t on days no workspace used anything: no one's, so not above. The | |
| 179 | + | workspaces' costs and this add up to the cost in the statement. | |
| 180 | + | </p> | |
| 181 | + | )} | |
| 176 | 182 | </Section> | |
| 177 | 183 | ||
| 178 | 184 | </main> | |
| ⋯ | |||
| 216 | 222 | const usagePercent = soldSomething && o.usageMarginPercent !== undefined ? o.usageMarginPercent : null; | |
| 217 | 223 | const running = o.runningCostMicros ?? 0; | |
| 218 | 224 | const unmapped = o.unmappedCostMicros ?? 0; | |
| 219 | − | // Cloudflare's subscriptions are not on the usage bill: a month's, over the range. | |
| 220 | − | const subscriptions = subscriptionsOver(report.caps.fixedMonthlyMicros, range); | |
| 225 | + | // Cloudflare's subscriptions are not on the usage bill: each day's share of its billing cycle. | |
| 226 | + | const subscriptions = subscriptionsOver(report.caps.fixedMonthlyMicros, range, o); | |
| 221 | 227 | const paid = whoPaid(o, subscriptions); | |
| 222 | 228 | const moneyIn = o.usageMicros + o.plansMicros; | |
| 223 | 229 | const spent = paid.totalMicros; | |
| 224 | 230 | const net = moneyIn - spent; | |
| 225 | − | const givenParts = [ | |
| 226 | − | ["100% discounts", o.givenCompedMicros ?? 0], | |
| 227 | − | ["free use", o.givenFreeMicros ?? 0], | |
| 228 | − | ["trial", o.givenTrialMicros ?? 0], | |
| 229 | − | ["open-source pool", o.givenPoolMicros ?? 0], | |
| 230 | − | ["partial discounts", o.givenDiscountMicros ?? 0], | |
| 231 | − | ["promotional credit", o.givenCreditPromotionalMicros ?? 0], | |
| 232 | − | ["goodwill credit", o.givenCreditGoodwillMicros ?? 0], | |
| 233 | − | ["testing resets", o.givenResetMicros ?? 0], | |
| 234 | − | ].filter(([, micros]) => (micros as number) > 0) as [string, number][]; | |
| 231 | + | const givenParts = givenBy(o); | |
| 232 | + | const gateway = o.gatewayCostMicros ?? 0; | |
| 235 | 233 | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ | |
| 236 | 234 | { | |
| 237 | 235 | title: "Usage sold", | |
| ⋯ | |||
| 259 | 257 | title: "Cloudflare subscriptions", | |
| 260 | 258 | note: | |
| 261 | 259 | report.caps.fixedSource === "cloudflare" | |
| 262 | − | ? `Fixed, as Cloudflare lists them${(report.caps.fixedItems ?? []).length > 0 ? ` (${(report.caps.fixedItems ?? []).map((i) => i.name).join(", ")})` : ""}; not on the usage bill` | |
| 260 | + | ? `Fixed, as Cloudflare lists them${(report.caps.fixedItems ?? []).length > 0 ? ` (${(report.caps.fixedItems ?? []).map((i) => i.name).join(", ")})` : ""}; not on the usage bill. Each day is its billing cycle's share of ${usd(report.caps.fixedMonthlyMicros, { cents: true })} a month` | |
| 263 | 261 | : "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS) until Cloudflare's list is read; not on the usage bill", | |
| 264 | 262 | in: null, | |
| 265 | 263 | cost: subscriptions, | |
| ⋯ | |||
| 300 | 298 | <Stat | |
| 301 | 299 | label="Who g1t paid" | |
| 302 | 300 | value={usd(paid.totalMicros, { cents: true })} | |
| 303 | − | hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}, model providers ${usd(paid.modelsMicros, { cents: true })}; the cost in All in`} | |
| 301 | + | hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}, model providers ${usd(paid.modelsMicros, { cents: true })}${gateway > 0 && Math.abs(gateway - paid.modelsMicros) >= 10_000 ? ` (AI Gateway priced them at ${usd(gateway, { cents: true })}; see Drift)` : ""}; the cost in All in`} | |
| 304 | 302 | /> | |
| 305 | 303 | <Stat | |
| 306 | 304 | label="Proposals waiting" | |
| ⋯ | |||
| 493 | 491 | <p className="mt-3 text-xs text-muted"> | |
| 494 | 492 | Why this differs from the statement above: this is {caps.month} so far, the statement the last {range} days. The statement takes Cloudflare's bill as the | |
| 495 | 493 | cost of what Cloudflare runs, so sandboxes and builds inside Cloudflare's included usage cost nothing there, and here what the price book says they | |
| 496 | − | cost. Here are Cloudflare's subscriptions for the whole month, there for the range. | |
| 494 | + | cost. Cloudflare's subscriptions are counted the same way in both: each day its billing cycle's share of the month's price. | |
| 497 | 495 | {(caps.resetMicros ?? 0) > 0 && ( | |
| 498 | 496 | <> | |
| 499 | 497 | {" "} | |
| 3313 | 3313 | /// given, never a leak. | |
| 3314 | 3314 | #[serde(default)] | |
| 3315 | 3315 | pub given_reset_micros: i64, | |
| 3316 | + | /// What workspaces were charged while payments were not live (Stripe's | |
| 3317 | + | /// test mode): no real money came in, so it is given, never money in. | |
| 3318 | + | #[serde(default)] | |
| 3319 | + | pub given_unpaid_micros: i64, | |
| 3316 | 3320 | /// Credits over the range: given (every kind), spent on usage, and | |
| 3317 | 3321 | /// refunds' money given back. | |
| 3318 | 3322 | #[serde(default)] | |
| ⋯ | |||
| 3341 | 3345 | /// they pay Stripe's fee, so they are not revenue either. | |
| 3342 | 3346 | #[serde(default)] | |
| 3343 | 3347 | pub card_fees_micros: i64, | |
| 3348 | + | /// Cloudflare's subscriptions over the range: each day's share of the | |
| 3349 | + | /// billing cycle it is in (a month's price over the cycle's days), the | |
| 3350 | + | /// same accrual g1t's own spend uses for the calendar month. | |
| 3351 | + | #[serde(default)] | |
| 3352 | + | pub subscriptions_micros: i64, | |
| 3353 | + | /// What AI Gateway priced g1t's own provider traffic at over the range | |
| 3354 | + | /// (Cloudflare-billed requests left out): what the providers bill, to | |
| 3355 | + | /// set beside `models_cost_micros`, the ledger's figure. | |
| 3356 | + | #[serde(default)] | |
| 3357 | + | pub gateway_cost_micros: i64, | |
| 3344 | 3358 | } | |
| 3345 | 3359 | ||
| 3346 | 3360 | /// A count, cost or leak that does not add up. | |
| ⋯ | |||
| 3543 | 3557 | /// g1t's own spend against its two caps. | |
| 3544 | 3558 | #[serde(default)] | |
| 3545 | 3559 | pub caps: SpendCaps, | |
| 3560 | + | /// Cloudflare's current billing cycle: its usage cost so far, by meter, | |
| 3561 | + | /// and where it is heading. Absent until the bill has been read. | |
| 3562 | + | #[serde(default)] | |
| 3563 | + | pub cycle: Option<CloudflareCycle>, | |
| 3564 | + | /// The last read of Cloudflare's billable usage: what came back. | |
| 3565 | + | #[serde(default)] | |
| 3566 | + | pub bill_read: Option<BillRead>, | |
| 3567 | + | /// Of the range's cost, what no workspace's usage could carry (a day | |
| 3568 | + | /// with no usage at all): running g1t, attributed to no one. The | |
| 3569 | + | /// workspaces' costs and this add up to the cost. | |
| 3570 | + | #[serde(default)] | |
| 3571 | + | pub unattributed_micros: i64, | |
| 3546 | 3572 | } | |
| 3547 | 3573 | ||
| 3574 | + | /// Cloudflare's billing cycle (monthly, from the day the account's | |
| 3575 | + | /// subscription renews), as Cloudflare's Billable usage page shows it. | |
| 3576 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 3577 | + | #[serde(rename_all = "camelCase")] | |
| 3578 | + | pub struct CloudflareCycle { | |
| 3579 | + | /// The cycle's first and last days, YYYY-MM-DD, UTC. | |
| 3580 | + | pub start: String, | |
| 3581 | + | pub end: String, | |
| 3582 | + | pub days: u32, | |
| 3583 | + | /// Days from its start to today, today included. | |
| 3584 | + | pub days_elapsed: u32, | |
| 3585 | + | /// Usage cost so far, after the included allowances. | |
| 3586 | + | pub usage_micros: i64, | |
| 3587 | + | /// `usage_micros` over the days elapsed, times the cycle's days. | |
| 3588 | + | pub projected_micros: i64, | |
| 3589 | + | pub average_daily_micros: i64, | |
| 3590 | + | /// Cloudflare's subscriptions for the cycle (not on the usage bill). | |
| 3591 | + | pub subscriptions_micros: i64, | |
| 3592 | + | pub meters: Vec<CycleMeter>, | |
| 3593 | + | } | |
| 3594 | + | ||
| 3595 | + | /// One of Cloudflare's meters over the cycle so far. | |
| 3596 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 3597 | + | #[serde(rename_all = "camelCase")] | |
| 3598 | + | pub struct CycleMeter { | |
| 3599 | + | pub product: String, | |
| 3600 | + | pub meter: String, | |
| 3601 | + | pub raw_name: String, | |
| 3602 | + | pub unit: String, | |
| 3603 | + | /// What was used. | |
| 3604 | + | pub quantity: f64, | |
| 3605 | + | /// What the cycle includes, in the same unit; None without a list price. | |
| 3606 | + | pub included: Option<f64>, | |
| 3607 | + | /// Past the included amount, as Cloudflare bills it. | |
| 3608 | + | pub billable_quantity: f64, | |
| 3609 | + | pub cost_micros: i64, | |
| 3610 | + | /// `cloudflare` (the cost Cloudflare put on its lines), `list` (the | |
| 3611 | + | /// list price past the included amount, while Cloudflare's lines carry | |
| 3612 | + | /// no cost), or `none` (no list price known: costed at $0). | |
| 3613 | + | pub basis: String, | |
| 3614 | + | } | |
| 3615 | + | ||
| 3616 | + | /// What the last read of Cloudflare's billable usage got back. | |
| 3617 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 3618 | + | #[serde(rename_all = "camelCase")] | |
| 3619 | + | pub struct BillRead { | |
| 3620 | + | pub read_at: String, | |
| 3621 | + | pub since: String, | |
| 3622 | + | pub until: String, | |
| 3623 | + | pub rows: u32, | |
| 3624 | + | pub pages: u32, | |
| 3625 | + | /// Rows with a consumed quantity (`ConsumedQuantity`), and rows with | |
| 3626 | + | /// only a pricing quantity. | |
| 3627 | + | pub consumed_rows: u32, | |
| 3628 | + | pub pricing_only_rows: u32, | |
| 3629 | + | /// Rows Cloudflare put a cost on. | |
| 3630 | + | pub costed_rows: u32, | |
| 3631 | + | } | |
| 3632 | + | ||
| 3548 | 3633 | /// What g1t itself pays for, against its caps (billing's `budget`): the | |
| 3549 | 3634 | /// daily breaker on all of it, and each comped account's monthly budget. | |
| 3550 | 3635 | /// One of Cloudflare's subscriptions, at what it comes to a month. | |
| ⋯ | |||
| 3595 | 3680 | /// Each subscription, when read from Cloudflare. | |
| 3596 | 3681 | #[serde(default)] | |
| 3597 | 3682 | pub fixed_items: Vec<FixedCost>, | |
| 3683 | + | /// Of `fixed_monthly_micros`, this calendar month's days so far: each | |
| 3684 | + | /// day's share of the billing cycle it is in, today included. | |
| 3685 | + | #[serde(default)] | |
| 3686 | + | pub fixed_month_micros: i64, | |
| 3598 | 3687 | /// Money in this month, through the last reconciled day. | |
| 3599 | 3688 | pub revenue_micros: i64, | |
| 3600 | 3689 | /// Of this month's buckets, what was spent on workspaces whose billing | |
| 2 | 2 | ||
| 3 | 3 | How g1t checks what it charges against what Cloudflare charges it, keeps | |
| 4 | 4 | prices at cost plus 20%, and tells staff when the margin slips. Internal. | |
| 5 | − | Code: `services/billing/src/costs.rs` (reading the bill), `margin.rs` | |
| 5 | + | Code: `services/billing/src/costs.rs` (reading the bill), `cycle.rs` | |
| 6 | + | (Cloudflare's billing cycle, list prices and included amounts), `margin.rs` | |
| 6 | 7 | (reconciliation, drift, alerts), `pricing.rs` (versions, proposals, | |
| 7 | 8 | notice), `keeper.rs` (sandbox and Workers for Platforms measurements), | |
| 8 | 9 | `budget.rs` (what g1t pays for itself, and its caps; see | |
| ⋯ | |||
| 19 | 20 | ||
| 20 | 21 | | Source | What | Where it lands | | |
| 21 | 22 | | --- | --- | --- | | |
| 22 | − | | Billable usage, `GET /accounts/{account}/billable-usage?from=&to=` | One row per service per day in FOCUS columns: `ServiceFamilyName`, `ServiceName`, `ChargePeriodStart`, `PricingQuantity`, `ContractedCost` / `BilledCost` / `ListCost`. Every product g1t uses appears once it is used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, Cloudflare for SaaS, Email. Inside an included amount the cost is 0. | `cost_lines`, source `billable_usage` | | |
| 23 | + | | Billable usage, `GET /accounts/{account}/billable-usage?from=&to=` | One row per service per day in FOCUS columns: `ServiceFamilyName`, `ServiceName`, `ChargePeriodStart`, `ConsumedQuantity` (else `PricingQuantity`), `ContractedCost` / `BilledCost` / `EffectiveCost`. Every page is read (`result_info`: `cursor`, else `total_pages`), over whole billing cycles (see [The billing cycle](#the-billing-cycle)). Every product g1t uses appears here once it is used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, Cloudflare for SaaS, Email. While a cycle is open its rows carry no cost; `ListCost` is never taken, since it is before the included amounts. | `cost_lines`, source `billable_usage`: `quantity` (consumed), `billed_usd` (Cloudflare's own cost), `billable_quantity` and `cost_usd` (over the cycle), `basis`; the read itself in `cost_reads` | | |
| 23 | 24 | | GraphQL `artifactsEventsAdaptiveGroups` | Artifacts' own count by `date`, `eventType` and `repositoryName`. Operations are `create`, `fork`, `push`, `pull`, `delete`; errors (`rateLimited`, `serverError`, …) are kept but not counted. | `cost_lines`, source `artifacts_events`; per workspace (from the store key `<workspace>--<repo>`; a pull request's working copy, `pulls--<id>`, is its repository's workspace's, from repos' `pull_owners`) in `own_counts` as `cloudflare_git` | | |
| 24 | 25 | | GraphQL `aiGatewayRequestsAdaptiveGroups`, filtered to `AI_GATEWAY_ID` | What AI Gateway priced g1t's own provider traffic at, by `date`, `provider` and `model`: `count`, `sum.cost` (dollars), `sum.tokensIn`/`tokensOut`/`cacheReadTokens`/`cacheWriteTokens`; asked twice in one query, filtered `wholesale: 0` and `wholesale: 1`. `wholesale` is never a dimension: grouped by it, Cloudflare answers no rows and no error (until 2026-10-08 that left the gateway's side empty while it had logged $11.11). Read over its own window: the last 31 days until it has answered with a line, then the last few. Field names checked against Cloudflare's schema (introspection of `AccountAiGatewayRequestsAdaptiveGroups{Sum,Dimensions,Filter_InputObject}`). An adaptive (sampled) dataset: an estimate, close at g1t's volumes. Only g1t's hosted models go through this gateway: a workspace's own provider is called at its own address, never here. | `cost_lines`, source `ai_gateway`, product `ai_gateway_requests`: per day and model a line `<provider>_<model>` (requests, at the gateway's cost), and at no cost `…__tokens`, `…__cache_read_tokens`, `…__cache_write_tokens`; Cloudflare-billed (unified billing) requests are prefixed `wholesale__`. Mapped to `models` (migration 0036). A re-read day replaces all its gateway lines. | | |
| 25 | 26 | | The ledger | Every charge: its cost at the price book's cost, what it was charged at price, what paid for it. | read, never written | | |
| 26 | 27 | | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` | | |
| 27 | 28 | | `plan_payments` | The plan's $20. | read | | |
| 28 | 29 | | repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` | | |
| 29 | − | | Subscriptions, `GET /accounts/{account}/subscriptions` | What g1t pays each month whatever it uses (Workers Paid, add-ons): each subscription that is paid, trialing or awaiting payment, at its price over its frequency. Not on the billable-usage bill. Read in the daily run with the bill's token; a failure is logged and the last read stays. | `cf_subscriptions` (one row) | | |
| 30 | + | | Subscriptions, `GET /accounts/{account}/subscriptions` | What g1t pays each month whatever it uses (Workers Paid, add-ons): each subscription that is paid, trialing or awaiting payment, at its price over its frequency; and `current_period_start` of the monthly one, the day every billing cycle starts. Not on the billable-usage bill. Read first in the daily run, with the bill's token; a failure is logged and the last read stays. | `cf_subscriptions` (one row; `cycle_start`) | | |
| 30 | 31 | | repos `artifacts_usage` | Every raw meter of the git store (`git.fetch`, `git.receive_pack`, `binding.*`, …) per day and workspace, with repos' `operation_mapping`. | `own_counts` meters `artifacts_<raw meter>`, and `cost_operations` (raw counts × the mapping's `cost_operations`: what g1t expects Cloudflare to bill) | | |
| 31 | 32 | ||
| 32 | 33 | A meter's slug is Cloudflare's name lower-cased with words joined by `_` | |
| ⋯ | |||
| 35 | 36 | `workers_for_platforms_cpu_ms`. Several rows of the same day and meter | |
| 36 | 37 | (regions, tiers) are added together before they are stored. | |
| 37 | 38 | ||
| 39 | + | ## The billing cycle | |
| 40 | + | ||
| 41 | + | Cloudflare bills usage per billing cycle: a month from the day the | |
| 42 | + | account's subscription renews. g1t's account renews on the 28th, so a cycle | |
| 43 | + | runs from the 28th to the 27th (Sep 28 to Oct 27, 2026). Each meter's | |
| 44 | + | included amount ("first 30M are included") is the account's, once a cycle: | |
| 45 | + | not a day's, not a calendar month's, and not per product. Code: | |
| 46 | + | `services/billing/src/cycle.rs`. | |
| 47 | + | ||
| 48 | + | - **When a cycle starts**: `current_period_start` of the monthly | |
| 49 | + | subscription, from the daily read of the subscriptions | |
| 50 | + | (`cf_subscriptions.cycle_start`). Until that has worked, | |
| 51 | + | `CLOUDFLARE_BILLING_DAY` on g1t-billing (`28`), else the 1st. | |
| 52 | + | - **What is read**: the whole current cycle, every run, and the cycle | |
| 53 | + | before while it is in its first 4 days (Cloudflare posts a day a day or | |
| 54 | + | two late and restates recent days) or when nothing has been read yet. | |
| 55 | + | Every page of the answer. The days read replace what was kept for them. | |
| 56 | + | - **What a meter costs**: where Cloudflare put a cost on any of a meter's | |
| 57 | + | lines in the cycle, that cost, line by line (`basis` `cloudflare`). | |
| 58 | + | Otherwise the list price past the included amount (`basis` `list`), on | |
| 59 | + | the days the cycle's running total passes it: a cycle that crosses the | |
| 60 | + | included amount on the 7th has all of its cost on the 7th and after, as | |
| 61 | + | on Cloudflare's Billable usage page. Meters priced per million | |
| 62 | + | (requests, CPU ms, rows, operations) are billed in whole millions: | |
| 63 | + | 9.16M CPU ms past the included 30M is 10M, $0.20. A meter with no list | |
| 64 | + | price is costed at $0 (`basis` `none`), and sudo lists it. | |
| 65 | + | - **List prices** (`cycle::LIST_PRICES`), each with its included amount | |
| 66 | + | per cycle: | |
| 67 | + | ||
| 68 | + | | Meter | Included | Price | | |
| 69 | + | | --- | --- | --- | | |
| 70 | + | | Workers standard requests | 10M | $0.30 per million | | |
| 71 | + | | Workers CPU ms | 30M | $0.02 per million | | |
| 72 | + | | Workers for Platforms requests | 20M | $0.30 per million | | |
| 73 | + | | Workers for Platforms CPU ms | 60M | $0.02 per million | | |
| 74 | + | | Workers for Platforms scripts | 1,000 | $0.02 each | | |
| 75 | + | | Workers Logs events | 20M | $0.60 per million | | |
| 76 | + | | D1 rows read / written | 25B / 50M | $0.001 / $1.00 per million | | |
| 77 | + | | D1 storage | 5 GB-month | $0.75 per GB-month | | |
| 78 | + | | KV reads / writes, lists, deletes | 10M / 1M each | $0.50 / $5.00 per million | | |
| 79 | + | | KV storage | 1 GB | $0.50 per GB-month | | |
| 80 | + | | R2 Class A / Class B operations | 1M / 10M | $4.50 / $0.36 per million | | |
| 81 | + | | Durable Objects requests | 1M | $0.15 per million | | |
| 82 | + | | Durable Objects duration | 400,000 GB-s | $12.50 per million GB-s | | |
| 83 | + | | Durable Objects SQL rows read / written | 25B / 50M | $0.001 / $1.00 per million | | |
| 84 | + | | Durable Objects SQL storage | 5 GB-month | $0.20 per GB-month | | |
| 85 | + | | Queues operations | 1M | $0.40 per million | | |
| 86 | + | | Containers memory | 25 GiB-hours (90,000 GiB-s) | $0.0000025 per GiB-second | | |
| 87 | + | | Containers vCPU | 375 vCPU-minutes (22,500 vCPU-s) | $0.000020 per vCPU-second | | |
| 88 | + | | Containers disk | 200 GB-hours (720,000 GB-s) | $0.00000007 per GB-second | | |
| 89 | + | | Containers egress, North America and Europe / elsewhere | 1 TB / 500 GB | $0.025 / $0.04 per GB | | |
| 90 | + | | Vectorize queried / stored dimensions | 50M / 10M | $0.01 per million / $0.05 per 100 million | | |
| 91 | + | | Workers AI neurons | 10,000 a day | $0.011 per 1,000 | | |
| 92 | + | ||
| 93 | + | Not priced yet, so $0 until Cloudflare's lines carry a cost: Artifacts | |
| 94 | + | (billed from 2026-10-14), Email Service, Browser Rendering, R2 storage, | |
| 95 | + | Cloudflare for SaaS. When Cloudflare changes a price or an included | |
| 96 | + | amount, change the table and its test in the same pull request. | |
| 97 | + | - **Projection**: the cycle's cost so far over the days elapsed, times the | |
| 98 | + | cycle's days, as Cloudflare's page projects it ($0.29 over 12 days of a | |
| 99 | + | 30-day cycle is $0.73). | |
| 100 | + | - **Subscriptions** (Workers Paid and Workers for Platforms, $30 a month) | |
| 101 | + | accrue day by day: each day is its cycle's share of the month's price. | |
| 102 | + | The statement's range and g1t's own spend (the calendar month so far) both | |
| 103 | + | add up the same days, so they never disagree about a day. | |
| 104 | + | ||
| 105 | + | Sudo's Bill & pricing page opens with **This billing cycle**: each meter's | |
| 106 | + | use, what the cycle includes, what is past it and its cost, the total so | |
| 107 | + | far, the average day, the projection and the subscriptions. Under it, what | |
| 108 | + | the last read got back (`cost_reads`): rows, pages, how many rows had a | |
| 109 | + | consumed quantity and how many only a pricing quantity, and how many carried | |
| 110 | + | Cloudflare's own cost. | |
| 111 | + | ||
| 112 | + | To check the figures against Cloudflare: in the dashboard, Billing → | |
| 113 | + | Billable usage, for the same cycle. The total so far, the projection and | |
| 114 | + | each meter's total and billable quantity should agree within a cent. If | |
| 115 | + | they do not, read the answer itself with a token with Billing Read: | |
| 116 | + | `GET https://api.cloudflare.com/client/v4/accounts/{account}/billable-usage?from=<cycle start>&to=<today>`, | |
| 117 | + | and compare, for `Workers CPU ms` and `Container Memory`, the | |
| 118 | + | `ConsumedQuantity`, `PricingQuantity`, `PricingUnit` and the cost columns, | |
| 119 | + | and `result_info` for more pages. | |
| 120 | + | ||
| 38 | 121 | ## Credentials | |
| 39 | 122 | ||
| 40 | 123 | | Secret on g1t-billing | Permissions | Used for | | |
| ⋯ | |||
| 61 | 144 | 1. The keeper's measurements (sandbox seconds, app requests and CPU), each | |
| 62 | 145 | a proposal now, not a direct change. | |
| 63 | 146 | 2. `costs_daily`: | |
| 64 | − | 1. Read the bill, the Artifacts events and AI Gateway's analytics. The first run reads the last | |
| 65 | − | 31 days (GraphQL keeps 31); later runs the last 4, since Cloudflare | |
| 66 | − | restates recent days, or back to the last day read after a gap. | |
| 67 | − | Lines are upserted on `(day, source, product, meter)`, so a re-read | |
| 68 | − | replaces, never adds. | |
| 147 | + | 1. Read Cloudflare's subscriptions (and with them when the billing cycle | |
| 148 | + | starts), then the bill, the Artifacts events and AI Gateway's | |
| 149 | + | analytics. The bill is read over whole billing cycles, every page | |
| 150 | + | (see [The billing cycle](#the-billing-cycle)), and priced over each | |
| 151 | + | cycle; its days replace what was kept for them. The Artifacts events | |
| 152 | + | and the gateway: the first run reads the last 31 days (GraphQL keeps | |
| 153 | + | 31); later runs the last 4, since Cloudflare restates recent days, or | |
| 154 | + | back to the last day read after a gap. Lines are upserted on | |
| 155 | + | `(day, source, product, meter)`, so a re-read replaces, never adds. | |
| 69 | 156 | 2. g1t's own counts for the same days (replaced per day). | |
| 70 | 157 | 3. Snapshot `pending_usage` into `pending_days`. | |
| 71 | − | 4. Reconcile the last 31 days (further back after a gap) into | |
| 158 | + | 4. Reconcile the last 31 days (further back after a gap, or to the | |
| 159 | + | first day of the bill's read) into | |
| 72 | 160 | `margin_days` and `workspace_costs` | |
| 73 | 161 | (replaced per day). | |
| 74 | 162 | 5. Drift over the last 7 days into `cost_drift`. | |
| ⋯ | |||
| 103 | 191 | ||
| 104 | 192 | For each day and bucket: | |
| 105 | 193 | ||
| 106 | − | - **Cloudflare cost** = Σ the bucket's lines' cost, as billed: after the | |
| 107 | − | included allowances, so a month inside them costs $0 here as on | |
| 108 | − | Cloudflare's Billable usage page. `models` uses the ledger's cost of the | |
| 194 | + | - **Cloudflare cost** = Σ the bucket's lines' cost, after the included | |
| 195 | + | amounts over the billing cycle (see [The billing cycle](#the-billing-cycle)), | |
| 196 | + | so a cycle inside them costs $0 here as on Cloudflare's Billable usage | |
| 197 | + | page, and the day its total passes one carries the cost. `models` uses the ledger's cost of the | |
| 109 | 198 | tokens instead; that is paid to the model providers and is not on | |
| 110 | 199 | Cloudflare's bill. Its "Cloudflare" column is what AI Gateway priced the | |
| 111 | 200 | same traffic at, which drift compares with the ledger (below); it is | |
| ⋯ | |||
| 116 | 205 | - **Value** = what customers were charged at price: `-amount_micros` plus | |
| 117 | 206 | what the plan's included usage, a trial, the open-source pool or g1t paid. | |
| 118 | 207 | g1t's own (comped) workspaces are valued at cost plus the margin. | |
| 119 | − | - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price. | |
| 120 | − | Never tax or card fees: a payment credits the balance, and | |
| 121 | − | `plan_payments`, without them (see [Tax and the card fee](#tax-and-the-card-fee)). | |
| 208 | + | - **Cash** = what workspaces paid with real money: `-amount_micros`, and | |
| 209 | + | the plan's price, from the day payments went live (see "charged without | |
| 210 | + | real money" below). Never tax or card fees: a payment credits the | |
| 211 | + | balance, and `plan_payments`, without them (see [Tax and the card fee](#tax-and-the-card-fee)). | |
| 122 | 212 | - **Given away** = the part of the cost that went on usage g1t paid for | |
| 123 | 213 | itself on purpose, by why: | |
| 124 | 214 | - **comped**: all of a comped workspace's cost, every bucket; | |
| ⋯ | |||
| 141 | 231 | 0046). The model calls and Cloudflare usage still happened, so the | |
| 142 | 232 | reconciliation reads the kept rows back as that workspace's usage on | |
| 143 | 233 | their days: valued as before, no cash, all of it given. See | |
| 144 | − | [Resetting a test workspace](#resetting-a-test-workspace). | |
| 234 | + | [Resetting a test workspace](#resetting-a-test-workspace); | |
| 235 | + | - **charged without real money**: what workspaces were charged, and | |
| 236 | + | plans paid, while payments were not live (Stripe's test mode), or | |
| 237 | + | before the day they went live (`cost_settings.payments_live_since`, | |
| 238 | + | kept the first time the run sees live payments). It brought in no | |
| 239 | + | money, so it is taken out of cash and given (`given_unpaid_micros`, | |
| 240 | + | migration 0052): never money in, never margin, never what a workspace | |
| 241 | + | paid. The plan's included usage counts as money in only from that day | |
| 242 | + | too. | |
| 145 | 243 | ||
| 146 | 244 | Otherwise a workspace's day is split by those shares of its value at | |
| 147 | 245 | price, and the same shares of each of its buckets' cost are given, its | |
| ⋯ | |||
| 163 | 261 | sits near 16.7%. | |
| 164 | 262 | - **Running g1t**: the plan's price against `platform` less its given | |
| 165 | 263 | share. | |
| 166 | − | - **Cloudflare subscriptions**: what Cloudflare lists, a month, over | |
| 167 | − | the range (`cf_subscriptions`); until a read has worked, | |
| 168 | − | `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate. | |
| 264 | + | - **Cloudflare subscriptions**: what Cloudflare lists, a month | |
| 265 | + | (`cf_subscriptions`), accrued over the range day by day as each day's | |
| 266 | + | share of its billing cycle (`OverallMargin.subscriptions_micros`); | |
| 267 | + | until a read has worked, `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an | |
| 268 | + | estimate. | |
| 169 | 269 | - **Not mapped**: billed, charged for by nothing. | |
| 170 | 270 | - **Given away**: by why. A budget, watched under g1t's own spend, never | |
| 171 | 271 | shown as a loss. | |
| 172 | 272 | - **All in**: money in against all of it, with the figure without what | |
| 173 | 273 | was given beside it. **Who g1t paid** is All in's cost, split into | |
| 174 | 274 | Cloudflare's usage, Cloudflare's subscriptions over the range, and the | |
| 175 | − | model providers. | |
| 275 | + | model providers (the ledger's cost); where AI Gateway priced g1t's own | |
| 276 | + | provider traffic at more than a cent apart from that | |
| 277 | + | (`OverallMargin.gateway_cost_micros`, Cloudflare-billed requests left | |
| 278 | + | out), it says so beside it, and the `models` drift says why. | |
| 176 | 279 | ||
| 177 | 280 | The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash. | |
| 178 | 281 | - **Quantities**: where a mapping names an `own_meter`, Cloudflare's | |
| ⋯ | |||
| 186 | 289 | each was charged for it. `platform` | |
| 187 | 290 | and `unmapped` are shared by each workspace's share of all usage that | |
| 188 | 291 | day. Shares are whole micros that add up to the bill exactly (largest | |
| 189 | − | remainder). | |
| 292 | + | remainder). On a day no workspace used anything, running g1t has no one to | |
| 293 | + | share it: it stays no one's, and sudo says how much under **Workspaces that | |
| 294 | + | cost most** (`CostsReport.unattributed_micros`), so the workspaces' costs | |
| 295 | + | and it add up to the statement's cost. | |
| 190 | 296 | ||
| 191 | 297 | ## Drift (last 7 days) | |
| 192 | 298 | ||
| ⋯ | |||
| 789 | 895 | `margin_days.given_credit_{promotional,goodwill}_micros`, and backfills | |
| 790 | 896 | earlier credits (see [Credits from g1t](#credits-from-g1t)). Migration | |
| 791 | 897 | `0046_reset_costs.sql` adds `reset_costs` and `margin_days.given_reset_micros` | |
| 792 | − | (see [Resetting a test workspace](#resetting-a-test-workspace)). | |
| 898 | + | (see [Resetting a test workspace](#resetting-a-test-workspace)). Migration | |
| 899 | + | `0052_cloudflare_cycle.sql` adds `cost_lines.billed_usd`, | |
| 900 | + | `billable_quantity` and `basis`, `cf_subscriptions.cycle_start`, | |
| 901 | + | `cost_reads`, `margin_days.given_unpaid_micros` and the | |
| 902 | + | `payments_live_since` setting (see [The billing cycle](#the-billing-cycle)). | |
| 793 | 903 | ||
| 794 | 904 | ## Spend caps | |
| 795 | 905 | ||
| ⋯ | |||
| 825 | 935 | so far, today included, counted as each charge settled: the model | |
| 826 | 936 | providers' cost and the price book's cost of sandboxes and builds. The | |
| 827 | 937 | statement is the range, reconciled against Cloudflare's bill, where | |
| 828 | − | sandboxes inside Cloudflare's included usage cost $0. The section shows | |
| 829 | − | Cloudflare's subscriptions for the whole month, the statement over the | |
| 830 | − | range. And `g1t_spend` is never wiped: spend on a workspace a testing reset | |
| 938 | + | sandboxes inside Cloudflare's included usage cost $0. Both count | |
| 939 | + | Cloudflare's subscriptions the same way: each day its billing cycle's share | |
| 940 | + | of the month's price (`SpendCaps.fixed_month_micros` here, the month's days | |
| 941 | + | so far). And `g1t_spend` is never wiped: spend on a workspace a testing reset | |
| 831 | 942 | wiped later stays here (sudo names it, `SpendCaps.reset_micros`), while the | |
| 832 | 943 | statement has it only where the reset kept it (`reset_costs`, given away as | |
| 833 | 944 | testing resets). syntaqx's $7.41 of 2026-10-02 to 10-05, reset on 10-07 | |
| ⋯ | |||
| 841 | 952 | | The daily breaker | `PLATFORM_DAILY_SPEND_CAP_MICROS` | $75 a day (UTC) | New agent runs on g1t's hosted models that g1t would pay for are refused until 00:00 UTC. Not paused: agents on the workspace's own model provider, checks and builds, and workspaces paying with live payments on the plan (not given by staff) or an enterprise contract. In test mode that exemption covers no one. | | |
| 842 | 953 | ||
| 843 | 954 | `0` turns either off. Cloudflare's subscriptions are read from Cloudflare | |
| 844 | − | each day (`cf_subscriptions`) and shown on the page only; | |
| 845 | − | `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works. | |
| 955 | + | each day (`cf_subscriptions`) and shown on the page only, accrued day by | |
| 956 | + | day; `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works. | |
| 846 | 957 | ||
| 847 | 958 | The checks are cheap: `reserve` reads today's total (one indexed sum) and, | |
| 848 | 959 | for a comped account, its month's comped rows. Refusals come back as | |
| 1539 | 1539 | taxCollectedMicros?: number; | |
| 1540 | 1540 | /** Card processing fees passed on with card payments, net of refunds: they pay Stripe's fee, not revenue. */ | |
| 1541 | 1541 | cardFeesMicros?: number; | |
| 1542 | + | /** What workspaces were charged while payments were not live (Stripe's test mode): given, never money in. */ | |
| 1543 | + | givenUnpaidMicros?: number; | |
| 1544 | + | /** Cloudflare's subscriptions over the range: each day's share of the billing cycle it is in. */ | |
| 1545 | + | subscriptionsMicros?: number; | |
| 1546 | + | /** What AI Gateway priced g1t's own provider traffic at over the range, beside the ledger's model cost. */ | |
| 1547 | + | gatewayCostMicros?: number; | |
| 1542 | 1548 | }; | |
| 1543 | 1549 | ||
| 1544 | 1550 | /** A count, cost or leak that does not add up. */ | |
| ⋯ | |||
| 1680 | 1686 | settings: CostSettings; | |
| 1681 | 1687 | /** g1t's own spend against its two caps. */ | |
| 1682 | 1688 | caps: SpendCaps; | |
| 1689 | + | /** Cloudflare's current billing cycle: usage cost so far by meter, and the projection. Absent until the bill is read. */ | |
| 1690 | + | cycle?: CloudflareCycle | null; | |
| 1691 | + | /** The last read of Cloudflare's billable usage: what came back. */ | |
| 1692 | + | billRead?: BillRead | null; | |
| 1693 | + | /** Of the range's cost, what no workspace's usage could carry: running g1t, attributed to no one. */ | |
| 1694 | + | unattributedMicros?: number; | |
| 1695 | + | }; | |
| 1696 | + | ||
| 1697 | + | /** Cloudflare's billing cycle, as its Billable usage page shows it. */ | |
| 1698 | + | export type CloudflareCycle = { | |
| 1699 | + | /** First and last days, YYYY-MM-DD, UTC. */ | |
| 1700 | + | start: string; | |
| 1701 | + | end: string; | |
| 1702 | + | days: number; | |
| 1703 | + | /** Days from its start to today, today included. */ | |
| 1704 | + | daysElapsed: number; | |
| 1705 | + | /** Usage cost so far, after the included allowances. */ | |
| 1706 | + | usageMicros: number; | |
| 1707 | + | /** usageMicros over the days elapsed, times the cycle's days. */ | |
| 1708 | + | projectedMicros: number; | |
| 1709 | + | averageDailyMicros: number; | |
| 1710 | + | /** Cloudflare's subscriptions for the cycle (not on the usage bill). */ | |
| 1711 | + | subscriptionsMicros: number; | |
| 1712 | + | meters: CycleMeter[]; | |
| 1713 | + | }; | |
| 1714 | + | ||
| 1715 | + | /** One of Cloudflare's meters over the cycle so far. */ | |
| 1716 | + | export type CycleMeter = { | |
| 1717 | + | product: string; | |
| 1718 | + | meter: string; | |
| 1719 | + | rawName: string; | |
| 1720 | + | unit: string; | |
| 1721 | + | quantity: number; | |
| 1722 | + | /** What the cycle includes; null without a list price. */ | |
| 1723 | + | included: number | null; | |
| 1724 | + | billableQuantity: number; | |
| 1725 | + | costMicros: number; | |
| 1726 | + | /** cloudflare: Cloudflare's own cost; list: the list price past the included amount; none: no list price known. */ | |
| 1727 | + | basis: "cloudflare" | "list" | "none" | string; | |
| 1683 | 1728 | }; | |
| 1684 | 1729 | ||
| 1730 | + | /** What the last read of Cloudflare's billable usage got back. */ | |
| 1731 | + | export type BillRead = { | |
| 1732 | + | readAt: string; | |
| 1733 | + | since: string; | |
| 1734 | + | until: string; | |
| 1735 | + | rows: number; | |
| 1736 | + | pages: number; | |
| 1737 | + | consumedRows: number; | |
| 1738 | + | pricingOnlyRows: number; | |
| 1739 | + | costedRows: number; | |
| 1740 | + | }; | |
| 1741 | + | ||
| 1685 | 1742 | /** One level of the platform pause, as sudo shows it. Snake case, as billing sends it. */ | |
| 1686 | 1743 | export type PauseState = { | |
| 1687 | 1744 | level: PauseLevel; | |
| ⋯ | |||
| 1769 | 1826 | fixedReadAt?: string | null; | |
| 1770 | 1827 | /** Each subscription, when read from Cloudflare. */ | |
| 1771 | 1828 | fixedItems?: { name: string; monthlyMicros: number }[]; | |
| 1829 | + | /** Of fixedMonthlyMicros, this calendar month's days so far, each at its billing cycle's daily share. */ | |
| 1830 | + | fixedMonthMicros?: number; | |
| 1772 | 1831 | /** Money in this month, through the last reconciled day. */ | |
| 1773 | 1832 | revenueMicros: number; | |
| 1774 | 1833 | /** | |
| 1 | + | -- Cloudflare's usage bill priced over its billing cycle, and money in only | |
| 2 | + | -- when it is real. See src/cycle.rs, src/costs.rs and | |
| 3 | + | -- docs/BILLING_OPERATIONS.md ("The billing cycle"). | |
| 4 | + | ||
| 5 | + | -- What Cloudflare's line itself said it cost (never its list cost, which | |
| 6 | + | -- is before the included amounts), what of the quantity is past the | |
| 7 | + | -- cycle's included amount, and where cost_usd came from: 'cloudflare', | |
| 8 | + | -- 'list' (the list price past the included amount) or 'none' (no list | |
| 9 | + | -- price known). Empty for lines that are not billable usage. | |
| 10 | + | ALTER TABLE cost_lines ADD COLUMN billed_usd REAL NOT NULL DEFAULT 0; | |
| 11 | + | ALTER TABLE cost_lines ADD COLUMN billable_quantity REAL NOT NULL DEFAULT 0; | |
| 12 | + | ALTER TABLE cost_lines ADD COLUMN basis TEXT NOT NULL DEFAULT ''; | |
| 13 | + | ||
| 14 | + | -- When the current billing cycle started, from the subscriptions' read | |
| 15 | + | -- (current_period_start): the day of the month every cycle starts on. | |
| 16 | + | ALTER TABLE cf_subscriptions ADD COLUMN cycle_start TEXT; | |
| 17 | + | ||
| 18 | + | -- The last read of each source: what came back, to tell from sudo whether | |
| 19 | + | -- it was all of it and in which units. | |
| 20 | + | CREATE TABLE IF NOT EXISTS cost_reads ( | |
| 21 | + | source TEXT PRIMARY KEY, | |
| 22 | + | read_at TEXT NOT NULL, | |
| 23 | + | since TEXT NOT NULL, | |
| 24 | + | until TEXT NOT NULL, | |
| 25 | + | rows INTEGER NOT NULL, | |
| 26 | + | pages INTEGER NOT NULL, | |
| 27 | + | consumed_rows INTEGER NOT NULL, | |
| 28 | + | pricing_only_rows INTEGER NOT NULL, | |
| 29 | + | costed_rows INTEGER NOT NULL | |
| 30 | + | ); | |
| 31 | + | ||
| 32 | + | -- What workspaces were charged while payments were not live (Stripe's | |
| 33 | + | -- test mode): given away, never money in. | |
| 34 | + | ALTER TABLE margin_days ADD COLUMN given_unpaid_micros INTEGER NOT NULL DEFAULT 0; | |
| 35 | + | ||
| 36 | + | -- The day payments went live: charges before it were not real money. | |
| 37 | + | -- Empty until the daily run first sees live payments. | |
| 38 | + | INSERT OR IGNORE INTO cost_settings (key, value, updated_at, updated_by) VALUES | |
| 39 | + | ('payments_live_since', '', '2026-10-09T00:00:00Z', 'migration'); |
| 519 | 519 | let reset_micros = reset.iter().filter_map(|r| r.micros).sum(); | |
| 520 | 520 | let reset_workspaces = reset.into_iter().map(|r| r.account.strip_prefix("ws_").unwrap_or(&r.account).to_owned()).collect(); | |
| 521 | 521 | let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?; | |
| 522 | + | // This month's days so far, each its billing cycle's share: the | |
| 523 | + | // same accrual as the statement's range (`cycle::accrued`). | |
| 524 | + | let fixed_month_micros = crate::cycle::accrued(fixed.monthly_micros, &month_start, &day, self.cycle_anchor().await?); | |
| 522 | 525 | Ok(SpendCaps { | |
| 523 | 526 | reset_micros, | |
| 524 | 527 | reset_workspaces, | |
| ⋯ | |||
| 538 | 541 | fixed_source: fixed.source.into(), | |
| 539 | 542 | fixed_read_at: fixed.read_at, | |
| 540 | 543 | fixed_items: fixed.items, | |
| 544 | + | fixed_month_micros, | |
| 541 | 545 | revenue_micros, | |
| 542 | 546 | }) | |
| 543 | 547 | } | |
| 20 | 20 | //! what g1t counted (`margin`). | |
| 21 | 21 | //! | |
| 22 | 22 | //! Each becomes cost lines in `cost_lines`, one per (day, source, | |
| 23 | − | //! product, meter), upserted, so reading a day again replaces it. The | |
| 24 | − | //! first run reads the last 31 days; after that the last few, since | |
| 25 | − | //! Cloudflare restates recent days as usage settles. | |
| 23 | + | //! product, meter), upserted, so reading a day again replaces it. The bill | |
| 24 | + | //! is read over whole billing cycles, every page of it, and priced over | |
| 25 | + | //! each cycle (`cycle`): its included amounts are the account's, once a | |
| 26 | + | //! cycle. The Artifacts events: the first run reads the last 31 days; after | |
| 27 | + | //! that the last few, since Cloudflare restates recent days as usage | |
| 28 | + | //! settles. | |
| 26 | 29 | //! | |
| 27 | 30 | //! The token is `CLOUDFLARE_BILLING_TOKEN` (Account: Billing Read and | |
| 28 | 31 | //! Account Analytics Read), or the keeper's `CLOUDFLARE_USAGE_TOKEN`, | |
| ⋯ | |||
| 52 | 55 | pub(crate) const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 53 | 56 | ||
| 54 | 57 | /// One day of one meter of one Cloudflare product. | |
| 55 | − | #[derive(Clone, Debug, PartialEq)] | |
| 58 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 56 | 59 | pub(crate) struct CostLine { | |
| 57 | 60 | /// YYYY-MM-DD, UTC. | |
| 58 | 61 | pub day: String, | |
| ⋯ | |||
| 65 | 68 | pub meter: String, | |
| 66 | 69 | pub unit: String, | |
| 67 | 70 | pub quantity: f64, | |
| 68 | − | /// What g1t pays, in dollars: contracted, billed, or list. | |
| 71 | + | /// What g1t pays, in dollars: for billable usage, set over the billing | |
| 72 | + | /// cycle by `cycle::price_lines` (Cloudflare's own cost where its lines | |
| 73 | + | /// carry one, else the list price past the included amount). | |
| 69 | 74 | pub cost_usd: f64, | |
| 70 | 75 | /// The name as Cloudflare gave it, for people. | |
| 71 | 76 | pub raw_name: String, | |
| 77 | + | /// What Cloudflare's line itself said it cost (contracted, billed or | |
| 78 | + | /// effective; never list, which is before the included amounts). | |
| 79 | + | pub billed_usd: f64, | |
| 80 | + | /// Of `quantity`, what is past the cycle's included amount. | |
| 81 | + | pub billable_quantity: f64, | |
| 82 | + | /// Where `cost_usd` came from: `cycle::BASIS_*`, or empty for lines | |
| 83 | + | /// that are not billable usage. | |
| 84 | + | pub basis: &'static str, | |
| 72 | 85 | } | |
| 73 | 86 | ||
| 74 | 87 | /// `Workers for Platforms CPU ms (First 60M ms are included)` → | |
| ⋯ | |||
| 126 | 139 | .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.trim().parse().ok()))) | |
| 127 | 140 | } | |
| 128 | 141 | ||
| 142 | + | /// FOCUS's consumed quantity: what was used, in the meter's own units. | |
| 143 | + | const CONSUMED: [&str; 2] = ["ConsumedQuantity", "consumed_quantity"]; | |
| 144 | + | /// FOCUS's pricing quantity: in pricing units, which can be blocks. | |
| 145 | + | const PRICING: [&str; 2] = ["PricingQuantity", "pricing_quantity"]; | |
| 146 | + | /// What a line cost g1t, as Cloudflare says: contracted, billed, effective. | |
| 147 | + | /// Never `ListCost`: that is before the included amounts. | |
| 148 | + | const COSTS: [&[&str]; 3] = [&["ContractedCost", "contracted_cost"], &["BilledCost", "billed_cost"], &["EffectiveCost", "effective_cost"]]; | |
| 149 | + | ||
| 150 | + | fn billed(row: &Value) -> f64 { | |
| 151 | + | COSTS.iter().find_map(|keys| number(row, keys).filter(|c| *c > 0.0)).unwrap_or(0.0) | |
| 152 | + | } | |
| 153 | + | ||
| 129 | 154 | /// One row of billable usage, read leniently: the API is new, and its | |
| 130 | 155 | /// field names are FOCUS's (in either case). None without a service or a | |
| 131 | − | /// day. | |
| 156 | + | /// day. The quantity is what was consumed (the included amounts are applied | |
| 157 | + | /// to it over the cycle, `cycle`), else the pricing quantity. | |
| 132 | 158 | pub(crate) fn line_from_focus(row: &Value) -> Option<CostLine> { | |
| 133 | 159 | let service = text(row, &["ServiceName", "service_name", "service"]); | |
| 134 | 160 | let day = text(row, &["ChargePeriodStart", "charge_period_start", "UsageDate", "date"]); | |
| ⋯ | |||
| 137 | 163 | } | |
| 138 | 164 | let family = text(row, &["ServiceFamilyName", "service_family_name", "ServiceCategory"]); | |
| 139 | 165 | let (product, meter) = product_and_meter(family, service); | |
| 140 | − | // What g1t pays: contracted, else billed, else list. | |
| 141 | − | let cost = [ | |
| 142 | − | &["ContractedCost", "contracted_cost"][..], | |
| 143 | − | &["BilledCost", "billed_cost"][..], | |
| 144 | − | &["EffectiveCost", "effective_cost"][..], | |
| 145 | − | &["ListCost", "list_cost"][..], | |
| 146 | − | ] | |
| 147 | − | .iter() | |
| 148 | − | .find_map(|keys| number(row, keys).filter(|c| *c > 0.0)) | |
| 149 | − | .unwrap_or(0.0); | |
| 166 | + | let consumed = number(row, &CONSUMED); | |
| 167 | + | let billed = billed(row); | |
| 150 | 168 | Some(CostLine { | |
| 151 | 169 | day: day[..10].to_owned(), | |
| 152 | 170 | source: SOURCE_BILLABLE, | |
| 153 | 171 | product, | |
| 154 | 172 | meter, | |
| 155 | − | unit: text(row, &["PricingUnit", "pricing_unit", "ConsumedUnit", "consumed_unit"]).to_owned(), | |
| 156 | − | quantity: number(row, &["PricingQuantity", "pricing_quantity", "ConsumedQuantity", "consumed_quantity"]).unwrap_or(0.0), | |
| 157 | − | cost_usd: cost, | |
| 173 | + | unit: if consumed.is_some() { | |
| 174 | + | text(row, &["ConsumedUnit", "consumed_unit", "PricingUnit", "pricing_unit"]) | |
| 175 | + | } else { | |
| 176 | + | text(row, &["PricingUnit", "pricing_unit", "ConsumedUnit", "consumed_unit"]) | |
| 177 | + | } | |
| 178 | + | .to_owned(), | |
| 179 | + | quantity: consumed.or_else(|| number(row, &PRICING)).unwrap_or(0.0), | |
| 180 | + | cost_usd: billed, | |
| 158 | 181 | raw_name: if family.is_empty() { service.to_owned() } else { format!("{family} / {service}") }, | |
| 182 | + | billed_usd: billed, | |
| 183 | + | ..CostLine::default() | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// What a read of billable usage got back, to tell from sudo whether it | |
| 188 | + | /// was all of it and in which units. | |
| 189 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 190 | + | pub(crate) struct ReadStats { | |
| 191 | + | pub rows: u32, | |
| 192 | + | pub pages: u32, | |
| 193 | + | pub consumed_rows: u32, | |
| 194 | + | pub pricing_only_rows: u32, | |
| 195 | + | pub costed_rows: u32, | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | pub(crate) fn read_stats(body: &Value, pages: u32) -> ReadStats { | |
| 199 | + | let rows = body["result"].as_array().cloned().unwrap_or_default(); | |
| 200 | + | let mut stats = ReadStats { rows: rows.len() as u32, pages, ..ReadStats::default() }; | |
| 201 | + | for row in &rows { | |
| 202 | + | if number(row, &CONSUMED).is_some() { | |
| 203 | + | stats.consumed_rows += 1; | |
| 204 | + | } else if number(row, &PRICING).is_some() { | |
| 205 | + | stats.pricing_only_rows += 1; | |
| 206 | + | } | |
| 207 | + | if billed(row) > 0.0 { | |
| 208 | + | stats.costed_rows += 1; | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | stats | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// The query for the page after `page` of a v4 answer: by cursor where it | |
| 215 | + | /// gives one, else by page number while `total_pages` says there are more. | |
| 216 | + | /// None when that was the last. | |
| 217 | + | pub(crate) fn next_page(body: &Value, page: u32) -> Option<String> { | |
| 218 | + | let info = &body["result_info"]; | |
| 219 | + | if body["result"].as_array().is_some_and(Vec::is_empty) { | |
| 220 | + | return None; | |
| 221 | + | } | |
| 222 | + | if let Some(cursor) = info["cursor"].as_str().filter(|c| !c.is_empty()) { | |
| 223 | + | let encoded: String = cursor | |
| 224 | + | .bytes() | |
| 225 | + | .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) | |
| 226 | + | .collect(); | |
| 227 | + | return Some(format!("cursor={encoded}")); | |
| 228 | + | } | |
| 229 | + | let total = info["total_pages"].as_u64().or_else(|| { | |
| 230 | + | let (count, per) = (info["total_count"].as_u64()?, info["per_page"].as_u64().filter(|p| *p > 0)?); | |
| 231 | + | Some(count.div_ceil(per)) | |
| 232 | + | })?; | |
| 233 | + | (u64::from(page) < total).then(|| match info["per_page"].as_u64() { | |
| 234 | + | Some(per) => format!("page={}&per_page={per}", page + 1), | |
| 235 | + | None => format!("page={}", page + 1), | |
| 159 | 236 | }) | |
| 160 | 237 | } | |
| 161 | 238 | ||
| ⋯ | |||
| 172 | 249 | Some(existing) => { | |
| 173 | 250 | existing.quantity += line.quantity; | |
| 174 | 251 | existing.cost_usd += line.cost_usd; | |
| 252 | + | existing.billed_usd += line.billed_usd; | |
| 253 | + | existing.billable_quantity += line.billable_quantity; | |
| 175 | 254 | } | |
| 176 | 255 | None => out.push(line), | |
| 177 | 256 | } | |
| ⋯ | |||
| 232 | 311 | quantity: g["count"].as_f64().unwrap_or(0.0), | |
| 233 | 312 | cost_usd: 0.0, | |
| 234 | 313 | raw_name: format!("Artifacts / {kind}"), | |
| 314 | + | ..CostLine::default() | |
| 235 | 315 | }) | |
| 236 | 316 | }) | |
| 237 | 317 | .collect(), | |
| ⋯ | |||
| 310 | 390 | quantity, | |
| 311 | 391 | cost_usd, | |
| 312 | 392 | raw_name: format!("AI Gateway / {provider} / {model}{}", if wholesale { " (billed by Cloudflare)" } else { "" }), | |
| 393 | + | ..CostLine::default() | |
| 313 | 394 | }; | |
| 314 | 395 | lines.push(line(name.clone(), "requests", g["count"].as_f64().unwrap_or(0.0), sum("cost").max(0.0))); | |
| 315 | 396 | lines.push(line(format!("{name}{GATEWAY_TOKENS}"), "tokens", sum("tokensIn") + sum("tokensOut"), 0.0)); | |
| ⋯ | |||
| 575 | 656 | } | |
| 576 | 657 | ||
| 577 | 658 | impl Billing { | |
| 578 | − | /// Reads Cloudflare's bill for the days due (see `window`) into | |
| 579 | − | /// `cost_lines`: the days read and how many lines. None without a | |
| 580 | − | /// token. What could not be read is added to `problems`, and what AI | |
| 581 | − | /// Gateway's analytics answered to `gateway`. | |
| 659 | + | /// Reads Cloudflare's bill into `cost_lines`: the billable usage over | |
| 660 | + | /// whole billing cycles (`cycle::bill_since`), every page of it, priced | |
| 661 | + | /// over each cycle (`cycle::price_lines`) and replacing what was kept | |
| 662 | + | /// for those days; Artifacts' events and AI Gateway's analytics for the | |
| 663 | + | /// days due (`window`). Returns those days, how many lines, and the | |
| 664 | + | /// first day of the bill's read. None without a token. What could not be | |
| 665 | + | /// read is added to `problems`, and what AI Gateway's analytics answered | |
| 666 | + | /// to `gateway`. | |
| 582 | 667 | pub(crate) async fn read_cloudflare( | |
| 583 | 668 | &self, | |
| 584 | 669 | keeper: &Keeper, | |
| 585 | 670 | problems: &mut Vec<String>, | |
| 586 | 671 | gateway: &mut GatewayRead, | |
| 587 | − | ) -> Result<Option<(String, String, u32)>> { | |
| 672 | + | ) -> Result<Option<(String, String, u32, String)>> { | |
| 588 | 673 | if !keeper.can_read_bill() { | |
| 589 | 674 | return Ok(None); | |
| 590 | 675 | } | |
| ⋯ | |||
| 602 | 687 | let (since, until) = window(last.as_deref(), now_ms()); | |
| 603 | 688 | let fetched_at = rfc3339(now_ms()); | |
| 604 | 689 | let mut written = 0; | |
| 605 | − | match keeper.billable_usage_body(&since, &until).await.map_err(|e| e.to_string()).and_then(|b| lines_from_billable(&b)) { | |
| 606 | − | Ok(lines) => written += self.upsert_lines(&lines, &fetched_at).await?, | |
| 690 | + | // The bill over whole cycles, every time: Cloudflare posts a day a | |
| 691 | + | // day or two late and restates recent ones, and a cycle's included | |
| 692 | + | // amounts can only be applied to all of its days. | |
| 693 | + | let anchor = self.cycle_anchor().await?; | |
| 694 | + | let bill_since = crate::cycle::bill_since(&until, anchor, RESTATE_DAYS as u32, last.is_none()); | |
| 695 | + | let read = keeper | |
| 696 | + | .billable_usage_pages(&bill_since, &until) | |
| 697 | + | .await | |
| 698 | + | .map_err(|e| e.to_string()) | |
| 699 | + | .and_then(|(body, pages)| Ok((lines_from_billable(&body)?, read_stats(&body, pages)))); | |
| 700 | + | match read { | |
| 701 | + | Ok((_, stats)) if stats.rows == 0 && last.is_some() => { | |
| 702 | + | problems.push(format!("Cloudflare's billable usage answered no rows for {bill_since} to {until}; what was read before is kept.")); | |
| 703 | + | self.keep_read(&bill_since, &until, &stats, &fetched_at).await?; | |
| 704 | + | } | |
| 705 | + | Ok((mut lines, stats)) => { | |
| 706 | + | crate::cycle::price_lines(&mut lines, anchor); | |
| 707 | + | // The days read are replaced whole: a meter gone from a day | |
| 708 | + | // must not keep its old line. | |
| 709 | + | self.db | |
| 710 | + | .prepare("DELETE FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3") | |
| 711 | + | .bind(&[SOURCE_BILLABLE.into(), bill_since.as_str().into(), until.as_str().into()])? | |
| 712 | + | .run() | |
| 713 | + | .await?; | |
| 714 | + | written += self.upsert_lines(&lines, &fetched_at).await?; | |
| 715 | + | self.keep_read(&bill_since, &until, &stats, &fetched_at).await?; | |
| 716 | + | } | |
| 607 | 717 | Err(error) => problems.push(format!("Cloudflare's billable usage could not be read: {error}")), | |
| 608 | 718 | } | |
| 609 | 719 | match keeper.graphql(artifacts_variables(keeper.account(), &since, &until)).await.map_err(|e| e.to_string()) { | |
| ⋯ | |||
| 653 | 763 | } | |
| 654 | 764 | } | |
| 655 | 765 | } | |
| 656 | − | Ok(Some((since, until, written))) | |
| 766 | + | Ok(Some((since, until, written, bill_since))) | |
| 657 | 767 | } | |
| 658 | 768 | ||
| 769 | + | /// What the last read of billable usage got back (`cost_reads`). | |
| 770 | + | async fn keep_read(&self, since: &str, until: &str, stats: &ReadStats, read_at: &str) -> Result<()> { | |
| 771 | + | self.db | |
| 772 | + | .prepare( | |
| 773 | + | "INSERT INTO cost_reads (source, read_at, since, until, rows, pages, consumed_rows, pricing_only_rows, costed_rows) | |
| 774 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) | |
| 775 | + | ON CONFLICT (source) DO UPDATE SET read_at = ?2, since = ?3, until = ?4, rows = ?5, pages = ?6, | |
| 776 | + | consumed_rows = ?7, pricing_only_rows = ?8, costed_rows = ?9", | |
| 777 | + | ) | |
| 778 | + | .bind(&[ | |
| 779 | + | SOURCE_BILLABLE.into(), | |
| 780 | + | read_at.into(), | |
| 781 | + | since.into(), | |
| 782 | + | until.into(), | |
| 783 | + | stats.rows.into(), | |
| 784 | + | stats.pages.into(), | |
| 785 | + | stats.consumed_rows.into(), | |
| 786 | + | stats.pricing_only_rows.into(), | |
| 787 | + | stats.costed_rows.into(), | |
| 788 | + | ])? | |
| 789 | + | .run() | |
| 790 | + | .await?; | |
| 791 | + | Ok(()) | |
| 792 | + | } | |
| 793 | + | ||
| 659 | 794 | /// Cloudflare's own per-workspace counts for the days, replacing what | |
| 660 | 795 | /// was kept for them. | |
| 661 | 796 | /// The workspace of each pull request's working copy, from repos; | |
| ⋯ | |||
| 705 | 840 | statements.push( | |
| 706 | 841 | self.db | |
| 707 | 842 | .prepare( | |
| 708 | − | "INSERT INTO cost_lines (day, source, product, meter, unit, quantity, cost_usd, raw_name, fetched_at) | |
| 709 | − | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) | |
| 843 | + | "INSERT INTO cost_lines (day, source, product, meter, unit, quantity, cost_usd, raw_name, fetched_at, billed_usd, billable_quantity, basis) | |
| 844 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) | |
| 710 | 845 | ON CONFLICT (day, source, product, meter) DO UPDATE SET | |
| 711 | − | unit = ?5, quantity = ?6, cost_usd = ?7, raw_name = ?8, fetched_at = ?9", | |
| 846 | + | unit = ?5, quantity = ?6, cost_usd = ?7, raw_name = ?8, fetched_at = ?9, | |
| 847 | + | billed_usd = ?10, billable_quantity = ?11, basis = ?12", | |
| 712 | 848 | ) | |
| 713 | 849 | .bind(&[ | |
| 714 | 850 | line.day.as_str().into(), | |
| ⋯ | |||
| 720 | 856 | line.cost_usd.into(), | |
| 721 | 857 | line.raw_name.as_str().into(), | |
| 722 | 858 | fetched_at.into(), | |
| 859 | + | line.billed_usd.into(), | |
| 860 | + | line.billable_quantity.into(), | |
| 861 | + | line.basis.into(), | |
| 723 | 862 | ])?, | |
| 724 | 863 | ); | |
| 725 | 864 | } | |
| ⋯ | |||
| 983 | 1122 | assert!((artifacts.cost_usd - 4.5).abs() < 1e-9, "billed, not list: {}", artifacts.cost_usd); | |
| 984 | 1123 | let wfp = lines.iter().find(|l| l.meter.starts_with("workers_for_platforms")).unwrap(); | |
| 985 | 1124 | assert_eq!(wfp.product, "workers"); | |
| 986 | − | // No billed cost: the list cost. | |
| 987 | − | assert_eq!(wfp.cost_usd, 1.5); | |
| 1125 | + | // A list cost is before the included amounts: never what g1t pays. | |
| 1126 | + | // The cycle prices it (`cycle::price_lines`). | |
| 1127 | + | assert_eq!((wfp.cost_usd, wfp.billed_usd), (0.0, 0.0)); | |
| 988 | 1128 | let memory = lines.iter().find(|l| l.product == "containers").unwrap(); | |
| 989 | 1129 | assert_eq!((memory.day.as_str(), memory.quantity, memory.cost_usd), ("2026-10-03", 14_000.0, 0.0)); | |
| 990 | 1130 | assert!(lines_from_billable(&json!({ "success": false, "errors": [{ "code": 10000 }] })).is_err()); | |
| 991 | 1131 | } | |
| 992 | 1132 | ||
| 993 | 1133 | #[test] | |
| 1134 | + | fn the_quantity_is_what_was_consumed_not_the_pricing_blocks() { | |
| 1135 | + | // FOCUS: ConsumedQuantity in the meter's units; PricingQuantity in | |
| 1136 | + | // pricing units, which can be blocks of a million. | |
| 1137 | + | let row = json!({ | |
| 1138 | + | "ChargePeriodStart": "2026-10-07T00:00:00Z", | |
| 1139 | + | "ServiceFamilyName": "Workers", | |
| 1140 | + | "ServiceName": "Workers CPU ms (first 30M are included)", | |
| 1141 | + | "ConsumedQuantity": 11160000, "ConsumedUnit": "ms", | |
| 1142 | + | "PricingQuantity": 11.16, "PricingUnit": "1M ms", | |
| 1143 | + | "BilledCost": 0, "ListCost": 0.2232 | |
| 1144 | + | }); | |
| 1145 | + | let line = line_from_focus(&row).unwrap(); | |
| 1146 | + | assert_eq!((line.quantity, line.unit.as_str(), line.cost_usd), (11_160_000.0, "ms", 0.0)); | |
| 1147 | + | let body = json!({ "result": [row, { "ServiceName": "x", "ChargePeriodStart": "2026-10-07", "PricingQuantity": 3, "BilledCost": 0.5 }] }); | |
| 1148 | + | assert_eq!( | |
| 1149 | + | read_stats(&body, 2), | |
| 1150 | + | ReadStats { rows: 2, pages: 2, consumed_rows: 1, pricing_only_rows: 1, costed_rows: 1 } | |
| 1151 | + | ); | |
| 1152 | + | } | |
| 1153 | + | ||
| 1154 | + | #[test] | |
| 1155 | + | fn every_page_of_an_answer_is_read() { | |
| 1156 | + | let page = |info: Value| json!({ "success": true, "result": [{ "ServiceName": "x" }], "result_info": info }); | |
| 1157 | + | assert_eq!(next_page(&page(json!({ "page": 1, "per_page": 50, "total_pages": 3 })), 1).as_deref(), Some("page=2&per_page=50")); | |
| 1158 | + | assert_eq!(next_page(&page(json!({ "page": 3, "per_page": 50, "total_pages": 3 })), 3), None); | |
| 1159 | + | // From the count when the pages are not given. | |
| 1160 | + | assert_eq!(next_page(&page(json!({ "per_page": 100, "total_count": 230 })), 2).as_deref(), Some("page=3&per_page=100")); | |
| 1161 | + | assert_eq!(next_page(&page(json!({ "cursor": "a+b/c=" })), 1).as_deref(), Some("cursor=a%2Bb%2Fc%3D")); | |
| 1162 | + | assert_eq!(next_page(&page(json!({ "cursor": "" })), 1), None); | |
| 1163 | + | // No paging said, or nothing on the page: that was all. | |
| 1164 | + | assert_eq!(next_page(&json!({ "result": [{}] }), 1), None); | |
| 1165 | + | assert_eq!(next_page(&json!({ "result": [], "result_info": { "total_pages": 9 } }), 1), None); | |
| 1166 | + | } | |
| 1167 | + | ||
| 1168 | + | #[test] | |
| 994 | 1169 | fn reading_the_same_days_twice_gives_the_same_lines() { | |
| 995 | 1170 | // Idempotent: the same answer aggregates to the same keys and | |
| 996 | 1171 | // amounts, so the upsert replaces rather than adds. | |
| 1 | + | //! Cloudflare's billing cycle, and what g1t's usage costs over it. | |
| 2 | + | //! | |
| 3 | + | //! Cloudflare bills usage per billing cycle, a month from the day the | |
| 4 | + | //! account's subscription renews (for g1t's account, the 28th: Sep 28 to | |
| 5 | + | //! Oct 27), and each meter's included amount ("first 30M are included") is | |
| 6 | + | //! the account's, once per cycle, not per day or per product. So a day's | |
| 7 | + | //! cost depends on every day before it in the cycle: the cost lands on the | |
| 8 | + | //! day the cycle's running total passes the included amount, and on every | |
| 9 | + | //! day after, as on Cloudflare's own Billable usage page. | |
| 10 | + | //! | |
| 11 | + | //! Cloudflare's billable-usage lines carry no cost while a cycle is open | |
| 12 | + | //! (seen 2026-10-08: every line $0 while the dashboard showed $0.29), so | |
| 13 | + | //! the cost of each meter is the list price past the included amount, | |
| 14 | + | //! applied here over the cycle; where Cloudflare does put a cost on a | |
| 15 | + | //! meter's lines in a cycle, that cost is taken instead, as it is. | |
| 16 | + | //! | |
| 17 | + | //! Meters priced per million (requests, CPU ms, rows, operations) are billed | |
| 18 | + | //! in whole millions: 9.16M CPU ms past the included 30M is billed as 10M, | |
| 19 | + | //! $0.20, which is what Cloudflare's page showed on 2026-10-09. | |
| 20 | + | ||
| 21 | + | use std::collections::BTreeMap; | |
| 22 | + | ||
| 23 | + | use crate::costs::{CostLine, SOURCE_BILLABLE, days_between}; | |
| 24 | + | ||
| 25 | + | /// Where a line's cost came from. | |
| 26 | + | pub(crate) const BASIS_CLOUDFLARE: &str = "cloudflare"; | |
| 27 | + | pub(crate) const BASIS_LIST: &str = "list"; | |
| 28 | + | pub(crate) const BASIS_NONE: &str = "none"; | |
| 29 | + | ||
| 30 | + | /// g1t's account renews on the 28th; `CLOUDFLARE_BILLING_DAY` says so | |
| 31 | + | /// until Cloudflare's subscriptions have been read. | |
| 32 | + | pub(crate) const DEFAULT_ANCHOR: u32 = 1; | |
| 33 | + | ||
| 34 | + | /// A billing cycle: its first and last days, YYYY-MM-DD, inclusive. | |
| 35 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 36 | + | pub(crate) struct Cycle { | |
| 37 | + | pub start: String, | |
| 38 | + | pub end: String, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl Cycle { | |
| 42 | + | pub fn days(&self) -> u32 { | |
| 43 | + | days_between(&self.start, &self.end).len() as u32 | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Days from its start to `day`, both included (at most all of them). | |
| 47 | + | pub fn days_elapsed(&self, day: &str) -> u32 { | |
| 48 | + | let until = if day > self.end.as_str() { self.end.as_str() } else { day }; | |
| 49 | + | days_between(&self.start, until).len() as u32 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn previous(&self, anchor: u32) -> Cycle { | |
| 53 | + | cycle_of(&shift(&self.start, -1), anchor) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | fn ymd(day: &str) -> Option<(i32, u32, u32)> { | |
| 58 | + | let mut parts = day.get(..10)?.splitn(3, '-'); | |
| 59 | + | Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn days_in_month(year: i32, month: u32) -> u32 { | |
| 63 | + | match month { | |
| 64 | + | 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29, | |
| 65 | + | 2 => 28, | |
| 66 | + | 4 | 6 | 9 | 11 => 30, | |
| 67 | + | _ => 31, | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) { | |
| 72 | + | let index = year * 12 + month as i32 - 1 + delta; | |
| 73 | + | (index.div_euclid(12), index.rem_euclid(12) as u32 + 1) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// `day` moved by `by` days. | |
| 77 | + | fn shift(day: &str, by: i64) -> String { | |
| 78 | + | let ms = g1t_contracts::time::parse_rfc3339(&format!("{}T00:00:00Z", &day[..10.min(day.len())])).unwrap_or(0) as i64; | |
| 79 | + | g1t_contracts::time::rfc3339((ms + by * crate::costs::DAY_MS as i64).max(0) as u64)[..10].to_owned() | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The cycle `day` is in, for a cycle that starts on day `anchor` of each | |
| 83 | + | /// month (the month's last day where it has fewer). | |
| 84 | + | pub(crate) fn cycle_of(day: &str, anchor: u32) -> Cycle { | |
| 85 | + | let anchor = anchor.clamp(1, 31); | |
| 86 | + | let Some((year, month, date)) = ymd(day) else { | |
| 87 | + | return Cycle { start: day.to_owned(), end: day.to_owned() }; | |
| 88 | + | }; | |
| 89 | + | let (sy, sm) = if date >= anchor.min(days_in_month(year, month)) { (year, month) } else { add_months(year, month, -1) }; | |
| 90 | + | let (ny, nm) = add_months(sy, sm, 1); | |
| 91 | + | let start = format!("{sy:04}-{sm:02}-{:02}", anchor.min(days_in_month(sy, sm))); | |
| 92 | + | let next = format!("{ny:04}-{nm:02}-{:02}", anchor.min(days_in_month(ny, nm))); | |
| 93 | + | Cycle { start, end: shift(&next, -1) } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// The day of the month a cycle starts on, from a subscription's | |
| 97 | + | /// `current_period_start`. | |
| 98 | + | pub(crate) fn anchor_of(period_start: &str) -> Option<u32> { | |
| 99 | + | ymd(period_start).map(|(_, _, d)| d).filter(|d| (1..=31).contains(d)) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// The days to read the bill for: the current cycle, and the one before | |
| 103 | + | /// while it may still be restated (its first `restate_days` days) or when | |
| 104 | + | /// nothing has been read yet. Whole cycles, so the included amounts are | |
| 105 | + | /// applied to all of a cycle's usage. | |
| 106 | + | pub(crate) fn bill_since(today: &str, anchor: u32, restate_days: u32, first: bool) -> String { | |
| 107 | + | let current = cycle_of(today, anchor); | |
| 108 | + | if first || current.days_elapsed(today) <= restate_days { | |
| 109 | + | current.previous(anchor).start | |
| 110 | + | } else { | |
| 111 | + | current.start | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A meter's list price, with what each cycle includes. | |
| 116 | + | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 117 | + | pub(crate) struct ListPrice { | |
| 118 | + | /// Cloudflare's product and a meter prefix, as `costs::slug` makes them. | |
| 119 | + | pub product: &'static str, | |
| 120 | + | pub meter: &'static str, | |
| 121 | + | /// Included each cycle (or each day, `daily`), in the meter's units. | |
| 122 | + | pub included: f64, | |
| 123 | + | /// Dollars per `per` units. | |
| 124 | + | pub usd: f64, | |
| 125 | + | pub per: f64, | |
| 126 | + | /// Billed in whole blocks of `per` (the per-million meters). | |
| 127 | + | pub whole: bool, | |
| 128 | + | /// The included amount is a day's (Workers AI), not a cycle's. | |
| 129 | + | pub daily: bool, | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | impl ListPrice { | |
| 133 | + | /// What is past the included amount, of `used`. | |
| 134 | + | pub fn over(&self, used: f64) -> f64 { | |
| 135 | + | (used - self.included).max(0.0) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// What `used` units cost in one cycle (or day). | |
| 139 | + | pub fn cost(&self, used: f64) -> f64 { | |
| 140 | + | let over = self.over(used); | |
| 141 | + | if over <= 0.0 { | |
| 142 | + | return 0.0; | |
| 143 | + | } | |
| 144 | + | let blocks = over / self.per; | |
| 145 | + | // A float sum of a whole number of units must not round up a block. | |
| 146 | + | let blocks = if self.whole { (blocks - 1e-9).ceil().max(1.0) } else { blocks }; | |
| 147 | + | blocks * self.usd | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | const fn price(product: &'static str, meter: &'static str, included: f64, usd: f64, per: f64, whole: bool) -> ListPrice { | |
| 152 | + | ListPrice { product, meter, included, usd, per, whole, daily: false } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | const M: f64 = 1_000_000.0; | |
| 156 | + | ||
| 157 | + | /// Cloudflare's list prices on Workers Paid, as published (checked | |
| 158 | + | /// 2026-10-09 against the Billable usage page: Workers CPU and Containers | |
| 159 | + | /// memory to the cent). A meter not here is costed at $0 and shown as | |
| 160 | + | /// having no list price, until Cloudflare's lines carry its cost or a price | |
| 161 | + | /// is added here. Containers: vCPU-seconds, GiB-seconds and GB-seconds; | |
| 162 | + | /// storage in GB-months. | |
| 163 | + | pub(crate) const LIST_PRICES: &[ListPrice] = &[ | |
| 164 | + | price("workers", "workers_standard_requests", 10.0 * M, 0.30, M, true), | |
| 165 | + | price("workers", "workers_cpu_ms", 30.0 * M, 0.02, M, true), | |
| 166 | + | price("workers", "workers_for_platforms_requests", 20.0 * M, 0.30, M, true), | |
| 167 | + | price("workers", "workers_for_platforms_cpu_ms", 60.0 * M, 0.02, M, true), | |
| 168 | + | price("workers", "workers_for_platforms_scripts", 1_000.0, 0.02, 1.0, false), | |
| 169 | + | price("workers", "workers_logs", 20.0 * M, 0.60, M, true), | |
| 170 | + | price("workers_for_platforms", "workers_for_platforms_requests", 20.0 * M, 0.30, M, true), | |
| 171 | + | price("workers_for_platforms", "workers_for_platforms_cpu_ms", 60.0 * M, 0.02, M, true), | |
| 172 | + | price("workers_for_platforms", "workers_for_platforms_scripts", 1_000.0, 0.02, 1.0, false), | |
| 173 | + | price("d1", "d1_rows_read", 25_000.0 * M, 0.001, M, true), | |
| 174 | + | price("d1", "d1_rows_written", 50.0 * M, 1.00, M, true), | |
| 175 | + | price("d1", "d1_storage", 5.0, 0.75, 1.0, false), | |
| 176 | + | price("workers_kv", "kv_read_operations", 10.0 * M, 0.50, M, true), | |
| 177 | + | price("workers_kv", "kv_write_operations", M, 5.00, M, true), | |
| 178 | + | price("workers_kv", "kv_list_operations", M, 5.00, M, true), | |
| 179 | + | price("workers_kv", "kv_delete_operations", M, 5.00, M, true), | |
| 180 | + | price("workers_kv", "kv_storage", 1.0, 0.50, 1.0, false), | |
| 181 | + | price("r2", "r2_storage_class_a_operations", M, 4.50, M, true), | |
| 182 | + | price("r2", "r2_storage_class_b_operations", 10.0 * M, 0.36, M, true), | |
| 183 | + | price("durable_objects", "durable_objects_compute_requests", M, 0.15, M, true), | |
| 184 | + | price("durable_objects", "durable_objects_compute_duration", 400_000.0, 12.50, M, false), | |
| 185 | + | price("durable_objects", "durable_objects_storage_rows_read", 25_000.0 * M, 0.001, M, true), | |
| 186 | + | price("durable_objects", "durable_objects_storage_rows_written", 50.0 * M, 1.00, M, true), | |
| 187 | + | price("durable_objects", "durable_objects_sql_storage", 5.0, 0.20, 1.0, false), | |
| 188 | + | price("queues", "queues_standard_operations", M, 0.40, M, true), | |
| 189 | + | // 25 GiB-hours, 375 vCPU-minutes and 200 GB-hours a cycle. | |
| 190 | + | price("containers", "container_memory", 90_000.0, 0.000_002_5, 1.0, false), | |
| 191 | + | price("containers", "container_vcpu", 22_500.0, 0.000_020, 1.0, false), | |
| 192 | + | price("containers", "container_disk", 720_000.0, 0.000_000_07, 1.0, false), | |
| 193 | + | price("containers", "container_egress_north_america_europe", 1_000.0, 0.025, 1.0, false), | |
| 194 | + | price("containers", "container_egress_everywhere_else", 500.0, 0.04, 1.0, false), | |
| 195 | + | price("vectorize", "vectorize_queried", 50.0 * M, 0.01, M, false), | |
| 196 | + | price("vectorize", "vectorize_stored", 10.0 * M, 0.05, 100.0 * M, false), | |
| 197 | + | ListPrice { product: "workers_ai", meter: "workers_ai_neurons", included: 10_000.0, usd: 0.011, per: 1_000.0, whole: false, daily: true }, | |
| 198 | + | ]; | |
| 199 | + | ||
| 200 | + | /// The list price for a line: its product's with the longest matching | |
| 201 | + | /// meter prefix. | |
| 202 | + | pub(crate) fn list_price(product: &str, meter: &str) -> Option<&'static ListPrice> { | |
| 203 | + | LIST_PRICES.iter().filter(|p| p.product == product && meter.starts_with(p.meter)).max_by_key(|p| p.meter.len()) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Puts a cost on every billable-usage line, cycle by cycle (`anchor`): | |
| 207 | + | /// Cloudflare's own where it put one on any of the meter's lines in the | |
| 208 | + | /// cycle, else the list price past the included amount, landing on the | |
| 209 | + | /// days the cycle's running total passes it. Other lines are left alone. | |
| 210 | + | pub(crate) fn price_lines(lines: &mut [CostLine], anchor: u32) { | |
| 211 | + | let mut groups: BTreeMap<(String, String, String), Vec<usize>> = BTreeMap::new(); | |
| 212 | + | for (index, line) in lines.iter().enumerate() { | |
| 213 | + | if line.source != SOURCE_BILLABLE { | |
| 214 | + | continue; | |
| 215 | + | } | |
| 216 | + | let period = match list_price(&line.product, &line.meter) { | |
| 217 | + | Some(p) if p.daily => line.day.clone(), | |
| 218 | + | _ => cycle_of(&line.day, anchor).start, | |
| 219 | + | }; | |
| 220 | + | groups.entry((period, line.product.clone(), line.meter.clone())).or_default().push(index); | |
| 221 | + | } | |
| 222 | + | for ((_, product, meter), mut indexes) in groups { | |
| 223 | + | indexes.sort_by(|a, b| lines[*a].day.cmp(&lines[*b].day)); | |
| 224 | + | let billed = indexes.iter().any(|i| lines[*i].billed_usd > 0.0); | |
| 225 | + | let list = list_price(&product, &meter); | |
| 226 | + | let mut used = 0.0; | |
| 227 | + | for index in indexes { | |
| 228 | + | let line = &mut lines[index]; | |
| 229 | + | let before = used; | |
| 230 | + | used += line.quantity.max(0.0); | |
| 231 | + | line.billable_quantity = list.map_or(0.0, |p| p.over(used) - p.over(before)); | |
| 232 | + | (line.cost_usd, line.basis) = match list { | |
| 233 | + | _ if billed => (line.billed_usd, BASIS_CLOUDFLARE), | |
| 234 | + | Some(p) => (p.cost(used) - p.cost(before), BASIS_LIST), | |
| 235 | + | None => (0.0, BASIS_NONE), | |
| 236 | + | }; | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// A cycle's cost, from what it cost over the days elapsed: Cloudflare's | |
| 242 | + | /// projection on its Billable usage page ($0.29 over 12 days of a 30-day | |
| 243 | + | /// cycle is $0.73). | |
| 244 | + | pub(crate) fn project(cost_micros: i64, days_elapsed: u32, days: u32) -> i64 { | |
| 245 | + | if days_elapsed == 0 { | |
| 246 | + | return cost_micros; | |
| 247 | + | } | |
| 248 | + | (cost_micros as f64 * days as f64 / days_elapsed as f64).round() as i64 | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// A monthly subscription over the days from `since` to `until`: each day | |
| 252 | + | /// its cycle's share (the month's price over the cycle's days), so a whole | |
| 253 | + | /// cycle comes to the month's price and any range to its days' part of it. | |
| 254 | + | pub(crate) fn accrued(monthly_micros: i64, since: &str, until: &str, anchor: u32) -> i64 { | |
| 255 | + | let mut lengths: BTreeMap<String, u32> = BTreeMap::new(); | |
| 256 | + | let total: f64 = days_between(since, until) | |
| 257 | + | .iter() | |
| 258 | + | .map(|day| { | |
| 259 | + | let cycle = cycle_of(day, anchor); | |
| 260 | + | let days = *lengths.entry(cycle.start.clone()).or_insert_with(|| cycle.days()); | |
| 261 | + | monthly_micros as f64 / days.max(1) as f64 | |
| 262 | + | }) | |
| 263 | + | .sum(); | |
| 264 | + | total.round() as i64 | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[cfg(test)] | |
| 268 | + | mod tests { | |
| 269 | + | use super::*; | |
| 270 | + | ||
| 271 | + | fn line(day: &str, product: &str, meter: &str, quantity: f64, billed: f64) -> CostLine { | |
| 272 | + | CostLine { | |
| 273 | + | day: day.into(), | |
| 274 | + | source: SOURCE_BILLABLE, | |
| 275 | + | product: product.into(), | |
| 276 | + | meter: meter.into(), | |
| 277 | + | unit: "Count".into(), | |
| 278 | + | quantity, | |
| 279 | + | billed_usd: billed, | |
| 280 | + | raw_name: meter.into(), | |
| 281 | + | ..CostLine::default() | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[test] | |
| 286 | + | fn a_cycle_runs_from_the_anchor_day_to_the_day_before_the_next() { | |
| 287 | + | assert_eq!(cycle_of("2026-10-09", 28), Cycle { start: "2026-09-28".into(), end: "2026-10-27".into() }); | |
| 288 | + | assert_eq!(cycle_of("2026-09-28", 28).start, "2026-09-28"); | |
| 289 | + | assert_eq!(cycle_of("2026-09-27", 28), Cycle { start: "2026-08-28".into(), end: "2026-09-27".into() }); | |
| 290 | + | assert_eq!(cycle_of("2026-10-28", 28), Cycle { start: "2026-10-28".into(), end: "2026-11-27".into() }); | |
| 291 | + | assert_eq!(cycle_of("2026-10-09", 28).days(), 30); | |
| 292 | + | assert_eq!(cycle_of("2026-10-09", 28).days_elapsed("2026-10-09"), 12); | |
| 293 | + | // Across a year, and an anchor past a short month's end. | |
| 294 | + | assert_eq!(cycle_of("2027-01-05", 28).start, "2026-12-28"); | |
| 295 | + | assert_eq!(cycle_of("2027-02-28", 31), Cycle { start: "2027-02-28".into(), end: "2027-03-30".into() }); | |
| 296 | + | assert_eq!(cycle_of("2026-10-09", 1), Cycle { start: "2026-10-01".into(), end: "2026-10-31".into() }); | |
| 297 | + | assert_eq!(anchor_of("2026-09-28T00:00:00Z"), Some(28)); | |
| 298 | + | assert_eq!(anchor_of("nonsense"), None); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn the_bill_is_read_for_whole_cycles() { | |
| 303 | + | // Ten days in: the current cycle only. | |
| 304 | + | assert_eq!(bill_since("2026-10-09", 28, 4, false), "2026-09-28"); | |
| 305 | + | // Its first days, or the first read: the cycle before too, which | |
| 306 | + | // Cloudflare may still restate. | |
| 307 | + | assert_eq!(bill_since("2026-09-30", 28, 4, false), "2026-08-28"); | |
| 308 | + | assert_eq!(bill_since("2026-10-09", 28, 4, true), "2026-08-28"); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// The owner's dashboard on 2026-10-09 (cycle Sep 28 to Oct 27): Workers | |
| 312 | + | /// CPU 39.16M ms (9.16M billable) $0.20, Containers memory 126.87k | |
| 313 | + | /// GiB-seconds (36.87k billable) $0.09, all of it on 10-07; total $0.29, | |
| 314 | + | /// projected $0.73. Cloudflare's lines carried no cost. | |
| 315 | + | #[test] | |
| 316 | + | fn the_cycle_matches_cloudflares_billable_usage_page() { | |
| 317 | + | let mut lines = vec![ | |
| 318 | + | line("2026-10-05", "workers", "workers_cpu_ms", 14_000_000.0, 0.0), | |
| 319 | + | line("2026-10-06", "workers", "workers_cpu_ms", 14_000_000.0, 0.0), | |
| 320 | + | line("2026-10-07", "workers", "workers_cpu_ms", 11_160_000.0, 0.0), | |
| 321 | + | line("2026-10-05", "containers", "container_memory_per_gib_second", 20_000.0, 0.0), | |
| 322 | + | line("2026-10-06", "containers", "container_memory_per_gib_second", 52_000.0, 0.0), | |
| 323 | + | line("2026-10-07", "containers", "container_memory_per_gib_second", 54_870.0, 0.0), | |
| 324 | + | line("2026-10-07", "containers", "container_vcpu", 12_000.0, 0.0), | |
| 325 | + | line("2026-10-07", "containers", "container_disk_per_gb_second", 253_740.0, 0.0), | |
| 326 | + | line("2026-10-07", "d1", "d1_rows_read", 61_820_000.0, 0.0), | |
| 327 | + | line("2026-10-07", "workers", "workers_standard_requests", 845_650.0, 0.0), | |
| 328 | + | line("2026-10-07", "email", "email_service_emails_sent", 7.0, 0.0), | |
| 329 | + | ]; | |
| 330 | + | price_lines(&mut lines, 28); | |
| 331 | + | let total: f64 = lines.iter().map(|l| l.cost_usd).sum(); | |
| 332 | + | assert!((total - 0.29).abs() < 0.01, "{total}"); | |
| 333 | + | let on = |day: &str| lines.iter().filter(|l| l.day == day).map(|l| l.cost_usd).sum::<f64>(); | |
| 334 | + | assert_eq!((on("2026-10-05"), on("2026-10-06")), (0.0, 0.0)); | |
| 335 | + | let cpu: f64 = lines.iter().filter(|l| l.meter == "workers_cpu_ms").map(|l| l.cost_usd).sum(); | |
| 336 | + | assert!((cpu - 0.20).abs() < 1e-9, "billed in whole millions: {cpu}"); | |
| 337 | + | let memory: Vec<&CostLine> = lines.iter().filter(|l| l.meter.starts_with("container_memory")).collect(); | |
| 338 | + | let billable: f64 = memory.iter().map(|l| l.billable_quantity).sum(); | |
| 339 | + | assert!((billable - 36_870.0).abs() < 1e-6); | |
| 340 | + | assert!((memory.iter().map(|l| l.cost_usd).sum::<f64>() - 0.092_175).abs() < 1e-9); | |
| 341 | + | assert!(lines.iter().filter(|l| l.product != "email").all(|l| l.basis == BASIS_LIST)); | |
| 342 | + | assert_eq!(lines.iter().find(|l| l.product == "email").unwrap().basis, BASIS_NONE); | |
| 343 | + | let micros = (total * 1_000_000.0).round() as i64; | |
| 344 | + | assert_eq!(project(micros, 12, 30), 730_438); | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[test] | |
| 348 | + | fn included_amounts_are_the_cycles_not_the_days_or_the_month() { | |
| 349 | + | // 20M CPU ms a day for three days across a cycle's end: the second | |
| 350 | + | // day passes 30M in the old cycle; the third starts a new one. | |
| 351 | + | let mut lines = vec![ | |
| 352 | + | line("2026-10-26", "workers", "workers_cpu_ms", 20_000_000.0, 0.0), | |
| 353 | + | line("2026-10-27", "workers", "workers_cpu_ms", 20_000_000.0, 0.0), | |
| 354 | + | line("2026-10-28", "workers", "workers_cpu_ms", 20_000_000.0, 0.0), | |
| 355 | + | ]; | |
| 356 | + | price_lines(&mut lines, 28); | |
| 357 | + | let costs: Vec<f64> = lines.iter().map(|l| (l.cost_usd * 100.0).round() / 100.0).collect(); | |
| 358 | + | assert_eq!(costs, vec![0.0, 0.20, 0.0]); | |
| 359 | + | // Unsorted lines are priced in day order all the same. | |
| 360 | + | let mut reversed = vec![lines[1].clone(), lines[0].clone()]; | |
| 361 | + | price_lines(&mut reversed, 28); | |
| 362 | + | assert_eq!(reversed[0].cost_usd, lines[1].cost_usd); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn cloudflares_own_cost_wins_for_its_meter_and_cycle() { | |
| 367 | + | let mut lines = vec![ | |
| 368 | + | line("2026-10-06", "workers", "workers_cpu_ms", 35_000_000.0, 0.0), | |
| 369 | + | line("2026-10-07", "workers", "workers_cpu_ms", 5_000_000.0, 0.19), | |
| 370 | + | line("2026-10-07", "containers", "container_memory_per_gib_second", 100_000.0, 0.0), | |
| 371 | + | ]; | |
| 372 | + | price_lines(&mut lines, 28); | |
| 373 | + | assert_eq!((lines[0].cost_usd, lines[0].basis), (0.0, BASIS_CLOUDFLARE)); | |
| 374 | + | assert_eq!((lines[1].cost_usd, lines[1].basis), (0.19, BASIS_CLOUDFLARE)); | |
| 375 | + | assert_eq!(lines[2].basis, BASIS_LIST); | |
| 376 | + | // Not billable usage: left as it is. | |
| 377 | + | let mut other = vec![CostLine { source: crate::costs::SOURCE_GATEWAY, cost_usd: 1.5, ..line("2026-10-07", "ai_gateway_requests", "m", 3.0, 0.0) }]; | |
| 378 | + | price_lines(&mut other, 28); | |
| 379 | + | assert_eq!((other[0].cost_usd, other[0].basis), (1.5, "")); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn workers_ai_includes_a_day_not_a_cycle() { | |
| 384 | + | let mut lines = vec![ | |
| 385 | + | line("2026-10-06", "workers_ai", "workers_ai_neurons", 9_000.0, 0.0), | |
| 386 | + | line("2026-10-07", "workers_ai", "workers_ai_neurons", 12_000.0, 0.0), | |
| 387 | + | ]; | |
| 388 | + | price_lines(&mut lines, 28); | |
| 389 | + | assert_eq!(lines[0].cost_usd, 0.0); | |
| 390 | + | assert!((lines[1].cost_usd - 0.022).abs() < 1e-12); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn the_longest_prefix_names_the_price() { | |
| 395 | + | assert_eq!(list_price("workers", "workers_for_platforms_cpu_ms").unwrap().included, 60.0 * M); | |
| 396 | + | assert_eq!(list_price("workers", "workers_cpu_ms").unwrap().included, 30.0 * M); | |
| 397 | + | assert!(list_price("email", "email_service_emails_sent").is_none()); | |
| 398 | + | // The keeper prices sandboxes at the same published rates. | |
| 399 | + | assert_eq!(list_price("containers", "container_memory_per_gib_second").unwrap().usd, 0.000_002_5); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn subscriptions_accrue_by_the_cycles_days() { | |
| 404 | + | // A whole cycle is the month's price, whatever its length. | |
| 405 | + | assert_eq!(accrued(30_000_000, "2026-09-28", "2026-10-27", 28), 30_000_000); | |
| 406 | + | assert_eq!(accrued(30_000_000, "2026-10-28", "2026-11-27", 28), 30_000_000); | |
| 407 | + | // Twelve days of a 30-day cycle; October to the 9th. | |
| 408 | + | assert_eq!(accrued(30_000_000, "2026-09-28", "2026-10-09", 28), 12_000_000); | |
| 409 | + | assert_eq!(accrued(30_000_000, "2026-10-01", "2026-10-09", 28), 9_000_000); | |
| 410 | + | // The statement's 30 days and the calendar month agree day for day. | |
| 411 | + | let statement = accrued(30_000_000, "2026-09-10", "2026-10-09", 28); | |
| 412 | + | let september = accrued(30_000_000, "2026-09-10", "2026-09-30", 28); | |
| 413 | + | assert_eq!(statement, september + accrued(30_000_000, "2026-10-01", "2026-10-09", 28)); | |
| 414 | + | assert_eq!(accrued(30_000_000, "2026-10-09", "2026-10-01", 28), 0); | |
| 415 | + | } | |
| 416 | + | } |
| 88 | 88 | .ok_or_else(|| worker::Error::RustError("no CLOUDFLARE_BILLING_TOKEN or CLOUDFLARE_USAGE_TOKEN".into())) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | − | /// Billable usage from `from` to `to` (dates), as Cloudflare answers it. | |
| 92 | − | pub(crate) async fn billable_usage_body(&self, from: &str, to: &str) -> Result<Value> { | |
| 93 | − | send_with(self.billing_token()?, Method::Get, &self.api(&format!("/billable-usage?from={from}&to={to}")), None).await | |
| 91 | + | /// Billable usage from `from` to `to` (dates), every page of it, as one | |
| 92 | + | /// answer (`result` holds all the rows), and how many pages it took. | |
| 93 | + | /// An answer that says it failed is returned as it is. | |
| 94 | + | pub(crate) async fn billable_usage_pages(&self, from: &str, to: &str) -> Result<(Value, u32)> { | |
| 95 | + | usage_pages(self.billing_token()?, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await | |
| 94 | 96 | } | |
| 95 | 97 | ||
| 96 | 98 | /// The account's subscriptions (Workers Paid, add-ons), as Cloudflare | |
| ⋯ | |||
| 180 | 182 | /// The account's billable usage, one row per service per day, as | |
| 181 | 183 | /// Cloudflare reports it. | |
| 182 | 184 | async fn billable_usage(&self, from: &str, to: &str) -> Result<Vec<UsageRow>> { | |
| 183 | − | let body = self | |
| 184 | − | .send(Method::Get, &self.api(&format!("/billable-usage?from={from}&to={to}")), None) | |
| 185 | − | .await?; | |
| 185 | + | let Some(token) = &self.token else { | |
| 186 | + | return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into())); | |
| 187 | + | }; | |
| 188 | + | let (body, _) = usage_pages(token, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await?; | |
| 186 | 189 | let rows = body["result"].as_array().cloned().unwrap_or_default(); | |
| 187 | 190 | Ok(rows.iter().filter_map(UsageRow::from_value).collect()) | |
| 188 | 191 | } | |
| ⋯ | |||
| 224 | 227 | ||
| 225 | 228 | /// A request to Cloudflare's API with a bearer token; anything but 200 is | |
| 226 | 229 | /// an error with what Cloudflare said. | |
| 230 | + | /// The most pages of billable usage read in one go: far more than a few | |
| 231 | + | /// months of g1t's meters. | |
| 232 | + | const MAX_USAGE_PAGES: u32 = 50; | |
| 233 | + | ||
| 234 | + | /// Every page of a billable-usage answer, its rows together. Before | |
| 235 | + | /// 2026-10-09 only the first page was read. | |
| 236 | + | async fn usage_pages(token: &str, url: &str) -> Result<(Value, u32)> { | |
| 237 | + | let mut rows: Vec<Value> = Vec::new(); | |
| 238 | + | let mut pages = 0; | |
| 239 | + | let mut next = url.to_owned(); | |
| 240 | + | loop { | |
| 241 | + | let body = send_with(token, Method::Get, &next, None).await?; | |
| 242 | + | if body["success"] == Value::Bool(false) { | |
| 243 | + | return Ok((body, pages + 1)); | |
| 244 | + | } | |
| 245 | + | pages += 1; | |
| 246 | + | rows.extend(body["result"].as_array().cloned().unwrap_or_default()); | |
| 247 | + | match crate::costs::next_page(&body, pages).filter(|_| pages < MAX_USAGE_PAGES) { | |
| 248 | + | Some(query) => next = format!("{url}&{query}"), | |
| 249 | + | None => break, | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | Ok((json!({ "success": true, "result": rows }), pages)) | |
| 253 | + | } | |
| 254 | + | ||
| 227 | 255 | async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> { | |
| 228 | 256 | let headers = Headers::new(); | |
| 229 | 257 | headers.set("authorization", &format!("Bearer {token}"))?; | |
| 25 | 25 | mod closing; | |
| 26 | 26 | mod compute; | |
| 27 | 27 | mod costs; | |
| 28 | + | mod cycle; | |
| 28 | 29 | mod margin; | |
| 29 | 30 | mod platform; | |
| 30 | 31 | mod pricing; |
| 120 | 120 | /// open-source pool, and discounts on an account's terms (what they took | |
| 121 | 121 | /// below cost plus the margin, `ledger.discount_micros`), and credits g1t | |
| 122 | 122 | /// staff gave, promotional and goodwill, when spent (`grants`), and usage a | |
| 123 | − | /// testing reset wiped (`reset_costs`): g1t paid for it and nobody will. | |
| 123 | + | /// testing reset wiped (`reset_costs`): g1t paid for it and nobody will; | |
| 124 | + | /// and what was charged while payments were not live (Stripe's test mode), | |
| 125 | + | /// which brought in no real money (`without_real_money`). | |
| 124 | 126 | /// The Team plan's included usage is paid for by the plan's price, so it is | |
| 125 | 127 | /// sold, not given; so is what a refund pays for. | |
| 126 | 128 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| ⋯ | |||
| 133 | 135 | pub credit_promotional: i64, | |
| 134 | 136 | pub credit_goodwill: i64, | |
| 135 | 137 | pub reset: i64, | |
| 138 | + | pub unpaid: i64, | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | impl Given { | |
| 139 | 142 | pub fn total(&self) -> i64 { | |
| 140 | − | self.comped + self.free + self.trial + self.pool + self.discount + self.credit() + self.reset | |
| 143 | + | self.comped + self.free + self.trial + self.pool + self.discount + self.credit() + self.reset + self.unpaid | |
| 141 | 144 | } | |
| 142 | 145 | ||
| 143 | 146 | /// Credits from g1t, both kinds. | |
| ⋯ | |||
| 154 | 157 | self.credit_promotional += other.credit_promotional; | |
| 155 | 158 | self.credit_goodwill += other.credit_goodwill; | |
| 156 | 159 | self.reset += other.reset; | |
| 160 | + | self.unpaid += other.unpaid; | |
| 157 | 161 | } | |
| 158 | 162 | ||
| 159 | 163 | /// The same shares of `cost` as these are of `value`, at most all of it. | |
| ⋯ | |||
| 173 | 177 | credit_promotional: part(self.credit_promotional), | |
| 174 | 178 | credit_goodwill: part(self.credit_goodwill), | |
| 175 | 179 | reset: part(self.reset), | |
| 180 | + | unpaid: part(self.unpaid), | |
| 176 | 181 | } | |
| 177 | 182 | } | |
| 178 | 183 | } | |
| ⋯ | |||
| 232 | 237 | } | |
| 233 | 238 | } | |
| 234 | 239 | ||
| 240 | + | /// Money in only where it is real. Charges made while payments were not | |
| 241 | + | /// live (Stripe's test mode) brought in nothing: a row's cash from before | |
| 242 | + | /// `live_since` (all of it while payments are not live, `None`) is taken | |
| 243 | + | /// out of cash and counted as given away (`unpaid`), so it is never money | |
| 244 | + | /// in, never margin, and never what a workspace paid. | |
| 245 | + | pub(crate) fn without_real_money(rows: &mut [UsageRow], live_since: Option<&str>) { | |
| 246 | + | for row in rows { | |
| 247 | + | if live_since.is_some_and(|since| row.day.as_str() >= since) || row.cash == 0 { | |
| 248 | + | continue; | |
| 249 | + | } | |
| 250 | + | // What else gave it away already (a 100% discount) stays that. | |
| 251 | + | row.given.unpaid += row.cash.min(row.value - row.given.total()).max(0); | |
| 252 | + | row.cash = 0; | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// The day payments went live, from `cost_settings` (`payments_live_since`) | |
| 257 | + | /// as a value read there: None while they are not live, the day kept when | |
| 258 | + | /// they are, else `today` (the first time they are seen live). | |
| 259 | + | pub(crate) fn live_since(live: bool, kept: Option<&str>, today: &str) -> Option<String> { | |
| 260 | + | if !live { | |
| 261 | + | return None; | |
| 262 | + | } | |
| 263 | + | Some(kept.filter(|d| d.len() >= 10).map_or_else(|| today.to_owned(), |d| d[..10].to_owned())) | |
| 264 | + | } | |
| 265 | + | ||
| 235 | 266 | /// What a workspace was charged for one key on one day. | |
| 236 | 267 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 237 | 268 | pub(crate) struct UsageRow { | |
| ⋯ | |||
| 1016 | 1047 | given_credit_goodwill_micros: Option<i64>, | |
| 1017 | 1048 | #[serde(default)] | |
| 1018 | 1049 | given_reset_micros: Option<i64>, | |
| 1050 | + | #[serde(default)] | |
| 1051 | + | given_unpaid_micros: Option<i64>, | |
| 1019 | 1052 | } | |
| 1020 | 1053 | ||
| 1021 | 1054 | impl From<MarginRow> for ProductDay { | |
| ⋯ | |||
| 1038 | 1071 | credit_promotional: r.given_credit_promotional_micros.unwrap_or(0), | |
| 1039 | 1072 | credit_goodwill: r.given_credit_goodwill_micros.unwrap_or(0), | |
| 1040 | 1073 | reset: r.given_reset_micros.unwrap_or(0), | |
| 1074 | + | unpaid: r.given_unpaid_micros.unwrap_or(0), | |
| 1041 | 1075 | }, | |
| 1042 | 1076 | } | |
| 1043 | 1077 | } | |
| ⋯ | |||
| 1050 | 1084 | pub(crate) async fn costs_daily(&self, env: &Env, keeper: &crate::keeper::Keeper) -> Result<CostsRun> { | |
| 1051 | 1085 | let mut run = CostsRun::default(); | |
| 1052 | 1086 | let mut gateway = costs::GatewayRead::default(); | |
| 1053 | − | let (since, until) = match self.read_cloudflare(keeper, &mut run.problems, &mut gateway).await? { | |
| 1054 | − | Some((since, until, lines)) => { | |
| 1087 | + | // The subscriptions first: they say when the billing cycle starts, | |
| 1088 | + | // which the bill is priced by. Not a problem for the run: the last | |
| 1089 | + | // read, or the estimate, stays. | |
| 1090 | + | if keeper.can_read_bill() | |
| 1091 | + | && let Err(error) = self.read_subscriptions(keeper).await | |
| 1092 | + | { | |
| 1093 | + | worker::console_error!("Cloudflare's subscriptions were not read: {error}"); | |
| 1094 | + | } | |
| 1095 | + | let (since, until, bill_since) = match self.read_cloudflare(keeper, &mut run.problems, &mut gateway).await? { | |
| 1096 | + | Some((since, until, lines, bill_since)) => { | |
| 1055 | 1097 | run.lines = lines; | |
| 1056 | − | (since, until) | |
| 1098 | + | (since, until, Some(bill_since)) | |
| 1057 | 1099 | } | |
| 1058 | 1100 | // Without the bill, still reconcile what g1t knows itself, over | |
| 1059 | 1101 | // the same days the bill would be read for. | |
| ⋯ | |||
| 1063 | 1105 | day: Option<String>, | |
| 1064 | 1106 | } | |
| 1065 | 1107 | let last = self.db.prepare("SELECT MAX(day) AS day FROM margin_days").first::<Last>(None).await?.and_then(|l| l.day); | |
| 1066 | − | costs::window(last.as_deref(), now_ms()) | |
| 1108 | + | let (since, until) = costs::window(last.as_deref(), now_ms()); | |
| 1109 | + | (since, until, None) | |
| 1067 | 1110 | } | |
| 1068 | 1111 | }; | |
| 1069 | − | // Not a problem for the run: the last read, or the estimate, stays. | |
| 1070 | − | if keeper.can_read_bill() | |
| 1071 | − | && let Err(error) = self.read_subscriptions(keeper).await | |
| 1072 | − | { | |
| 1073 | − | worker::console_error!("Cloudflare's subscriptions were not read: {error}"); | |
| 1074 | − | } | |
| 1075 | 1112 | if let Err(error) = self.count_own(&since, &until).await { | |
| 1076 | 1113 | run.problems.push(format!("g1t's own counts could not be read: {error}")); | |
| 1077 | 1114 | } | |
| ⋯ | |||
| 1080 | 1117 | // bill was read for: it reads only what is already kept, so a change | |
| 1081 | 1118 | // in how a day is valued reaches every day shown at the next run. | |
| 1082 | 1119 | let window = day_before(&until, costs::BACKFILL_DAYS - 1); | |
| 1083 | − | let reconcile_from = if window < since { window } else { since.clone() }; | |
| 1120 | + | let reconcile_from = [Some(window), Some(since.clone()), bill_since].into_iter().flatten().min().unwrap_or_default(); | |
| 1084 | 1121 | run.days = self.reconcile_range(&reconcile_from, &until).await?; | |
| 1085 | 1122 | let drift = self.find_drift(&until, &gateway).await?; | |
| 1086 | 1123 | run.proposals = self.measure_units(&until).await?; | |
| ⋯ | |||
| 1244 | 1281 | } | |
| 1245 | 1282 | } | |
| 1246 | 1283 | } | |
| 1284 | + | // Charges without real money behind them are not money in. | |
| 1285 | + | without_real_money(&mut out, self.payments_live_since().await?.as_deref()); | |
| 1247 | 1286 | Ok(out) | |
| 1248 | 1287 | } | |
| 1249 | 1288 | ||
| 1289 | + | /// The day payments went live (`live_since`): kept in `cost_settings` | |
| 1290 | + | /// the first time they are seen live, so charges from before it stay | |
| 1291 | + | /// test money after the switch. | |
| 1292 | + | pub(crate) async fn payments_live_since(&self) -> Result<Option<String>> { | |
| 1293 | + | #[derive(Deserialize)] | |
| 1294 | + | struct Row { | |
| 1295 | + | value: String, | |
| 1296 | + | } | |
| 1297 | + | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| 1298 | + | let kept = self | |
| 1299 | + | .db | |
| 1300 | + | .prepare("SELECT value FROM cost_settings WHERE key = 'payments_live_since'") | |
| 1301 | + | .first::<Row>(None) | |
| 1302 | + | .await? | |
| 1303 | + | .map(|r| r.value) | |
| 1304 | + | .filter(|v| v.len() >= 10); | |
| 1305 | + | let today = rfc3339(now_ms())[..10].to_owned(); | |
| 1306 | + | let since = live_since(live, kept.as_deref(), &today); | |
| 1307 | + | if let Some(day) = since.as_deref().filter(|_| kept.is_none()) { | |
| 1308 | + | self.db | |
| 1309 | + | .prepare( | |
| 1310 | + | "INSERT INTO cost_settings (key, value, updated_at, updated_by) VALUES ('payments_live_since', ?1, ?2, 'billing') | |
| 1311 | + | ON CONFLICT (key) DO UPDATE SET value = ?1, updated_at = ?2, updated_by = 'billing'", | |
| 1312 | + | ) | |
| 1313 | + | .bind(&[day.into(), rfc3339(now_ms()).into()])? | |
| 1314 | + | .run() | |
| 1315 | + | .await?; | |
| 1316 | + | } | |
| 1317 | + | Ok(since) | |
| 1318 | + | } | |
| 1319 | + | ||
| 1250 | 1320 | /// Which bucket each ledger key (and month-end source) is revenue of. | |
| 1251 | 1321 | async fn revenue_map(&self) -> Result<BTreeMap<String, String>> { | |
| 1252 | 1322 | #[derive(Deserialize)] | |
| ⋯ | |||
| 1387 | 1457 | statements.push( | |
| 1388 | 1458 | self.db | |
| 1389 | 1459 | .prepare( | |
| 1390 | − | "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, given_comped_micros, given_free_micros, given_trial_micros, given_pool_micros, given_discount_micros, given_credit_promotional_micros, given_credit_goodwill_micros, given_reset_micros, computed_at) | |
| 1391 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1460 | + | "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, given_comped_micros, given_free_micros, given_trial_micros, given_pool_micros, given_discount_micros, given_credit_promotional_micros, given_credit_goodwill_micros, given_reset_micros, given_unpaid_micros, computed_at) | |
| 1461 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1392 | 1462 | ) | |
| 1393 | 1463 | .bind(&[ | |
| 1394 | 1464 | d.day.as_str().into(), | |
| ⋯ | |||
| 1408 | 1478 | (d.given.credit_promotional as f64).into(), | |
| 1409 | 1479 | (d.given.credit_goodwill as f64).into(), | |
| 1410 | 1480 | (d.given.reset as f64).into(), | |
| 1481 | + | (d.given.unpaid as f64).into(), | |
| 1411 | 1482 | now.as_str().into(), | |
| 1412 | 1483 | ])?, | |
| 1413 | 1484 | ); | |
| ⋯ | |||
| 1964 | 2035 | overall.given_credit_promotional_micros += d.given.credit_promotional; | |
| 1965 | 2036 | overall.given_credit_goodwill_micros += d.given.credit_goodwill; | |
| 1966 | 2037 | overall.given_reset_micros += d.given.reset; | |
| 2038 | + | overall.given_unpaid_micros += d.given.unpaid; | |
| 1967 | 2039 | let sold = (d.cost() - d.given.total()).max(0); | |
| 1968 | 2040 | if OVERHEAD.contains(&d.bucket.as_str()) { | |
| 1969 | 2041 | overall.plans_micros += d.cash_micros; | |
| ⋯ | |||
| 1993 | 2065 | struct Included { | |
| 1994 | 2066 | micros: Option<i64>, | |
| 1995 | 2067 | } | |
| 1996 | − | overall.included_micros = self | |
| 1997 | − | .db | |
| 1998 | − | .prepare(format!( | |
| 1999 | − | "SELECT SUM(COALESCE(credit_micros, 0)) AS micros FROM ledger | |
| 2000 | − | WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND workspace NOT IN ({})", | |
| 2001 | − | crate::sales::INTERNAL_SQL | |
| 2002 | − | )) | |
| 2003 | − | .bind(&[since.as_str().into(), format!("{until}T23:59:59.999Z").into()])? | |
| 2004 | − | .first::<Included>(None) | |
| 2005 | − | .await? | |
| 2006 | − | .and_then(|r| r.micros) | |
| 2007 | − | .unwrap_or(0); | |
| 2068 | + | // Only what a plan paid for with real money (`without_real_money`). | |
| 2069 | + | let live_since = self.payments_live_since().await?; | |
| 2070 | + | overall.included_micros = match &live_since { | |
| 2071 | + | None => 0, | |
| 2072 | + | Some(live) => self | |
| 2073 | + | .db | |
| 2074 | + | .prepare(format!( | |
| 2075 | + | "SELECT SUM(COALESCE(credit_micros, 0)) AS micros FROM ledger | |
| 2076 | + | WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND workspace NOT IN ({})", | |
| 2077 | + | crate::sales::INTERNAL_SQL | |
| 2078 | + | )) | |
| 2079 | + | .bind(&[std::cmp::max(since.clone(), live.clone()).into(), format!("{until}T23:59:59.999Z").into()])? | |
| 2080 | + | .first::<Included>(None) | |
| 2081 | + | .await? | |
| 2082 | + | .and_then(|r| r.micros) | |
| 2083 | + | .unwrap_or(0), | |
| 2084 | + | }; | |
| 2008 | 2085 | let usage_in = overall.usage_micros + overall.included_micros; | |
| 2009 | 2086 | overall.usage_margin_micros = usage_in - overall.usage_cost_micros; | |
| 2010 | 2087 | overall.usage_margin_percent = margin_percent(usage_in, overall.usage_cost_micros); | |
| ⋯ | |||
| 2154 | 2231 | } | |
| 2155 | 2232 | let fetched_at = self.db.prepare("SELECT MAX(fetched_at) AS at FROM cost_lines").first::<Fetched>(None).await?.and_then(|f| f.at); | |
| 2156 | 2233 | ||
| 2234 | + | // Cloudflare's subscriptions over the range, day by day as each | |
| 2235 | + | // day's share of its billing cycle; and the cycle itself. | |
| 2236 | + | let anchor = self.cycle_anchor().await?; | |
| 2237 | + | let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?; | |
| 2238 | + | overall.subscriptions_micros = crate::cycle::accrued(fixed.monthly_micros, &since, &until, anchor); | |
| 2239 | + | let cycle = self.cloudflare_cycle(&until, anchor, fixed.monthly_micros).await?; | |
| 2240 | + | let bill_read = self.bill_read().await?; | |
| 2241 | + | // What AI Gateway priced g1t's own provider traffic at, beside the | |
| 2242 | + | // ledger's model cost (Cloudflare-billed requests are Cloudflare's). | |
| 2243 | + | overall.gateway_cost_micros = self | |
| 2244 | + | .db | |
| 2245 | + | .prepare("SELECT SUM(cost_usd) AS cost FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3 AND substr(meter, 1, 11) <> ?4") | |
| 2246 | + | .bind(&[costs::SOURCE_GATEWAY.into(), since.as_str().into(), until.as_str().into(), costs::GATEWAY_WHOLESALE.into()])? | |
| 2247 | + | .first::<CostSum>(None) | |
| 2248 | + | .await? | |
| 2249 | + | .and_then(|c| c.cost) | |
| 2250 | + | .map_or(0, micros); | |
| 2251 | + | // The workspaces' shares of the cost, and what no one's usage carried. | |
| 2252 | + | #[derive(Deserialize)] | |
| 2253 | + | struct Shared { | |
| 2254 | + | micros: Option<i64>, | |
| 2255 | + | } | |
| 2256 | + | let shared = self | |
| 2257 | + | .db | |
| 2258 | + | .prepare("SELECT SUM(cost_micros) AS micros FROM workspace_costs WHERE day >= ?1 AND day <= ?2") | |
| 2259 | + | .bind(&[since.as_str().into(), until.as_str().into()])? | |
| 2260 | + | .first::<Shared>(None) | |
| 2261 | + | .await? | |
| 2262 | + | .and_then(|s| s.micros) | |
| 2263 | + | .unwrap_or(0); | |
| 2264 | + | let unattributed_micros = unattributed(overall.cost_micros, shared); | |
| 2265 | + | ||
| 2157 | 2266 | Ok(CostsReport { | |
| 2158 | 2267 | configured, | |
| 2159 | 2268 | fetched_at, | |
| ⋯ | |||
| 2181 | 2290 | mappings, | |
| 2182 | 2291 | settings: self.cost_settings().await?, | |
| 2183 | 2292 | caps: self.spend_caps().await?, | |
| 2293 | + | cycle, | |
| 2294 | + | bill_read, | |
| 2295 | + | unattributed_micros, | |
| 2184 | 2296 | }) | |
| 2185 | 2297 | } | |
| 2298 | + | ||
| 2299 | + | /// Cloudflare's billing cycle that `today` is in: its usage cost so far, | |
| 2300 | + | /// by meter, with the included amounts, and where it is heading. None | |
| 2301 | + | /// while nothing of it has been read. | |
| 2302 | + | async fn cloudflare_cycle(&self, today: &str, anchor: u32, monthly_micros: i64) -> Result<Option<CloudflareCycle>> { | |
| 2303 | + | #[derive(Deserialize)] | |
| 2304 | + | struct Row { | |
| 2305 | + | product: String, | |
| 2306 | + | meter: String, | |
| 2307 | + | raw_name: String, | |
| 2308 | + | unit: String, | |
| 2309 | + | quantity: f64, | |
| 2310 | + | billable_quantity: Option<f64>, | |
| 2311 | + | cost_usd: f64, | |
| 2312 | + | basis: Option<String>, | |
| 2313 | + | } | |
| 2314 | + | let cycle = crate::cycle::cycle_of(today, anchor); | |
| 2315 | + | let rows = self | |
| 2316 | + | .db | |
| 2317 | + | .prepare( | |
| 2318 | + | "SELECT product, meter, MAX(raw_name) AS raw_name, MAX(unit) AS unit, SUM(quantity) AS quantity, | |
| 2319 | + | SUM(billable_quantity) AS billable_quantity, SUM(cost_usd) AS cost_usd, MAX(basis) AS basis | |
| 2320 | + | FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3 GROUP BY product, meter", | |
| 2321 | + | ) | |
| 2322 | + | .bind(&[SOURCE_BILLABLE.into(), cycle.start.as_str().into(), today.into()])? | |
| 2323 | + | .all() | |
| 2324 | + | .await? | |
| 2325 | + | .results::<Row>()?; | |
| 2326 | + | if rows.is_empty() { | |
| 2327 | + | return Ok(None); | |
| 2328 | + | } | |
| 2329 | + | let elapsed = cycle.days_elapsed(today); | |
| 2330 | + | let meters = rows | |
| 2331 | + | .into_iter() | |
| 2332 | + | .map(|r| { | |
| 2333 | + | let list = crate::cycle::list_price(&r.product, &r.meter); | |
| 2334 | + | CycleMeter { | |
| 2335 | + | included: list.map(|p| if p.daily { p.included * elapsed as f64 } else { p.included }), | |
| 2336 | + | billable_quantity: r.billable_quantity.unwrap_or(0.0), | |
| 2337 | + | cost_micros: micros(r.cost_usd), | |
| 2338 | + | basis: r.basis.filter(|b| !b.is_empty()).unwrap_or_else(|| crate::cycle::BASIS_NONE.to_owned()), | |
| 2339 | + | product: r.product, | |
| 2340 | + | meter: r.meter, | |
| 2341 | + | raw_name: r.raw_name, | |
| 2342 | + | unit: r.unit, | |
| 2343 | + | quantity: r.quantity, | |
| 2344 | + | } | |
| 2345 | + | }) | |
| 2346 | + | .collect(); | |
| 2347 | + | Ok(Some(cycle_report(&cycle, elapsed, meters, monthly_micros))) | |
| 2348 | + | } | |
| 2349 | + | ||
| 2350 | + | /// The last read of billable usage (`cost_reads`). | |
| 2351 | + | async fn bill_read(&self) -> Result<Option<BillRead>> { | |
| 2352 | + | #[derive(Deserialize)] | |
| 2353 | + | struct Row { | |
| 2354 | + | read_at: String, | |
| 2355 | + | since: String, | |
| 2356 | + | until: String, | |
| 2357 | + | rows: u32, | |
| 2358 | + | pages: u32, | |
| 2359 | + | consumed_rows: u32, | |
| 2360 | + | pricing_only_rows: u32, | |
| 2361 | + | costed_rows: u32, | |
| 2362 | + | } | |
| 2363 | + | Ok(self | |
| 2364 | + | .db | |
| 2365 | + | .prepare("SELECT * FROM cost_reads WHERE source = ?1") | |
| 2366 | + | .bind(&[SOURCE_BILLABLE.into()])? | |
| 2367 | + | .first::<Row>(None) | |
| 2368 | + | .await? | |
| 2369 | + | .map(|r| BillRead { | |
| 2370 | + | read_at: r.read_at, | |
| 2371 | + | since: r.since, | |
| 2372 | + | until: r.until, | |
| 2373 | + | rows: r.rows, | |
| 2374 | + | pages: r.pages, | |
| 2375 | + | consumed_rows: r.consumed_rows, | |
| 2376 | + | pricing_only_rows: r.pricing_only_rows, | |
| 2377 | + | costed_rows: r.costed_rows, | |
| 2378 | + | })) | |
| 2379 | + | } | |
| 2186 | 2380 | } | |
| 2187 | 2381 | ||
| 2382 | + | #[derive(Deserialize)] | |
| 2383 | + | struct CostSum { | |
| 2384 | + | cost: Option<f64>, | |
| 2385 | + | } | |
| 2386 | + | ||
| 2387 | + | /// Of `total` cost, what the workspaces' shares did not carry: running g1t | |
| 2388 | + | /// on days no workspace used anything. Never below zero. | |
| 2389 | + | pub(crate) fn unattributed(total: i64, shared: i64) -> i64 { | |
| 2390 | + | (total - shared).max(0) | |
| 2391 | + | } | |
| 2392 | + | ||
| 2393 | + | /// The cycle as sudo shows it: the meters, most costly first, their total, | |
| 2394 | + | /// the average day and Cloudflare's projection. | |
| 2395 | + | pub(crate) fn cycle_report(cycle: &crate::cycle::Cycle, elapsed: u32, mut meters: Vec<CycleMeter>, monthly_micros: i64) -> CloudflareCycle { | |
| 2396 | + | meters.sort_by(|a, b| b.cost_micros.cmp(&a.cost_micros).then(b.quantity.total_cmp(&a.quantity)).then(a.meter.cmp(&b.meter))); | |
| 2397 | + | let usage: i64 = meters.iter().map(|m| m.cost_micros).sum(); | |
| 2398 | + | let days = cycle.days(); | |
| 2399 | + | CloudflareCycle { | |
| 2400 | + | start: cycle.start.clone(), | |
| 2401 | + | end: cycle.end.clone(), | |
| 2402 | + | days, | |
| 2403 | + | days_elapsed: elapsed, | |
| 2404 | + | usage_micros: usage, | |
| 2405 | + | projected_micros: crate::cycle::project(usage, elapsed, days), | |
| 2406 | + | average_daily_micros: if elapsed > 0 { usage / elapsed as i64 } else { usage }, | |
| 2407 | + | subscriptions_micros: monthly_micros, | |
| 2408 | + | meters, | |
| 2409 | + | } | |
| 2410 | + | } | |
| 2411 | + | ||
| 2188 | 2412 | #[cfg(test)] | |
| 2189 | 2413 | mod tests { | |
| 2190 | 2414 | use super::*; | |
| ⋯ | |||
| 2672 | 2896 | given_credit_promotional_micros: Some(0), | |
| 2673 | 2897 | given_credit_goodwill_micros: Some(0), | |
| 2674 | 2898 | given_reset_micros: Some(models.given.reset), | |
| 2899 | + | given_unpaid_micros: Some(0), | |
| 2675 | 2900 | }; | |
| 2676 | 2901 | assert_eq!(ProductDay::from(row).given, models.given); | |
| 2677 | 2902 | } | |
| 2678 | 2903 | ||
| 2679 | 2904 | #[test] | |
| 2905 | + | fn test_mode_charges_are_never_money_in() { | |
| 2906 | + | // A $12 sandbox charge and the $20 plan, both while payments were in | |
| 2907 | + | // Stripe's test mode: valued as before, given as unpaid, no cash. | |
| 2908 | + | let mut rows = vec![ | |
| 2909 | + | usage("2026-10-06", "acme", "sandbox", 12_000_000, 12_000_000, 10_000_000), | |
| 2910 | + | UsageRow { day: "2026-10-06".into(), workspace: "acme".into(), key: "plan".into(), value: 666_667, cash: 666_667, ..UsageRow::default() }, | |
| 2911 | + | ]; | |
| 2912 | + | without_real_money(&mut rows, None); | |
| 2913 | + | assert!(rows.iter().all(|r| r.cash == 0)); | |
| 2914 | + | // A comped workspace's charge is given once, as comped. | |
| 2915 | + | let mut comped = vec![UsageRow { given: Given { comped: 500, ..Given::default() }, ..usage("2026-10-06", "flagon-io", "sandbox", 500, 500, 400) }]; | |
| 2916 | + | without_real_money(&mut comped, None); | |
| 2917 | + | assert_eq!((comped[0].cash, comped[0].given.total()), (0, 500)); | |
| 2918 | + | assert_eq!(rows[0].given.unpaid, 12_000_000); | |
| 2919 | + | assert_eq!(rows[0].value, 12_000_000); | |
| 2920 | + | assert_eq!(rows[1].given.unpaid, 666_667); | |
| 2921 | + | let (days, workspaces) = fold(&rules(), &revenue_map(), &[], &[], &rows, &BTreeSet::new()); | |
| 2922 | + | assert_eq!(days.iter().map(|d| d.cash_micros).sum::<i64>(), 0); | |
| 2923 | + | let sandboxes = days.iter().find(|d| d.bucket == "sandboxes").unwrap(); | |
| 2924 | + | assert_eq!(sandboxes.given.unpaid, sandboxes.cost()); | |
| 2925 | + | assert!(workspaces.iter().all(|w| w.revenue == 0)); | |
| 2926 | + | // Once live: from that day on, it is money. | |
| 2927 | + | let mut rows = vec![ | |
| 2928 | + | usage("2026-10-06", "acme", "sandbox", 1_000_000, 1_000_000, 800_000), | |
| 2929 | + | usage("2026-10-07", "acme", "sandbox", 1_000_000, 1_000_000, 800_000), | |
| 2930 | + | ]; | |
| 2931 | + | without_real_money(&mut rows, Some("2026-10-07")); | |
| 2932 | + | assert_eq!((rows[0].cash, rows[0].given.unpaid), (0, 1_000_000)); | |
| 2933 | + | assert_eq!((rows[1].cash, rows[1].given.unpaid), (1_000_000, 0)); | |
| 2934 | + | // When payments went live is kept from the first time it is seen. | |
| 2935 | + | assert_eq!(live_since(false, Some("2026-10-07"), "2026-10-09"), None); | |
| 2936 | + | assert_eq!(live_since(true, None, "2026-10-09").as_deref(), Some("2026-10-09")); | |
| 2937 | + | assert_eq!(live_since(true, Some("2026-10-07"), "2026-10-09").as_deref(), Some("2026-10-07")); | |
| 2938 | + | } | |
| 2939 | + | ||
| 2940 | + | #[test] | |
| 2941 | + | fn workspaces_and_running_g1t_add_up_to_the_bill() { | |
| 2942 | + | // Running g1t on a day with usage is shared; on a day with none it | |
| 2943 | + | // is no one's, and the report says so. | |
| 2944 | + | let lines = vec![ | |
| 2945 | + | line("2026-10-06", SOURCE_BILLABLE, "workers", "workers_cpu_ms", 1.0, 0.10), | |
| 2946 | + | line("2026-10-07", SOURCE_BILLABLE, "workers", "workers_cpu_ms", 1.0, 0.20), | |
| 2947 | + | line("2026-10-07", SOURCE_BILLABLE, "containers", "container_memory_per_gib_second", 1.0, 0.09), | |
| 2948 | + | ]; | |
| 2949 | + | let usage = vec![usage("2026-10-07", "acme", "sandbox", 100, 100, 80), usage("2026-10-07", "beta", "sandbox", 300, 300, 240)]; | |
| 2950 | + | let (days, workspaces) = fold(&rules(), &revenue_map(), &lines, &[], &usage, &BTreeSet::new()); | |
| 2951 | + | let total: i64 = days.iter().map(ProductDay::cost).sum(); | |
| 2952 | + | let shared: i64 = workspaces.iter().map(|w| w.cost).sum(); | |
| 2953 | + | assert_eq!(total, 390_000); | |
| 2954 | + | assert_eq!(unattributed(total, shared), 100_000); | |
| 2955 | + | assert_eq!(shared + unattributed(total, shared), total); | |
| 2956 | + | } | |
| 2957 | + | ||
| 2958 | + | #[test] | |
| 2959 | + | fn the_cycle_report_projects_as_cloudflare_does() { | |
| 2960 | + | let cycle = crate::cycle::cycle_of("2026-10-09", 28); | |
| 2961 | + | let meter = |meter: &str, cost: i64, quantity: f64| CycleMeter { meter: meter.into(), cost_micros: cost, quantity, ..CycleMeter::default() }; | |
| 2962 | + | let report = cycle_report(&cycle, 12, vec![meter("container_memory", 92_175, 126_870.0), meter("workers_cpu_ms", 200_000, 39_160_000.0), meter("d1_rows_read", 0, 61_820_000.0)], 30_000_000); | |
| 2963 | + | assert_eq!((report.start.as_str(), report.end.as_str(), report.days, report.days_elapsed), ("2026-09-28", "2026-10-27", 30, 12)); | |
| 2964 | + | assert_eq!(report.usage_micros, 292_175); | |
| 2965 | + | assert_eq!(report.projected_micros, 730_438); | |
| 2966 | + | assert_eq!(report.average_daily_micros, 24_347); | |
| 2967 | + | assert_eq!(report.meters[0].meter, "workers_cpu_ms"); | |
| 2968 | + | assert_eq!(report.meters[2].meter, "d1_rows_read"); | |
| 2969 | + | } | |
| 2970 | + | ||
| 2971 | + | #[test] | |
| 2680 | 2972 | fn reconciling_again_gives_the_same_answer() { | |
| 2681 | 2973 | assert_eq!(gateway_and_ledger(true), gateway_and_ledger(true)); | |
| 2682 | 2974 | // A reset's kept rows are read back exactly as kept: running it | |
| 6 | 6 | //! (`CLOUDFLARE_BILLING_TOKEN`, Account: Billing Read) and kept in | |
| 7 | 7 | //! `cf_subscriptions`; until a read has worked, `CLOUDFLARE_FIXED_MONTHLY_MICROS` | |
| 8 | 8 | //! stands in as an estimate. | |
| 9 | + | //! | |
| 10 | + | //! The same read says when the billing cycle starts (`current_period_start`): | |
| 11 | + | //! the day of the month every cycle starts on (`cycle`), which is when the | |
| 12 | + | //! usage bill's included amounts start again. Until it is read, | |
| 13 | + | //! `CLOUDFLARE_BILLING_DAY` says. | |
| 9 | 14 | ||
| 10 | 15 | use g1t_contracts::billing::FixedCost; | |
| 11 | 16 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 49 | 54 | out | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 57 | + | /// When the current billing cycle started, from the first paid monthly | |
| 58 | + | /// subscription that says (`current_period_start`), as YYYY-MM-DD. | |
| 59 | + | pub(crate) fn period_start(body: &Value) -> Option<String> { | |
| 60 | + | body["result"] | |
| 61 | + | .as_array()? | |
| 62 | + | .iter() | |
| 63 | + | .filter(|s| PAID.contains(&s["state"].as_str().unwrap_or("Paid"))) | |
| 64 | + | .filter(|s| s["frequency"].as_str().unwrap_or("monthly") == "monthly") | |
| 65 | + | .find_map(|s| s["current_period_start"].as_str().filter(|d| crate::cycle::anchor_of(d).is_some())) | |
| 66 | + | .map(|d| d[..10].to_owned()) | |
| 67 | + | } | |
| 68 | + | ||
| 52 | 69 | /// What the fixed cost is, and where the figure came from. | |
| 53 | 70 | pub(crate) struct Fixed { | |
| 54 | 71 | pub monthly_micros: i64, | |
| ⋯ | |||
| 65 | 82 | let body = keeper.subscriptions_body().await?; | |
| 66 | 83 | let items = monthly(&body); | |
| 67 | 84 | let total: i64 = items.iter().map(|i| i.monthly_micros).sum(); | |
| 85 | + | let cycle_start = period_start(&body); | |
| 68 | 86 | self.db | |
| 69 | 87 | .prepare( | |
| 70 | − | "INSERT INTO cf_subscriptions (id, monthly_micros, detail, read_at) VALUES ('current', ?1, ?2, ?3) | |
| 71 | − | ON CONFLICT (id) DO UPDATE SET monthly_micros = excluded.monthly_micros, detail = excluded.detail, read_at = excluded.read_at", | |
| 88 | + | "INSERT INTO cf_subscriptions (id, monthly_micros, detail, read_at, cycle_start) VALUES ('current', ?1, ?2, ?3, ?4) | |
| 89 | + | ON CONFLICT (id) DO UPDATE SET monthly_micros = excluded.monthly_micros, detail = excluded.detail, read_at = excluded.read_at, | |
| 90 | + | cycle_start = COALESCE(excluded.cycle_start, cf_subscriptions.cycle_start)", | |
| 72 | 91 | ) | |
| 73 | − | .bind(&[(total as f64).into(), serde_json::to_string(&items)?.into(), rfc3339(now_ms()).into()])? | |
| 92 | + | .bind(&[ | |
| 93 | + | (total as f64).into(), | |
| 94 | + | serde_json::to_string(&items)?.into(), | |
| 95 | + | rfc3339(now_ms()).into(), | |
| 96 | + | cycle_start.map_or(worker::wasm_bindgen::JsValue::NULL, |d| d.into()), | |
| 97 | + | ])? | |
| 74 | 98 | .run() | |
| 75 | 99 | .await?; | |
| 76 | 100 | Ok(items.len()) | |
| 77 | 101 | } | |
| 78 | 102 | ||
| 103 | + | /// The day of the month Cloudflare's billing cycle starts on: from the | |
| 104 | + | /// subscriptions as last read, else `CLOUDFLARE_BILLING_DAY`, else the 1st. | |
| 105 | + | pub(crate) async fn cycle_anchor(&self) -> Result<u32> { | |
| 106 | + | #[derive(Deserialize)] | |
| 107 | + | struct Row { | |
| 108 | + | cycle_start: Option<String>, | |
| 109 | + | } | |
| 110 | + | let read = self | |
| 111 | + | .db | |
| 112 | + | .prepare("SELECT cycle_start FROM cf_subscriptions WHERE id = 'current'") | |
| 113 | + | .first::<Row>(None) | |
| 114 | + | .await? | |
| 115 | + | .and_then(|r| r.cycle_start) | |
| 116 | + | .and_then(|d| crate::cycle::anchor_of(&d)); | |
| 117 | + | let configured = self.env.var("CLOUDFLARE_BILLING_DAY").ok().and_then(|v| v.to_string().trim().parse::<u32>().ok()).filter(|d| (1..=31).contains(d)); | |
| 118 | + | Ok(read.or(configured).unwrap_or(crate::cycle::DEFAULT_ANCHOR)) | |
| 119 | + | } | |
| 120 | + | ||
| 79 | 121 | /// Cloudflare's subscriptions as last read, else the estimate. | |
| 80 | 122 | pub(crate) async fn fixed_monthly(&self, estimate: i64) -> Result<Fixed> { | |
| 81 | 123 | #[derive(Deserialize)] | |
| ⋯ | |||
| 125 | 167 | } | |
| 126 | 168 | ||
| 127 | 169 | #[test] | |
| 170 | + | fn the_cycle_starts_when_the_monthly_subscription_renews() { | |
| 171 | + | let body = json!({ "result": [ | |
| 172 | + | { "state": "Cancelled", "price": 5.0, "frequency": "monthly", "current_period_start": "2026-09-01T00:00:00Z" }, | |
| 173 | + | { "state": "Paid", "price": 240.0, "frequency": "yearly", "current_period_start": "2026-03-15T00:00:00Z" }, | |
| 174 | + | { "state": "Paid", "price": 5.0, "frequency": "monthly", "current_period_start": "2026-09-28T07:12:00Z" }, | |
| 175 | + | ] }); | |
| 176 | + | assert_eq!(period_start(&body).as_deref(), Some("2026-09-28")); | |
| 177 | + | assert_eq!(period_start(&json!({ "result": [{ "state": "Paid", "price": 5.0 }] })), None); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 128 | 181 | fn nothing_answered_is_nothing_paid() { | |
| 129 | 182 | assert!(monthly(&json!({ "result": [] })).is_empty()); | |
| 130 | 183 | assert!(monthly(&json!({})).is_empty()); | |
| 119 | 119 | // reconciles only what g1t counted itself. See | |
| 120 | 120 | // docs/BILLING_OPERATIONS.md. | |
| 121 | 121 | "CLOUDFLARE_ACCOUNT_ID": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 122 | + | // The day of the month the account's billing cycle starts on (Sep 28 | |
| 123 | + | // to Oct 27): the usage bill's included amounts are a cycle's | |
| 124 | + | // (src/cycle.rs). The subscriptions' read (current_period_start) | |
| 125 | + | // takes over once it has worked. | |
| 126 | + | "CLOUDFLARE_BILLING_DAY": "28", | |
| 122 | 127 | "AI_GATEWAY_ID": "g1t", | |
| 123 | 128 | // Where margin alerts go: a product or all of g1t under the margin | |
| 124 | 129 | // floor, leaks, drift. Empty sends none (sudo still shows them). |