Skip to content

Commit

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)

syntaqxcommitted Parents04bd058116681dBrowse files
16 files+1589−1190/16 viewed
+64−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts";
4+import type { CloudflareCycle, CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts";
55
66 import {
7+ basisLabel,
8+ billReadNote,
79 count,
10+ cycleHeadline,
811 daySeries,
912 daysBetween,
13+ givenParts,
1014 marginOnPrice,
1115 marginPercent,
1216 marginTone,
2327 subscriptionsOver,
2428 thresholdShare,
2529 unitDollars,
30+ unpricedMeters,
2631 versionCells,
2732 whoPaid,
2833 } from "./costs.ts";
237242 assert.equal(thresholdShare(240_000, 200_000), 120);
238243 assert.equal(thresholdShare(5, 0), null);
239244 });
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+});
+61−5
22 * Costs & margin: the arithmetic behind the page, apart from the SVG and
33 * the Workers runtime so it can be tested under Node. Money is in micros.
44 */
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";
66
77 import { parseDollars, usd } from "./money.ts";
88
6666 return { totalMicros: overall.costMicros + subscriptionsMicros, cloudflareMicros, subscriptionsMicros, modelsMicros };
6767 }
6868
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;
7176 return Math.round((monthlyMicros * days) / 30);
7277 }
7378
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+
74124 /**
75125 * A price version's cost and price as the table shows them. A rate g1t
76126 * sets has no cost behind it; a weight is a multiplier, not money.
252302 };
253303 const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" }));
254304 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";
255307 rows.push({
256308 key: "fixed",
257309 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)",
260316 });
261317 return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
262318 }
+83−2
55 import type { Route } from "./+types/costs-bill";
66 import { CostsHeader } from "~/components/costs-header";
77 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";
99 import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
1010 import { dollarsField, usd } from "~/lib/money";
1111
3737 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
3838 <CostsHeader page="bill" range={range} report={report} done={done} runError={failed?.section === "run" ? failed.error : null} description={description} />
3939
40+ <CycleSection report={report} />
41+
4042 <Section
41− className="mt-5"
43+ className="mt-6"
4244 id="drift"
4345 title="Drift"
4446 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)."
335337 );
336338 }
337339
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+
338419 function MappingsTable({ report }: { report: CostsReport }) {
339420 return (
340421 <div className="-mx-4 overflow-x-auto sm:-mx-5">
+14−16
77 import { DaysChart } from "~/components/costs";
88 import { CostsHeader, chip, costsHref } from "~/components/costs-header";
99 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";
1111 import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
1212 import { usd } from "~/lib/money";
1313
173173 </table>
174174 </div>
175175 )}
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&apos;s, so not above. The
179+ workspaces&apos; costs and this add up to the cost in the statement.
180+ </p>
181+ )}
176182 </Section>
177183
178184 </main>
216222 const usagePercent = soldSomething && o.usageMarginPercent !== undefined ? o.usageMarginPercent : null;
217223 const running = o.runningCostMicros ?? 0;
218224 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);
221227 const paid = whoPaid(o, subscriptions);
222228 const moneyIn = o.usageMicros + o.plansMicros;
223229 const spent = paid.totalMicros;
224230 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;
235233 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
236234 {
237235 title: "Usage sold",
259257 title: "Cloudflare subscriptions",
260258 note:
261259 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`
263261 : "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS) until Cloudflare's list is read; not on the usage bill",
264262 in: null,
265263 cost: subscriptions,
300298 <Stat
301299 label="Who g1t paid"
302300 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`}
304302 />
305303 <Stat
306304 label="Proposals waiting"
493491 <p className="mt-3 text-xs text-muted">
494492 Why this differs from the statement above: this is {caps.month} so far, the statement the last {range} days. The statement takes Cloudflare&apos;s bill as the
495493 cost of what Cloudflare runs, so sandboxes and builds inside Cloudflare&apos;s included usage cost nothing there, and here what the price book says they
496− cost. Here are Cloudflare&apos;s subscriptions for the whole month, there for the range.
494+ cost. Cloudflare&apos;s subscriptions are counted the same way in both: each day its billing cycle&apos;s share of the month&apos;s price.
497495 {(caps.resetMicros ?? 0) > 0 && (
498496 <>
499497 {" "}
+89−0
33133313 /// given, never a leak.
33143314 #[serde(default)]
33153315 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,
33163320 /// Credits over the range: given (every kind), spent on usage, and
33173321 /// refunds' money given back.
33183322 #[serde(default)]
33413345 /// they pay Stripe's fee, so they are not revenue either.
33423346 #[serde(default)]
33433347 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,
33443358 }
33453359
33463360 /// A count, cost or leak that does not add up.
35433557 /// g1t's own spend against its two caps.
35443558 #[serde(default)]
35453559 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,
35463572 }
35473573
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+
35483633 /// What g1t itself pays for, against its caps (billing's `budget`): the
35493634 /// daily breaker on all of it, and each comped account's monthly budget.
35503635 /// One of Cloudflare's subscriptions, at what it comes to a month.
35953680 /// Each subscription, when read from Cloudflare.
35963681 #[serde(default)]
35973682 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,
35983687 /// Money in this month, through the last reconciled day.
35993688 pub revenue_micros: i64,
36003689 /// Of this month's buckets, what was spent on workspaces whose billing
+138−27
22
33 How g1t checks what it charges against what Cloudflare charges it, keeps
44 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`
67 (reconciliation, drift, alerts), `pricing.rs` (versions, proposals,
78 notice), `keeper.rs` (sandbox and Workers for Platforms measurements),
89 `budget.rs` (what g1t pays for itself, and its caps; see
1920
2021 | Source | What | Where it lands |
2122 | --- | --- | --- |
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` |
2324 | 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` |
2425 | 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. |
2526 | 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 |
2627 | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
2728 | `plan_payments` | The plan's $20. | read |
2829 | 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`) |
3031 | 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) |
3132
3233 A meter's slug is Cloudflare's name lower-cased with words joined by `_`
3536 `workers_for_platforms_cpu_ms`. Several rows of the same day and meter
3637 (regions, tiers) are added together before they are stored.
3738
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+
38121 ## Credentials
39122
40123 | Secret on g1t-billing | Permissions | Used for |
61144 1. The keeper's measurements (sandbox seconds, app requests and CPU), each
62145 a proposal now, not a direct change.
63146 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.
69156 2. g1t's own counts for the same days (replaced per day).
70157 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
72160 `margin_days` and `workspace_costs`
73161 (replaced per day).
74162 5. Drift over the last 7 days into `cost_drift`.
103191
104192 For each day and bucket:
105193
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
109198 tokens instead; that is paid to the model providers and is not on
110199 Cloudflare's bill. Its "Cloudflare" column is what AI Gateway priced the
111200 same traffic at, which drift compares with the ledger (below); it is
116205 - **Value** = what customers were charged at price: `-amount_micros` plus
117206 what the plan's included usage, a trial, the open-source pool or g1t paid.
118207 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)).
122212 - **Given away** = the part of the cost that went on usage g1t paid for
123213 itself on purpose, by why:
124214 - **comped**: all of a comped workspace's cost, every bucket;
141231 0046). The model calls and Cloudflare usage still happened, so the
142232 reconciliation reads the kept rows back as that workspace's usage on
143233 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.
145243
146244 Otherwise a workspace's day is split by those shares of its value at
147245 price, and the same shares of each of its buckets' cost are given, its
163261 sits near 16.7%.
164262 - **Running g1t**: the plan's price against `platform` less its given
165263 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.
169269 - **Not mapped**: billed, charged for by nothing.
170270 - **Given away**: by why. A budget, watched under g1t's own spend, never
171271 shown as a loss.
172272 - **All in**: money in against all of it, with the figure without what
173273 was given beside it. **Who g1t paid** is All in's cost, split into
174274 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.
176279
177280 The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash.
178281 - **Quantities**: where a mapping names an `own_meter`, Cloudflare's
186289 each was charged for it. `platform`
187290 and `unmapped` are shared by each workspace's share of all usage that
188291 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.
190296
191297 ## Drift (last 7 days)
192298
789895 `margin_days.given_credit_{promotional,goodwill}_micros`, and backfills
790896 earlier credits (see [Credits from g1t](#credits-from-g1t)). Migration
791897 `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)).
793903
794904 ## Spend caps
795905
825935 so far, today included, counted as each charge settled: the model
826936 providers' cost and the price book's cost of sandboxes and builds. The
827937 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
831942 wiped later stays here (sudo names it, `SpendCaps.reset_micros`), while the
832943 statement has it only where the reset kept it (`reset_costs`, given away as
833944 testing resets). syntaqx's $7.41 of 2026-10-02 to 10-05, reset on 10-07
841952 | 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. |
842953
843954 `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.
846957
847958 The checks are cheap: `reserve` reads today's total (one indexed sum) and,
848959 for a comped account, its month's comped rows. Refusals come back as
+59−0
15391539 taxCollectedMicros?: number;
15401540 /** Card processing fees passed on with card payments, net of refunds: they pay Stripe's fee, not revenue. */
15411541 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;
15421548 };
15431549
15441550 /** A count, cost or leak that does not add up. */
16801686 settings: CostSettings;
16811687 /** g1t's own spend against its two caps. */
16821688 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;
16831728 };
16841729
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+
16851742 /** One level of the platform pause, as sudo shows it. Snake case, as billing sends it. */
16861743 export type PauseState = {
16871744 level: PauseLevel;
17691826 fixedReadAt?: string | null;
17701827 /** Each subscription, when read from Cloudflare. */
17711828 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;
17721831 /** Money in this month, through the last reconciled day. */
17731832 revenueMicros: number;
17741833 /**
+39−0
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');
+4−0
519519 let reset_micros = reset.iter().filter_map(|r| r.micros).sum();
520520 let reset_workspaces = reset.into_iter().map(|r| r.account.strip_prefix("ws_").unwrap_or(&r.account).to_owned()).collect();
521521 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?);
522525 Ok(SpendCaps {
523526 reset_micros,
524527 reset_workspaces,
538541 fixed_source: fixed.source.into(),
539542 fixed_read_at: fixed.read_at,
540543 fixed_items: fixed.items,
544+ fixed_month_micros,
541545 revenue_micros,
542546 })
543547 }
+207−32
2020 //! what g1t counted (`margin`).
2121 //!
2222 //! 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.
2629 //!
2730 //! The token is `CLOUDFLARE_BILLING_TOKEN` (Account: Billing Read and
2831 //! Account Analytics Read), or the keeper's `CLOUDFLARE_USAGE_TOKEN`,
5255 pub(crate) const DAY_MS: u64 = 24 * 60 * 60 * 1000;
5356
5457 /// One day of one meter of one Cloudflare product.
55−#[derive(Clone, Debug, PartialEq)]
58+#[derive(Clone, Debug, Default, PartialEq)]
5659 pub(crate) struct CostLine {
5760 /// YYYY-MM-DD, UTC.
5861 pub day: String,
6568 pub meter: String,
6669 pub unit: String,
6770 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).
6974 pub cost_usd: f64,
7075 /// The name as Cloudflare gave it, for people.
7176 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,
7285 }
7386
7487 /// `Workers for Platforms CPU ms (First 60M ms are included)` →
126139 .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.trim().parse().ok())))
127140 }
128141
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+
129154 /// One row of billable usage, read leniently: the API is new, and its
130155 /// 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.
132158 pub(crate) fn line_from_focus(row: &Value) -> Option<CostLine> {
133159 let service = text(row, &["ServiceName", "service_name", "service"]);
134160 let day = text(row, &["ChargePeriodStart", "charge_period_start", "UsageDate", "date"]);
137163 }
138164 let family = text(row, &["ServiceFamilyName", "service_family_name", "ServiceCategory"]);
139165 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);
150168 Some(CostLine {
151169 day: day[..10].to_owned(),
152170 source: SOURCE_BILLABLE,
153171 product,
154172 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,
158181 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),
159236 })
160237 }
161238
172249 Some(existing) => {
173250 existing.quantity += line.quantity;
174251 existing.cost_usd += line.cost_usd;
252+ existing.billed_usd += line.billed_usd;
253+ existing.billable_quantity += line.billable_quantity;
175254 }
176255 None => out.push(line),
177256 }
232311 quantity: g["count"].as_f64().unwrap_or(0.0),
233312 cost_usd: 0.0,
234313 raw_name: format!("Artifacts / {kind}"),
314+ ..CostLine::default()
235315 })
236316 })
237317 .collect(),
310390 quantity,
311391 cost_usd,
312392 raw_name: format!("AI Gateway / {provider} / {model}{}", if wholesale { " (billed by Cloudflare)" } else { "" }),
393+ ..CostLine::default()
313394 };
314395 lines.push(line(name.clone(), "requests", g["count"].as_f64().unwrap_or(0.0), sum("cost").max(0.0)));
315396 lines.push(line(format!("{name}{GATEWAY_TOKENS}"), "tokens", sum("tokensIn") + sum("tokensOut"), 0.0));
575656 }
576657
577658 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`.
582667 pub(crate) async fn read_cloudflare(
583668 &self,
584669 keeper: &Keeper,
585670 problems: &mut Vec<String>,
586671 gateway: &mut GatewayRead,
587− ) -> Result<Option<(String, String, u32)>> {
672+ ) -> Result<Option<(String, String, u32, String)>> {
588673 if !keeper.can_read_bill() {
589674 return Ok(None);
590675 }
602687 let (since, until) = window(last.as_deref(), now_ms());
603688 let fetched_at = rfc3339(now_ms());
604689 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+ }
607717 Err(error) => problems.push(format!("Cloudflare's billable usage could not be read: {error}")),
608718 }
609719 match keeper.graphql(artifacts_variables(keeper.account(), &since, &until)).await.map_err(|e| e.to_string()) {
653763 }
654764 }
655765 }
656− Ok(Some((since, until, written)))
766+ Ok(Some((since, until, written, bill_since)))
657767 }
658768
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+
659794 /// Cloudflare's own per-workspace counts for the days, replacing what
660795 /// was kept for them.
661796 /// The workspace of each pull request's working copy, from repos;
705840 statements.push(
706841 self.db
707842 .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)
710845 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",
712848 )
713849 .bind(&[
714850 line.day.as_str().into(),
720856 line.cost_usd.into(),
721857 line.raw_name.as_str().into(),
722858 fetched_at.into(),
859+ line.billed_usd.into(),
860+ line.billable_quantity.into(),
861+ line.basis.into(),
723862 ])?,
724863 );
725864 }
9831122 assert!((artifacts.cost_usd - 4.5).abs() < 1e-9, "billed, not list: {}", artifacts.cost_usd);
9841123 let wfp = lines.iter().find(|l| l.meter.starts_with("workers_for_platforms")).unwrap();
9851124 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));
9881128 let memory = lines.iter().find(|l| l.product == "containers").unwrap();
9891129 assert_eq!((memory.day.as_str(), memory.quantity, memory.cost_usd), ("2026-10-03", 14_000.0, 0.0));
9901130 assert!(lines_from_billable(&json!({ "success": false, "errors": [{ "code": 10000 }] })).is_err());
9911131 }
9921132
9931133 #[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]
9941169 fn reading_the_same_days_twice_gives_the_same_lines() {
9951170 // Idempotent: the same answer aggregates to the same keys and
9961171 // amounts, so the upsert replaces rather than adds.
+416−0
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+}
+34−6
8888 .ok_or_else(|| worker::Error::RustError("no CLOUDFLARE_BILLING_TOKEN or CLOUDFLARE_USAGE_TOKEN".into()))
8989 }
9090
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
9496 }
9597
9698 /// The account's subscriptions (Workers Paid, add-ons), as Cloudflare
180182 /// The account's billable usage, one row per service per day, as
181183 /// Cloudflare reports it.
182184 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?;
186189 let rows = body["result"].as_array().cloned().unwrap_or_default();
187190 Ok(rows.iter().filter_map(UsageRow::from_value).collect())
188191 }
224227
225228 /// A request to Cloudflare's API with a bearer token; anything but 200 is
226229 /// 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+
227255 async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
228256 let headers = Headers::new();
229257 headers.set("authorization", &format!("Bearer {token}"))?;
+1−0
2525 mod closing;
2626 mod compute;
2727 mod costs;
28+mod cycle;
2829 mod margin;
2930 mod platform;
3031 mod pricing;
+319−27
120120 /// open-source pool, and discounts on an account's terms (what they took
121121 /// below cost plus the margin, `ledger.discount_micros`), and credits g1t
122122 /// 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`).
124126 /// The Team plan's included usage is paid for by the plan's price, so it is
125127 /// sold, not given; so is what a refund pays for.
126128 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
133135 pub credit_promotional: i64,
134136 pub credit_goodwill: i64,
135137 pub reset: i64,
138+ pub unpaid: i64,
136139 }
137140
138141 impl Given {
139142 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
141144 }
142145
143146 /// Credits from g1t, both kinds.
154157 self.credit_promotional += other.credit_promotional;
155158 self.credit_goodwill += other.credit_goodwill;
156159 self.reset += other.reset;
160+ self.unpaid += other.unpaid;
157161 }
158162
159163 /// The same shares of `cost` as these are of `value`, at most all of it.
173177 credit_promotional: part(self.credit_promotional),
174178 credit_goodwill: part(self.credit_goodwill),
175179 reset: part(self.reset),
180+ unpaid: part(self.unpaid),
176181 }
177182 }
178183 }
232237 }
233238 }
234239
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+
235266 /// What a workspace was charged for one key on one day.
236267 #[derive(Clone, Debug, Default, PartialEq)]
237268 pub(crate) struct UsageRow {
10161047 given_credit_goodwill_micros: Option<i64>,
10171048 #[serde(default)]
10181049 given_reset_micros: Option<i64>,
1050+ #[serde(default)]
1051+ given_unpaid_micros: Option<i64>,
10191052 }
10201053
10211054 impl From<MarginRow> for ProductDay {
10381071 credit_promotional: r.given_credit_promotional_micros.unwrap_or(0),
10391072 credit_goodwill: r.given_credit_goodwill_micros.unwrap_or(0),
10401073 reset: r.given_reset_micros.unwrap_or(0),
1074+ unpaid: r.given_unpaid_micros.unwrap_or(0),
10411075 },
10421076 }
10431077 }
10501084 pub(crate) async fn costs_daily(&self, env: &Env, keeper: &crate::keeper::Keeper) -> Result<CostsRun> {
10511085 let mut run = CostsRun::default();
10521086 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)) => {
10551097 run.lines = lines;
1056− (since, until)
1098+ (since, until, Some(bill_since))
10571099 }
10581100 // Without the bill, still reconcile what g1t knows itself, over
10591101 // the same days the bill would be read for.
10631105 day: Option<String>,
10641106 }
10651107 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)
10671110 }
10681111 };
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− }
10751112 if let Err(error) = self.count_own(&since, &until).await {
10761113 run.problems.push(format!("g1t's own counts could not be read: {error}"));
10771114 }
10801117 // bill was read for: it reads only what is already kept, so a change
10811118 // in how a day is valued reaches every day shown at the next run.
10821119 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();
10841121 run.days = self.reconcile_range(&reconcile_from, &until).await?;
10851122 let drift = self.find_drift(&until, &gateway).await?;
10861123 run.proposals = self.measure_units(&until).await?;
12441281 }
12451282 }
12461283 }
1284+ // Charges without real money behind them are not money in.
1285+ without_real_money(&mut out, self.payments_live_since().await?.as_deref());
12471286 Ok(out)
12481287 }
12491288
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+
12501320 /// Which bucket each ledger key (and month-end source) is revenue of.
12511321 async fn revenue_map(&self) -> Result<BTreeMap<String, String>> {
12521322 #[derive(Deserialize)]
13871457 statements.push(
13881458 self.db
13891459 .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
13921462 )
13931463 .bind(&[
13941464 d.day.as_str().into(),
14081478 (d.given.credit_promotional as f64).into(),
14091479 (d.given.credit_goodwill as f64).into(),
14101480 (d.given.reset as f64).into(),
1481+ (d.given.unpaid as f64).into(),
14111482 now.as_str().into(),
14121483 ])?,
14131484 );
19642035 overall.given_credit_promotional_micros += d.given.credit_promotional;
19652036 overall.given_credit_goodwill_micros += d.given.credit_goodwill;
19662037 overall.given_reset_micros += d.given.reset;
2038+ overall.given_unpaid_micros += d.given.unpaid;
19672039 let sold = (d.cost() - d.given.total()).max(0);
19682040 if OVERHEAD.contains(&d.bucket.as_str()) {
19692041 overall.plans_micros += d.cash_micros;
19932065 struct Included {
19942066 micros: Option<i64>,
19952067 }
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+ };
20082085 let usage_in = overall.usage_micros + overall.included_micros;
20092086 overall.usage_margin_micros = usage_in - overall.usage_cost_micros;
20102087 overall.usage_margin_percent = margin_percent(usage_in, overall.usage_cost_micros);
21542231 }
21552232 let fetched_at = self.db.prepare("SELECT MAX(fetched_at) AS at FROM cost_lines").first::<Fetched>(None).await?.and_then(|f| f.at);
21562233
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+
21572266 Ok(CostsReport {
21582267 configured,
21592268 fetched_at,
21812290 mappings,
21822291 settings: self.cost_settings().await?,
21832292 caps: self.spend_caps().await?,
2293+ cycle,
2294+ bill_read,
2295+ unattributed_micros,
21842296 })
21852297 }
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+ }
21862380 }
21872381
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+
21882412 #[cfg(test)]
21892413 mod tests {
21902414 use super::*;
26722896 given_credit_promotional_micros: Some(0),
26732897 given_credit_goodwill_micros: Some(0),
26742898 given_reset_micros: Some(models.given.reset),
2899+ given_unpaid_micros: Some(0),
26752900 };
26762901 assert_eq!(ProductDay::from(row).given, models.given);
26772902 }
26782903
26792904 #[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]
26802972 fn reconciling_again_gives_the_same_answer() {
26812973 assert_eq!(gateway_and_ledger(true), gateway_and_ledger(true));
26822974 // A reset's kept rows are read back exactly as kept: running it
+56−3
66 //! (`CLOUDFLARE_BILLING_TOKEN`, Account: Billing Read) and kept in
77 //! `cf_subscriptions`; until a read has worked, `CLOUDFLARE_FIXED_MONTHLY_MICROS`
88 //! 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.
914
1015 use g1t_contracts::billing::FixedCost;
1116 use g1t_contracts::time::rfc3339;
4954 out
5055 }
5156
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+
5269 /// What the fixed cost is, and where the figure came from.
5370 pub(crate) struct Fixed {
5471 pub monthly_micros: i64,
6582 let body = keeper.subscriptions_body().await?;
6683 let items = monthly(&body);
6784 let total: i64 = items.iter().map(|i| i.monthly_micros).sum();
85+ let cycle_start = period_start(&body);
6886 self.db
6987 .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)",
7291 )
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+ ])?
7498 .run()
7599 .await?;
76100 Ok(items.len())
77101 }
78102
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+
79121 /// Cloudflare's subscriptions as last read, else the estimate.
80122 pub(crate) async fn fixed_monthly(&self, estimate: i64) -> Result<Fixed> {
81123 #[derive(Deserialize)]
125167 }
126168
127169 #[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]
128181 fn nothing_answered_is_nothing_paid() {
129182 assert!(monthly(&json!({ "result": [] })).is_empty());
130183 assert!(monthly(&json!({})).is_empty());
+5−0
119119 // reconciles only what g1t counted itself. See
120120 // docs/BILLING_OPERATIONS.md.
121121 "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",
122127 "AI_GATEWAY_ID": "g1t",
123128 // Where margin alerts go: a product or all of g1t under the margin
124129 // floor, leaks, drift. Empty sends none (sudo still shows them).