Commit

Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing

syntaqxcommitted Parent847b81bBrowse files
16 files+833−5180/16 viewed
+141−0
1+/**
2+ * What both costs pages open with: the title, the run button, the two
3+ * tabs, the range, and what needs attention.
4+ */
5+import { Link } from "react-router";
6+
7+import type { CostsReport } from "@g1t/contracts";
8+
9+import { Button, Notice, PageHeader } from "~/components/ui";
10+
11+export const RANGES = [7, 30, 90];
12+
13+export function chip(active: boolean) {
14+ return `inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
15+ active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
16+ }`;
17+}
18+
19+/** A costs page's address with its range (and, on Costs & margin, its product). */
20+export function costsHref(page: "costs" | "bill", range: number, bucket: string | null = null) {
21+ const params = new URLSearchParams();
22+ if (range !== 30) params.set("days", String(range));
23+ if (bucket && page === "costs") params.set("product", bucket);
24+ const query = params.toString();
25+ const path = page === "costs" ? "/costs" : "/costs/bill";
26+ return query ? `${path}?${query}` : path;
27+}
28+
29+const TABS = [
30+ { page: "costs", title: "Costs & margin" },
31+ { page: "bill", title: "Bill & pricing" },
32+] as const;
33+
34+export function CostsHeader({
35+ page,
36+ range,
37+ report,
38+ done,
39+ runError,
40+ description,
41+}: {
42+ page: "costs" | "bill";
43+ range: number;
44+ report: CostsReport | null;
45+ done: string | null;
46+ runError: string | null;
47+ description: string;
48+}) {
49+ // Margin under the floor at the top of Costs & margin; drift and leaks
50+ // at the top of Bill & pricing; workspaces on Reach out.
51+ const alerts = report?.alerts ?? [];
52+ const here = alerts.filter((a) => (page === "costs" ? a.kind === "overall" || a.kind === "margin" : a.kind === "drift" || a.kind === "leak"));
53+ const drift = alerts.filter((a) => a.kind === "drift" || a.kind === "leak").length;
54+ const workspaces = alerts.filter((a) => a.kind === "workspace").length;
55+ const margin = alerts.filter((a) => a.kind === "overall" || a.kind === "margin").length;
56+ return (
57+ <>
58+ <PageHeader title={TABS.find((t) => t.page === page)!.title} description={description} actions={<RunButton />} />
59+ <nav aria-label="Costs" className="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-b border-line">
60+ {TABS.map((tab) => (
61+ <Link
62+ key={tab.page}
63+ to={costsHref(tab.page, range)}
64+ aria-current={tab.page === page ? "page" : undefined}
65+ className={`-mb-px border-b-2 pb-2 text-sm ${tab.page === page ? "border-merged text-fg" : "border-transparent text-muted hover:text-fg"}`}
66+ >
67+ {tab.title}
68+ </Link>
69+ ))}
70+ </nav>
71+ <nav aria-label="Range" className="mt-4 flex flex-wrap gap-2">
72+ {RANGES.map((days) => (
73+ <Link key={days} to={costsHref(page, days)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}>
74+ Last {days} days
75+ </Link>
76+ ))}
77+ </nav>
78+ <div className="mt-5 space-y-3 empty:hidden">
79+ {done && <Notice tone="ok">{done}</Notice>}
80+ {runError && <Notice tone="error">{runError}</Notice>}
81+ {report && !report.configured && (
82+ <Notice tone="warn">
83+ Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account:
84+ Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing.
85+ </Notice>
86+ )}
87+ {here.map((alert) => (
88+ <Notice key={alert.id} tone={alert.kind === "drift" ? "warn" : "error"}>
89+ {alert.detail} <span className="text-faint">Since {alert.since}.</span>
90+ </Notice>
91+ ))}
92+ {page === "costs" && (drift > 0 || workspaces > 0) && (
93+ <p className="text-xs text-muted">
94+ Also open:{" "}
95+ {drift > 0 && (
96+ <Link to={costsHref("bill", range) + "#drift"} className="underline underline-offset-2">
97+ {drift} on Bill &amp; pricing
98+ </Link>
99+ )}
100+ {drift > 0 && workspaces > 0 && ", "}
101+ {workspaces > 0 && (
102+ <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2">
103+ {workspaces} workspace{workspaces === 1 ? "" : "s"} on Reach out
104+ </Link>
105+ )}
106+ .
107+ </p>
108+ )}
109+ {page === "bill" && margin > 0 && (
110+ <p className="text-xs text-muted">
111+ Also open:{" "}
112+ <Link to={costsHref("costs", range)} className="underline underline-offset-2">
113+ margin under the floor on Costs &amp; margin
114+ </Link>
115+ .
116+ </p>
117+ )}
118+ </div>
119+ </>
120+ );
121+}
122+
123+/**
124+ * The button that runs the nightly analysis now. sudo runs no JavaScript,
125+ * so app.css's run-button rules say it is running while the page waits.
126+ */
127+function RunButton() {
128+ return (
129+ <form method="post" className="flex items-center gap-3">
130+ <input type="hidden" name="intent" value="run" />
131+ <Button variant="quiet" type="submit" className="run-button">
132+ <span className="run-idle">Run the analysis now</span>
133+ <span className="run-busy">
134+ <span className="run-spinner" aria-hidden="true" />
135+ Running the analysis…
136+ </span>
137+ </Button>
138+ <span className="run-note text-xs text-muted">Reading the bill and reconciling 31 days; about a minute.</span>
139+ </form>
140+ );
141+}
+89−0
1+/**
2+ * Costs & margin and Bill & pricing: one report from billing, read by both
3+ * pages, and the changes staff make on either.
4+ */
5+import { data, redirect } from "react-router";
6+
7+import { parseCostSettings, parseMapping, parseRange } from "./costs";
8+import { admin } from "./services.server";
9+import { settle } from "./settle";
10+import { requireStaff } from "./staff";
11+
12+export const DONE: Record<string, string> = {
13+ run: "Ran the analysis: read Cloudflare's bill, reconciled the last 31 days and checked the alerts. The figures below are fresh.",
14+ approved: "Approved. A fall applies now; a rise after the notice period, and owners on the plan are emailed.",
15+ rejected: "Rejected, with the note kept for whoever measures it next.",
16+ settings: "Guardrails saved. They apply from the next run.",
17+ mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
18+ removed: "Mapping removed.",
19+ lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.",
20+};
21+
22+export type CostsActionResult = { error: string; section: string; values?: Record<string, string> };
23+
24+export async function costsLoader(request: Request, context: unknown) {
25+ requireStaff(context as Parameters<typeof requireStaff>[0]);
26+ const url = new URL(request.url);
27+ const range = parseRange(url.searchParams.get("days"));
28+ const report = await settle(admin.costs(range));
29+ const done = url.searchParams.get("done");
30+ return {
31+ range,
32+ bucket: url.searchParams.get("product"),
33+ report: report.ok ? report.value : null,
34+ error: report.ok ? null : report.error,
35+ done: done ? (DONE[done] ?? null) : null,
36+ };
37+}
38+
39+export async function costsAction(request: Request, context: unknown) {
40+ const staff = requireStaff(context as Parameters<typeof requireStaff>[0]);
41+ const form = await request.formData();
42+ const intent = String(form.get("intent") ?? "");
43+ const fail = (section: string, error: string) =>
44+ data<CostsActionResult>({ error, section, values: Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])) }, { status: 422 });
45+ const back = (done: string, hash = "") => {
46+ const url = new URL(request.url);
47+ url.searchParams.set("done", done);
48+ return redirect(`${url.pathname}${url.search}${hash}`);
49+ };
50+ if (intent === "run") {
51+ const run = await settle(admin.runCosts(staff.email));
52+ if (!run.ok) return fail("run", `Billing did not answer: ${run.error}`);
53+ if (!run.value.ok) return fail("run", run.value.error.message);
54+ if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" "));
55+ throw back("run");
56+ }
57+ if (intent === "lift") {
58+ const note = String(form.get("note") ?? "").trim().slice(0, 500);
59+ if (note.length < 5) return fail("lift", "Say why it is lifted, for whoever looks next.");
60+ const result = await settle(admin.liftBreaker(note, staff.email));
61+ if (!result.ok) return fail("lift", `Billing did not answer: ${result.error}`);
62+ if (!result.value.ok) return fail("lift", result.value.error.message);
63+ throw back("lifted", "#spend");
64+ }
65+ if (intent === "decide") {
66+ const id = String(form.get("id") ?? "");
67+ const decision = form.get("decision") === "approve" ? "approve" : "reject";
68+ const note = String(form.get("note") ?? "").trim().slice(0, 500);
69+ if (decision === "reject" && note.length < 5) return fail(`proposal-${id}`, "Say why it is rejected, for whoever measures it next.");
70+ const result = await admin.decideProposal(id, decision, note, staff.email);
71+ if (!result.ok) return fail(`proposal-${id}`, result.error.message);
72+ throw back(decision === "approve" ? "approved" : "rejected", "#proposals");
73+ }
74+ if (intent === "settings") {
75+ const parsed = parseCostSettings(form);
76+ if (!parsed.ok) return fail("settings", parsed.error);
77+ const result = await admin.setCostSettings(parsed.value, staff.email);
78+ if (!result.ok) return fail("settings", result.error.message);
79+ throw back("settings", "#guardrails");
80+ }
81+ if (intent === "mapping") {
82+ const parsed = parseMapping(form);
83+ if (!parsed.ok) return fail("mapping", parsed.error);
84+ const result = await admin.setCostMapping(parsed.value, staff.email);
85+ if (!result.ok) return fail("mapping", result.error.message);
86+ throw back(parsed.value.remove ? "removed" : "mapping", "#mappings");
87+ }
88+ return fail("run", "Unknown change.");
89+}
+6−1
199199 };
200200 const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" }));
201201 rows.push({ key: "free", title: "Free tier", micros: caps.freeTierMicros, note: "Free workspaces' share of git, storage and platform, reconciled through yesterday" });
202− rows.push({ key: "fixed", title: "Cloudflare subscriptions", micros: caps.fixedMonthlyMicros, note: "A month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)" });
202+ rows.push({
203+ key: "fixed",
204+ title: "Cloudflare subscriptions",
205+ micros: caps.fixedMonthlyMicros,
206+ note: caps.fixedSource === "cloudflare" ? "A month, as Cloudflare lists them" : "A month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)",
207+ });
203208 return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
204209 }
205210
+1−1
223223 label: "Costs & margin",
224224 to: "/costs",
225225 icon: "costs",
226− about: "What Cloudflare charged g1t against what g1t charged, by product: margin, drift, and price proposals.",
226+ about: "Where g1t's money goes, with what it gave away kept apart; on Bill & pricing, Cloudflare's bill, drift and the price book.",
227227 },
228228 {
229229 label: "Agents & models",
+1−0
1717 route("overages", "routes/overages.tsx"),
1818 route("velocity", "routes/velocity.tsx"),
1919 route("costs", "routes/costs.tsx"),
20+ route("costs/bill", "routes/costs-bill.tsx"),
2021 route("abuse", "routes/abuse.tsx"),
2122 route("invoices", "routes/invoices.tsx"),
2223 route("stripe", "routes/stripe.tsx"),
+363−0
1+import { type ReactNode } from "react";
2+
3+import type { CostsReport, PriceProposal } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/costs-bill";
6+import { CostsHeader } from "~/components/costs-header";
7+import { Badge, Button, EmptyState, Field, Input, Notice, Section, When } from "~/components/ui";
8+import { countLabel, driftLabel, percentLabel, unitDollars } from "~/lib/costs";
9+import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
10+import { dollarsField, usd } from "~/lib/money";
11+
12+export const meta: Route.MetaFunction = () => [{ title: "Bill & pricing · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13+
14+export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context);
15+export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context);
16+
17+/**
18+ * Cloudflare's bill as read, what does not add up against it, and the
19+ * price book: proposals, versions, mappings and the guardrails on them.
20+ */
21+export default function CostsBill({ loaderData, actionData }: Route.ComponentProps) {
22+ const { range, report, error, done } = loaderData;
23+ const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null;
24+ const description =
25+ "Cloudflare's bill as read, line by line, what does not add up against g1t's own counts and price book, and the prices themselves: proposals waiting for a decision, every version, the mappings and the guardrails.";
26+ if (!report) {
27+ return (
28+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
29+ <CostsHeader page="bill" range={range} report={null} done={null} runError={null} description={description} />
30+ <div className="mt-5">
31+ <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
32+ </div>
33+ </main>
34+ );
35+ }
36+ return (
37+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
38+ <CostsHeader page="bill" range={range} report={report} done={done} runError={failed?.section === "run" ? failed.error : null} description={description} />
39+
40+ <Section
41+ className="mt-5"
42+ id="drift"
43+ title="Drift"
44+ 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)."
45+ >
46+ {report.drift.length === 0 ? (
47+ <p className="text-sm text-muted">Nothing has drifted.</p>
48+ ) : (
49+ <div className="-mx-4 overflow-x-auto sm:-mx-5">
50+ <table className="w-full min-w-[44rem] text-sm">
51+ <thead>
52+ <tr className="border-b border-line text-left text-xs text-muted">
53+ <th className="px-4 py-2 font-medium sm:px-5">Product</th>
54+ <th className="px-4 py-2 font-medium">Kind</th>
55+ <th className="px-4 py-2 text-right font-medium">g1t</th>
56+ <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
57+ <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
58+ </tr>
59+ </thead>
60+ <tbody>
61+ {report.drift.map((d) => {
62+ const money = d.kind !== "count";
63+ return (
64+ <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
65+ <td className="px-4 py-2.5 sm:px-5">
66+ {d.title}
67+ <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
68+ </td>
69+ <td className="px-4 py-2.5">
70+ <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
71+ </td>
72+ <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
73+ <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
74+ <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
75+ </tr>
76+ );
77+ })}
78+ </tbody>
79+ </table>
80+ </div>
81+ )}
82+ </Section>
83+
84+ <Section
85+ className="mt-6"
86+ id="proposals"
87+ title="Price proposals"
88+ description={`What the keeper and the reconciler measured. Moves within ${report.settings.autoApplyPercent}% apply themselves${report.settings.autoApply ? "" : " (off now)"}; larger ones wait here. A fall applies at once; a rise after ${report.settings.noticeDays} days' notice, and a monthly meter's from the start of the next month.`}
89+ >
90+ {report.proposals.length === 0 ? (
91+ <p className="text-sm text-muted">No proposals yet.</p>
92+ ) : (
93+ <ul className="space-y-3">
94+ {report.proposals.map((p) => (
95+ <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
96+ ))}
97+ </ul>
98+ )}
99+ </Section>
100+
101+ <Section className="mt-6" title="Price versions" description="Every price as it was and will be. A version never changes once written; each charge records the version it was made at.">
102+ <div className="-mx-4 overflow-x-auto sm:-mx-5">
103+ <table className="w-full min-w-[44rem] text-sm">
104+ <thead>
105+ <tr className="border-b border-line text-left text-xs text-muted">
106+ <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
107+ <th className="px-4 py-2 text-right font-medium">Cost</th>
108+ <th className="px-4 py-2 text-right font-medium">Price</th>
109+ <th className="px-4 py-2 font-medium">From</th>
110+ <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
111+ </tr>
112+ </thead>
113+ <tbody>
114+ {report.versions.map((v) => (
115+ <tr key={v.id} className="border-b border-line align-top last:border-0">
116+ <td className="px-4 py-2.5 sm:px-5">
117+ <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
118+ {!v.appliedAt && (
119+ <span className="ml-2">
120+ <Badge tone="info">Coming</Badge>
121+ </span>
122+ )}
123+ </td>
124+ <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
125+ <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
126+ <td className="px-4 py-2.5 text-xs text-muted">
127+ <When at={v.effectiveAt} />
128+ </td>
129+ <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
130+ {v.reason} · {v.createdBy}
131+ </td>
132+ </tr>
133+ ))}
134+ </tbody>
135+ </table>
136+ </div>
137+ </Section>
138+
139+ <Section
140+ className="mt-6"
141+ title="Cloudflare's lines"
142+ description="Every meter Cloudflare billed or counted in the range, as it named it, and which of g1t's products it is a cost of. Not mapped means no one decided what pays for it."
143+ >
144+ {report.lines.length === 0 ? (
145+ <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Run the analysis now”.</EmptyState>
146+ ) : (
147+ <div className="-mx-4 overflow-x-auto sm:-mx-5">
148+ <table className="w-full min-w-[48rem] text-sm">
149+ <thead>
150+ <tr className="border-b border-line text-left text-xs text-muted">
151+ <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
152+ <th className="px-4 py-2 text-right font-medium">Quantity</th>
153+ <th className="px-4 py-2 text-right font-medium">Cost</th>
154+ <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
155+ </tr>
156+ </thead>
157+ <tbody>
158+ {report.lines.map((l) => (
159+ <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
160+ <td className="px-4 py-2.5 sm:px-5">
161+ <span className="font-mono text-xs">
162+ {l.product} / {l.meter}
163+ </span>
164+ <span className="block text-xs text-faint">
165+ {l.rawName}
166+ {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
167+ </span>
168+ </td>
169+ <td className="tabular px-4 py-2.5 text-right">
170+ {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
171+ </td>
172+ <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
173+ <td className="px-4 py-2.5 sm:pr-5">{l.bucket ? <span className="text-fg-soft">{l.bucket}</span> : <Badge tone="danger">Not mapped</Badge>}</td>
174+ </tr>
175+ ))}
176+ </tbody>
177+ </table>
178+ </div>
179+ )}
180+ </Section>
181+
182+ <Section
183+ className="mt-6"
184+ id="mappings"
185+ title="Mappings"
186+ description="Which of g1t's products each Cloudflare meter is a cost of. The longest matching meter prefix wins; * is the rest of the product. A price meter is measured from it; an own meter is g1t's count to compare; scaling prices one of g1t's units at as many of Cloudflare's as it took."
187+ >
188+ <MappingsTable report={report} />
189+ <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
190+ <input type="hidden" name="intent" value="mapping" />
191+ <Field label="Cloudflare product">
192+ <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
193+ </Field>
194+ <Field label="Meter prefix" hint="Or * for all of it">
195+ <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
196+ </Field>
197+ <Field label="g1t product">
198+ <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
199+ </Field>
200+ <Field label="Drift threshold, %">
201+ <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
202+ </Field>
203+ <Field label="Price meter" hint="Optional">
204+ <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
205+ </Field>
206+ <Field label="Own meter" hint="Optional">
207+ <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
208+ </Field>
209+ <Field label="Note">
210+ <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
211+ </Field>
212+ <div className="flex flex-col justify-end gap-2">
213+ <label className="flex items-center gap-2 text-sm text-muted">
214+ <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
215+ </label>
216+ <div className="flex gap-2">
217+ <Button type="submit">Save mapping</Button>
218+ <Button type="submit" name="remove" value="1" variant="danger">
219+ Remove
220+ </Button>
221+ </div>
222+ </div>
223+ {failed?.section === "mapping" && (
224+ <div className="sm:col-span-2 lg:col-span-4">
225+ <Notice tone="error">{failed.error}</Notice>
226+ </div>
227+ )}
228+ </form>
229+ </Section>
230+
231+ <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
232+ <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
233+ <input type="hidden" name="intent" value="settings" />
234+ <Field label="Apply small moves on their own">
235+ <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
236+ <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
237+ </label>
238+ </Field>
239+ <Field label="Up to, either way, %">
240+ <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
241+ </Field>
242+ <Field label="Notice for a rise, days">
243+ <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
244+ </Field>
245+ <Field label="Margin floor, %">
246+ <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
247+ </Field>
248+ <Field label="Alert after, days in a row">
249+ <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
250+ </Field>
251+ <Field label="Ignore days costing under, $">
252+ <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
253+ </Field>
254+ <Field label="Flag a workspace costing more than its revenue ×">
255+ <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
256+ </Field>
257+ <Field label="And at least, $">
258+ <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
259+ </Field>
260+ <div className="sm:col-span-2 lg:col-span-4">
261+ {failed?.section === "settings" && (
262+ <div className="mb-3">
263+ <Notice tone="error">{failed.error}</Notice>
264+ </div>
265+ )}
266+ <Button type="submit">Save guardrails</Button>
267+ </div>
268+ </form>
269+ </Section>
270+ </main>
271+ );
272+}
273+
274+function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
275+ const rise = p.proposedCostMicros > p.currentCostMicros;
276+ const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
277+ return (
278+ <li className="rounded-md border border-line px-4 py-3">
279+ <div className="flex flex-wrap items-baseline justify-between gap-2">
280+ <div className="min-w-0">
281+ <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
282+ </div>
283+ <div className="flex items-center gap-2">
284+ {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
285+ <Badge tone={statusTone}>{p.status}</Badge>
286+ </div>
287+ </div>
288+ <p className="tabular mt-1.5 text-sm">
289+ Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
290+ <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
291+ </p>
292+ <p className="mt-1 text-xs text-muted">{p.reason}</p>
293+ <p className="mt-1 text-xs text-faint">
294+ From the {p.source}, <When at={p.createdAt} time />
295+ {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
296+ {p.effectiveAt ? (
297+ <>
298+ {" "}
299+ · in force from <When at={p.effectiveAt} />
300+ </>
301+ ) : null}
302+ {p.note ? ` · “${p.note}”` : ""}
303+ </p>
304+ {p.status === "open" && (
305+ <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
306+ <input type="hidden" name="intent" value="decide" />
307+ <input type="hidden" name="id" value={p.id} />
308+ <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
309+ <div className="flex gap-2">
310+ <Button type="submit" name="decision" value="approve" variant="lavender">
311+ Approve
312+ </Button>
313+ <Button type="submit" name="decision" value="reject" variant="quiet">
314+ Reject
315+ </Button>
316+ </div>
317+ </form>
318+ )}
319+ {error && (
320+ <div className="mt-2">
321+ <Notice tone="error">{error}</Notice>
322+ </div>
323+ )}
324+ </li>
325+ );
326+}
327+
328+function MappingsTable({ report }: { report: CostsReport }) {
329+ return (
330+ <div className="-mx-4 overflow-x-auto sm:-mx-5">
331+ <table className="w-full min-w-[44rem] text-sm">
332+ <thead>
333+ <tr className="border-b border-line text-left text-xs text-muted">
334+ <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
335+ <th className="px-4 py-2 font-medium">g1t product</th>
336+ <th className="px-4 py-2 font-medium">Price meter</th>
337+ <th className="px-4 py-2 font-medium">Own meter</th>
338+ <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
339+ </tr>
340+ </thead>
341+ <tbody>
342+ {report.mappings.map((m) => (
343+ <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
344+ <td className="px-4 py-2 sm:px-5">
345+ <span className="font-mono text-xs">
346+ {m.product} / {m.meter}
347+ </span>
348+ {m.note && <span className="block text-xs text-faint">{m.note}</span>}
349+ </td>
350+ <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
351+ <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
352+ <td className="px-4 py-2 font-mono text-xs text-muted">
353+ {m.ownMeter ?? "—"}
354+ {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
355+ </td>
356+ <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
357+ </tr>
358+ ))}
359+ </tbody>
360+ </table>
361+ </div>
362+ );
363+}
+40−506
11 import type { ReactNode } from "react";
2−import { Link, data, redirect } from "react-router";
2+import { Link } from "react-router";
33
4−import type { CostsReport, PriceProposal } from "@g1t/contracts";
4+import type { CostsReport } from "@g1t/contracts";
55
66 import type { Route } from "./+types/costs";
77 import { DaysChart } from "~/components/costs";
8−import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Stat, When } from "~/components/ui";
9−import {
10− capPercent,
11− countLabel,
12− daySeries,
13− driftLabel,
14− marginTone,
15− parseBucket,
16− parseCostSettings,
17− parseMapping,
18− parseRange,
19− percentLabel,
20− spendRows,
21− unitDollars,
22−} from "~/lib/costs";
23−import { dollarsField, usd } from "~/lib/money";
24−import { admin } from "~/lib/services.server";
25−import { settle } from "~/lib/settle";
26−import { requireStaff } from "~/lib/staff";
8+import { CostsHeader, chip, costsHref } from "~/components/costs-header";
9+import { Badge, Button, Field, Input, Notice, Section, Stat, When } from "~/components/ui";
10+import { capPercent, daySeries, marginTone, parseBucket, percentLabel, spendRows } from "~/lib/costs";
11+import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
12+import { usd } from "~/lib/money";
2713
2814 export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }];
2915
30−const DONE: Record<string, string> = {
31− run: "Ran the analysis: read Cloudflare's bill, reconciled the last 31 days and checked the alerts. The figures below are fresh.",
32− approved: "Approved. A fall applies now; a rise after the notice period, and owners on the plan are emailed.",
33− rejected: "Rejected, with the note kept for whoever measures it next.",
34− settings: "Guardrails saved. They apply from the next run.",
35− mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
36− removed: "Mapping removed.",
37− lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.",
38−};
39−
40−const RANGES = [7, 30, 90];
41−
42−export async function loader({ request, context }: Route.LoaderArgs) {
43− requireStaff(context);
44− const url = new URL(request.url);
45− const range = parseRange(url.searchParams.get("days"));
46− const report = await settle(admin.costs(range));
47− const done = url.searchParams.get("done");
48− return {
49− range,
50− bucket: url.searchParams.get("product"),
51− report: report.ok ? report.value : null,
52− error: report.ok ? null : report.error,
53− done: done ? (DONE[done] ?? null) : null,
54− };
55−}
56−
57−type ActionResult = { error: string; section: string; values?: Record<string, string> };
58−
59−export async function action({ request, context }: Route.ActionArgs) {
60− const staff = requireStaff(context);
61− const form = await request.formData();
62− const intent = String(form.get("intent") ?? "");
63− const fail = (section: string, error: string) =>
64− data<ActionResult>({ error, section, values: Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])) }, { status: 422 });
65− const back = (done: string, hash = "") => {
66− const url = new URL(request.url);
67− url.searchParams.set("done", done);
68− return redirect(`${url.pathname}${url.search}${hash}`);
69− };
70− if (intent === "run") {
71− const run = await settle(admin.runCosts(staff.email));
72− if (!run.ok) return fail("run", `Billing did not answer: ${run.error}`);
73− if (!run.value.ok) return fail("run", run.value.error.message);
74− if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" "));
75− throw back("run");
76− }
77− if (intent === "lift") {
78− const note = String(form.get("note") ?? "").trim().slice(0, 500);
79− if (note.length < 5) return fail("lift", "Say why it is lifted, for whoever looks next.");
80− const result = await settle(admin.liftBreaker(note, staff.email));
81− if (!result.ok) return fail("lift", `Billing did not answer: ${result.error}`);
82− if (!result.value.ok) return fail("lift", result.value.error.message);
83− throw back("lifted", "#spend");
84− }
85− if (intent === "decide") {
86− const id = String(form.get("id") ?? "");
87− const decision = form.get("decision") === "approve" ? "approve" : "reject";
88− const note = String(form.get("note") ?? "").trim().slice(0, 500);
89− if (decision === "reject" && note.length < 5) return fail(`proposal-${id}`, "Say why it is rejected, for whoever measures it next.");
90− const result = await admin.decideProposal(id, decision, note, staff.email);
91− if (!result.ok) return fail(`proposal-${id}`, result.error.message);
92− throw back(decision === "approve" ? "approved" : "rejected", "#proposals");
93− }
94− if (intent === "settings") {
95− const parsed = parseCostSettings(form);
96− if (!parsed.ok) return fail("settings", parsed.error);
97− const result = await admin.setCostSettings(parsed.value, staff.email);
98− if (!result.ok) return fail("settings", result.error.message);
99− throw back("settings", "#guardrails");
100− }
101− if (intent === "mapping") {
102− const parsed = parseMapping(form);
103− if (!parsed.ok) return fail("mapping", parsed.error);
104− const result = await admin.setCostMapping(parsed.value, staff.email);
105− if (!result.ok) return fail("mapping", result.error.message);
106− throw back(parsed.value.remove ? "removed" : "mapping", "#mappings");
107− }
108− return fail("run", "Unknown change.");
109−}
16+export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context);
17+export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context);
11018
111−function chip(active: boolean) {
112− return `inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
113− active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
114− }`;
115−}
116−
117−function href(range: number, bucket: string | null) {
118− const params = new URLSearchParams();
119− if (range !== 30) params.set("days", String(range));
120− if (bucket) params.set("product", bucket);
121− const query = params.toString();
122− return query ? `/costs?${query}` : "/costs";
123−}
124−
19+/**
20+ * Where g1t's money goes: the statement, what g1t pays for itself against
21+ * its caps, and the same by day, by product and by workspace.
22+ */
12523 export default function Costs({ loaderData, actionData }: Route.ComponentProps) {
12624 const { range, report, error, done } = loaderData;
127− const failed = actionData && "error" in actionData ? (actionData as ActionResult) : null;
25+ const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null;
26+ const description =
27+ "What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus 20%; the bill itself, drift and the price book are on Bill & pricing.";
12828 if (!report) {
12929 return (
13030 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
131− <PageHeader title="Costs & margin" />
31+ <CostsHeader page="costs" range={range} report={null} done={null} runError={null} description={description} />
13232 <div className="mt-5">
13333 <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
13434 </div>
14040 const series = daySeries(report.days, report.since, report.until, bucket);
14141 const floor = report.settings.marginFloorPercent;
14242 const open = report.proposals.filter((p) => p.status === "open");
143− // Margin under the floor at the top; drift and leaks are in their own table, workspaces on Reach out.
144− const banner = report.alerts.filter((a) => a.kind === "overall" || a.kind === "margin");
145− const elsewhere = report.alerts.length - banner.length;
14643 return (
14744 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
148− <PageHeader
149− title="Costs & margin"
150− description="What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid. Prices are cost plus 20%; this is where that is checked against the bill, where drift and leaks show, and where price changes wait for a decision."
151− actions={<RunButton />}
152− />
153− <nav aria-label="Range" className="mt-5 flex flex-wrap gap-2">
154− {RANGES.map((days) => (
155− <Link key={days} to={href(days, bucket)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}>
156− Last {days} days
157− </Link>
158− ))}
159− </nav>
160− <div className="mt-5 space-y-3 empty:hidden">
161− {done && <Notice tone="ok">{done}</Notice>}
162− {failed?.section === "run" && <Notice tone="error">{failed.error}</Notice>}
163− {!report.configured && (
164− <Notice tone="warn">
165− Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account:
166− Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing.
167− </Notice>
168− )}
169− {banner.map((alert) => (
170− <Notice key={alert.id} tone="error">
171− {alert.detail} <span className="text-faint">Since {alert.since}.</span>
172− </Notice>
173− ))}
174− {elsewhere > 0 && (
175− <p className="text-xs text-muted">
176− {elsewhere} more open: {" "}
177− <a href="#drift" className="underline underline-offset-2">
178− drift and leaks
179− </a>{" "}
180− below, and workspaces that cost more than they pay on{" "}
181− <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2">
182− Reach out
183− </Link>
184− .
185− </p>
186− )}
187− </div>
45+ <CostsHeader page="costs" range={range} report={report} done={done} runError={failed?.section === "run" ? failed.error : null} description={description} />
18846
18947 <Statement report={report} floor={floor} range={range} proposals={open.length} />
19048
20058 }
20159 >
20260 <nav aria-label="Product" className="mb-4 flex flex-wrap gap-2">
203− <Link to={href(range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}>
61+ <Link to={costsHref("costs", range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}>
20462 All of g1t
20563 </Link>
20664 {report.products.map((p) => (
207− <Link key={p.bucket} to={href(range, p.bucket)} aria-current={p.bucket === bucket ? "page" : undefined} className={chip(p.bucket === bucket)}>
65+ <Link key={p.bucket} to={costsHref("costs", range, p.bucket)} aria-current={p.bucket === bucket ? "page" : undefined} className={chip(p.bucket === bucket)}>
20866 {p.title}
20967 </Link>
21068 ))}
23694 return (
23795 <tr key={p.bucket} className="border-b border-line last:border-0">
23896 <td className="px-4 py-2.5 sm:px-5">
239− <Link to={href(range, p.bucket)} className="text-fg hover:underline">
97+ <Link to={costsHref("costs", range, p.bucket)} className="text-fg hover:underline">
24098 {p.title}
24199 </Link>
242100 <span className="block text-xs text-faint">
257115 </tr>
258116 );
259117 })}
260− </tbody>
261− </table>
262− </div>
263− </Section>
264−
265− <Section
266− className="mt-6"
267− id="drift"
268− title="Drift"
269− 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)."
270− >
271− {report.drift.length === 0 ? (
272− <p className="text-sm text-muted">Nothing has drifted.</p>
273− ) : (
274− <div className="-mx-4 overflow-x-auto sm:-mx-5">
275− <table className="w-full min-w-[44rem] text-sm">
276− <thead>
277− <tr className="border-b border-line text-left text-xs text-muted">
278− <th className="px-4 py-2 font-medium sm:px-5">Product</th>
279− <th className="px-4 py-2 font-medium">Kind</th>
280− <th className="px-4 py-2 text-right font-medium">g1t</th>
281− <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
282− <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
283− </tr>
284− </thead>
285− <tbody>
286− {report.drift.map((d) => {
287− const money = d.kind !== "count";
288− return (
289− <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
290− <td className="px-4 py-2.5 sm:px-5">
291− {d.title}
292− <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
293− </td>
294− <td className="px-4 py-2.5">
295− <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
296− </td>
297− <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
298− <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
299− <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
300− </tr>
301− );
302− })}
303− </tbody>
304− </table>
305− </div>
306− )}
307− </Section>
308−
309− <Section
310− className="mt-6"
311− id="proposals"
312− title="Price proposals"
313− description={`What the keeper and the reconciler measured. Moves within ${report.settings.autoApplyPercent}% apply themselves${report.settings.autoApply ? "" : " (off now)"}; larger ones wait here. A fall applies at once; a rise after ${report.settings.noticeDays} days' notice, and a monthly meter's from the start of the next month.`}
314− >
315− {report.proposals.length === 0 ? (
316− <p className="text-sm text-muted">No proposals yet.</p>
317− ) : (
318− <ul className="space-y-3">
319− {report.proposals.map((p) => (
320− <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
321− ))}
322− </ul>
323− )}
324− </Section>
325−
326− <Section className="mt-6" title="Price versions" description="Every price as it was and will be. A version never changes once written; each charge records the version it was made at.">
327− <div className="-mx-4 overflow-x-auto sm:-mx-5">
328− <table className="w-full min-w-[44rem] text-sm">
329− <thead>
330− <tr className="border-b border-line text-left text-xs text-muted">
331− <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
332− <th className="px-4 py-2 text-right font-medium">Cost</th>
333− <th className="px-4 py-2 text-right font-medium">Price</th>
334− <th className="px-4 py-2 font-medium">From</th>
335− <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
336− </tr>
337− </thead>
338− <tbody>
339− {report.versions.map((v) => (
340− <tr key={v.id} className="border-b border-line align-top last:border-0">
341− <td className="px-4 py-2.5 sm:px-5">
342− <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
343− {!v.appliedAt && (
344− <span className="ml-2">
345− <Badge tone="info">Coming</Badge>
346− </span>
347− )}
348− </td>
349− <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
350− <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
351− <td className="px-4 py-2.5 text-xs text-muted">
352− <When at={v.effectiveAt} />
353− </td>
354− <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
355− {v.reason} · {v.createdBy}
356− </td>
357− </tr>
358− ))}
359118 </tbody>
360119 </table>
361120 </div>
404163 )}
405164 </Section>
406165
407− <Section
408− className="mt-6"
409− title="Cloudflare's lines"
410− description="Every meter Cloudflare billed or counted in the range, as it named it, and which of g1t's products it is a cost of. Not mapped means no one decided what pays for it."
411− >
412− {report.lines.length === 0 ? (
413− <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Run the analysis now”.</EmptyState>
414− ) : (
415− <div className="-mx-4 overflow-x-auto sm:-mx-5">
416− <table className="w-full min-w-[48rem] text-sm">
417− <thead>
418− <tr className="border-b border-line text-left text-xs text-muted">
419− <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
420− <th className="px-4 py-2 text-right font-medium">Quantity</th>
421− <th className="px-4 py-2 text-right font-medium">Cost</th>
422− <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
423− </tr>
424− </thead>
425− <tbody>
426− {report.lines.map((l) => (
427− <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
428− <td className="px-4 py-2.5 sm:px-5">
429− <span className="font-mono text-xs">
430− {l.product} / {l.meter}
431− </span>
432− <span className="block text-xs text-faint">
433− {l.rawName}
434− {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
435− </span>
436− </td>
437− <td className="tabular px-4 py-2.5 text-right">
438− {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
439− </td>
440− <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
441− <td className="px-4 py-2.5 sm:pr-5">{l.bucket ? <span className="text-fg-soft">{l.bucket}</span> : <Badge tone="danger">Not mapped</Badge>}</td>
442− </tr>
443− ))}
444− </tbody>
445− </table>
446− </div>
447− )}
448− </Section>
449−
450− <Section
451− className="mt-6"
452− id="mappings"
453− title="Mappings"
454− description="Which of g1t's products each Cloudflare meter is a cost of. The longest matching meter prefix wins; * is the rest of the product. A price meter is measured from it; an own meter is g1t's count to compare; scaling prices one of g1t's units at as many of Cloudflare's as it took."
455− >
456− <MappingsTable report={report} />
457− <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
458− <input type="hidden" name="intent" value="mapping" />
459− <Field label="Cloudflare product">
460− <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
461− </Field>
462− <Field label="Meter prefix" hint="Or * for all of it">
463− <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
464− </Field>
465− <Field label="g1t product">
466− <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
467− </Field>
468− <Field label="Drift threshold, %">
469− <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
470− </Field>
471− <Field label="Price meter" hint="Optional">
472− <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
473− </Field>
474− <Field label="Own meter" hint="Optional">
475− <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
476− </Field>
477− <Field label="Note">
478− <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
479− </Field>
480− <div className="flex flex-col justify-end gap-2">
481− <label className="flex items-center gap-2 text-sm text-muted">
482− <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
483− </label>
484− <div className="flex gap-2">
485− <Button type="submit">Save mapping</Button>
486− <Button type="submit" name="remove" value="1" variant="danger">
487− Remove
488− </Button>
489− </div>
490− </div>
491− {failed?.section === "mapping" && (
492− <div className="sm:col-span-2 lg:col-span-4">
493− <Notice tone="error">{failed.error}</Notice>
494− </div>
495− )}
496− </form>
497− </Section>
498−
499− <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
500− <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
501− <input type="hidden" name="intent" value="settings" />
502− <Field label="Apply small moves on their own">
503− <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
504− <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
505− </label>
506− </Field>
507− <Field label="Up to, either way, %">
508− <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
509− </Field>
510− <Field label="Notice for a rise, days">
511− <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
512− </Field>
513− <Field label="Margin floor, %">
514− <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
515− </Field>
516− <Field label="Alert after, days in a row">
517− <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
518− </Field>
519− <Field label="Ignore days costing under, $">
520− <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
521− </Field>
522− <Field label="Flag a workspace costing more than its revenue ×">
523− <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
524− </Field>
525− <Field label="And at least, $">
526− <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
527− </Field>
528− <div className="sm:col-span-2 lg:col-span-4">
529− {failed?.section === "settings" && (
530− <div className="mb-3">
531− <Notice tone="error">{failed.error}</Notice>
532− </div>
533− )}
534− <Button type="submit">Save guardrails</Button>
535− </div>
536− </form>
537− </Section>
538166 </main>
539− );
540−}
541−
542−function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
543− const rise = p.proposedCostMicros > p.currentCostMicros;
544− const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
545− return (
546− <li className="rounded-md border border-line px-4 py-3">
547− <div className="flex flex-wrap items-baseline justify-between gap-2">
548− <div className="min-w-0">
549− <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
550− </div>
551− <div className="flex items-center gap-2">
552− {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
553− <Badge tone={statusTone}>{p.status}</Badge>
554− </div>
555− </div>
556− <p className="tabular mt-1.5 text-sm">
557− Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
558− <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
559− </p>
560− <p className="mt-1 text-xs text-muted">{p.reason}</p>
561− <p className="mt-1 text-xs text-faint">
562− From the {p.source}, <When at={p.createdAt} time />
563− {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
564− {p.effectiveAt ? (
565− <>
566− {" "}
567− · in force from <When at={p.effectiveAt} />
568− </>
569− ) : null}
570− {p.note ? ` · “${p.note}”` : ""}
571− </p>
572− {p.status === "open" && (
573− <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
574− <input type="hidden" name="intent" value="decide" />
575− <input type="hidden" name="id" value={p.id} />
576− <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
577− <div className="flex gap-2">
578− <Button type="submit" name="decision" value="approve" variant="lavender">
579− Approve
580− </Button>
581− <Button type="submit" name="decision" value="reject" variant="quiet">
582− Reject
583− </Button>
584− </div>
585− </form>
586− )}
587− {error && (
588− <div className="mt-2">
589− <Notice tone="error">{error}</Notice>
590− </div>
591− )}
592− </li>
593167 );
594168 }
595169
609183 </div>
610184 <p className="mt-1.5 text-xs text-muted">{hint}</p>
611185 </div>
612− );
613−}
614−
615−/** g1t's own spend: the daily breaker, comped budgets, and this month by what paid. */
616−/**
617− * The button that runs the nightly analysis now. sudo runs no JavaScript,
618− * so app.css's run-button rules say it is running while the page waits.
619− */
620−function RunButton() {
621− return (
622− <form method="post" className="flex items-center gap-3">
623− <input type="hidden" name="intent" value="run" />
624− <Button variant="quiet" type="submit" className="run-button">
625− <span className="run-idle">Run the analysis now</span>
626− <span className="run-busy">
627− <span className="run-spinner" aria-hidden="true" />
628− Running the analysis…
629− </span>
630− </Button>
631− <span className="run-note text-xs text-muted">Reading the bill and reconciling 31 days; about a minute.</span>
632− </form>
633186 );
634187 }
635188
685238 },
686239 {
687240 title: "Cloudflare subscriptions",
688− note: "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS), not on the usage bill",
241+ note:
242+ report.caps.fixedSource === "cloudflare"
243+ ? `Fixed, as Cloudflare lists them${(report.caps.fixedItems ?? []).length > 0 ? ` (${(report.caps.fixedItems ?? []).map((i) => i.name).join(", ")})` : ""}; not on the usage bill`
244+ : "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS) until Cloudflare's list is read; not on the usage bill",
689245 in: null,
690246 cost: subscriptions,
691247 result: -subscriptions,
726282 <Stat
727283 label="Proposals waiting"
728284 value={String(proposals)}
729− hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"}
285+ hint={
286+ <>
287+ <Link to={costsHref("bill", range) + "#proposals"} className="underline underline-offset-2">
288+ On Bill &amp; pricing
289+ </Link>
290+ {report.fetchedAt ? (
291+ <>
292+ {" "}
293+ · bill read <When at={report.fetchedAt} time />
294+ </>
295+ ) : (
296+ " · the bill has not been read yet"
297+ )}
298+ </>
299+ }
730300 tone={proposals > 0 ? "warn" : undefined}
731301 />
732302 </div>
881451 );
882452 }
883453
884−function MappingsTable({ report }: { report: CostsReport }) {
885− return (
886− <div className="-mx-4 overflow-x-auto sm:-mx-5">
887− <table className="w-full min-w-[44rem] text-sm">
888− <thead>
889− <tr className="border-b border-line text-left text-xs text-muted">
890− <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
891− <th className="px-4 py-2 font-medium">g1t product</th>
892− <th className="px-4 py-2 font-medium">Price meter</th>
893− <th className="px-4 py-2 font-medium">Own meter</th>
894− <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
895− </tr>
896− </thead>
897− <tbody>
898− {report.mappings.map((m) => (
899− <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
900− <td className="px-4 py-2 sm:px-5">
901− <span className="font-mono text-xs">
902− {m.product} / {m.meter}
903− </span>
904− {m.note && <span className="block text-xs text-faint">{m.note}</span>}
905− </td>
906− <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
907− <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
908− <td className="px-4 py-2 font-mono text-xs text-muted">
909− {m.ownMeter ?? "—"}
910− {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
911− </td>
912− <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
913− </tr>
914− ))}
915− </tbody>
916− </table>
917− </div>
918− );
919−}
+18−1
26492649
26502650 /// What g1t itself pays for, against its caps (billing's `budget`): the
26512651 /// daily breaker on all of it, and each comped account's monthly budget.
2652+/// One of Cloudflare's subscriptions, at what it comes to a month.
2653+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
2654+#[serde(rename_all = "camelCase")]
2655+pub struct FixedCost {
2656+ pub name: String,
2657+ pub monthly_micros: i64,
2658+}
2659+
26522660 /// At cost, never at price. What sudo's Costs page and its red bar show.
26532661 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
26542662 #[serde(rename_all = "camelCase")]
26782686 /// Free workspaces' share of this month's reconciled costs (git,
26792687 /// storage, platform), through yesterday.
26802688 pub free_tier_micros: i64,
2681− /// `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare subscriptions, an estimate.
2689+ /// Cloudflare's subscriptions a month: as read from Cloudflare each
2690+ /// day, else `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate.
26822691 pub fixed_monthly_micros: i64,
2692+ /// `cloudflare` or `estimate`.
2693+ #[serde(default)]
2694+ pub fixed_source: String,
2695+ #[serde(default)]
2696+ pub fixed_read_at: Option<String>,
2697+ /// Each subscription, when read from Cloudflare.
2698+ #[serde(default)]
2699+ pub fixed_items: Vec<FixedCost>,
26832700 /// Money in this month, through the last reconciled day.
26842701 pub revenue_micros: i64,
26852702 }
+10−7
2525 | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
2626 | `plan_payments` | The plan's $20. | read |
2727 | repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` |
28+| 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) |
2829 | 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) |
2930
3031 A meter's slug is Cloudflare's name lower-cased with words joined by `_`
3738
3839 | Secret on g1t-billing | Permissions | Used for |
3940 | --- | --- | --- |
40−| `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill and the Artifacts events |
41+| `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill, the Artifacts events and the subscriptions |
4142 | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper; also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set |
4243
4344 With neither, the daily run reconciles only what g1t counted itself, and
7576 8. Open, update and close margin alerts; email new ones.
7677 9. Email owners on the plan about rises to come.
7778
78−**Run the analysis now** at the top of sudo's Costs & margin page
79+**Run the analysis now** at the top of sudo's Costs & margin and Bill & pricing pages
7980 (`admin_run_costs`) runs all of step 2 at once, alerts included, with no
8081 need to wait for 04:17 UTC. Running it twice is safe: every step replaces
8182 what it wrote.
137138 sits near 16.7%.
138139 - **Running g1t**: the plan's price against `platform` less its given
139140 share.
140− - **Cloudflare subscriptions**: `CLOUDFLARE_FIXED_MONTHLY_MICROS` over
141− the range, an estimate, since they are not on the usage bill.
141+ - **Cloudflare subscriptions**: what Cloudflare lists, a month, over
142+ the range (`cf_subscriptions`); until a read has worked,
143+ `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate.
142144 - **Not mapped**: billed, charged for by nothing.
143145 - **Given away**: by why. A budget, watched under g1t's own spend, never
144146 shown as a loss.
196198
197199 ## Changing a mapping
198200
199−In sudo, Costs & margin → **Mappings**: Cloudflare's product and meter
201+In sudo, Costs & margin → Bill & pricing → **Mappings**: Cloudflare's product and meter
200202 prefix (as **Cloudflare's lines** lists them; `*` for the rest of the
201203 product), g1t's product, and optionally:
202204
311313 | A comped account's monthly budget | `COMPED_MONTHLY_CEILING_MICROS`, or the account's own **Limit** in its terms | $150 a month | New work on the account (agents, checks, workflows, builds) is refused with "<name>'s monthly budget for g1t's own agents is used up … Staff can raise it in sudo". Runs already going finish; the per-run cap still applies to them. It lifts when staff raise the budget or the month turns (UTC). |
312314 | 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. |
313315
314−`0` turns either off. `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30: Workers
315−Paid and Workers for Platforms) is shown on the page only.
316+`0` turns either off. Cloudflare's subscriptions are read from Cloudflare
317+each day (`cf_subscriptions`) and shown on the page only;
318+`CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works.
316319
317320 The checks are cheap: `reserve` reads today's total (one indexed sum) and,
318321 for a comped account, its month's comped rows. Refusals come back as
+5−1
12411241 comped: CompedBudget[];
12421242 /** Free workspaces' share of reconciled costs this month (git, storage, platform). */
12431243 freeTierMicros: number;
1244− /** `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare subscriptions, an estimate. */
1244+ /** Cloudflare's subscriptions a month: read from Cloudflare each day, else `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate. */
12451245 fixedMonthlyMicros: number;
1246+ fixedSource?: "cloudflare" | "estimate" | string;
1247+ fixedReadAt?: string | null;
1248+ /** Each subscription, when read from Cloudflare. */
1249+ fixedItems?: { name: string; monthlyMicros: number }[];
12461250 /** Money in this month, through the last reconciled day. */
12471251 revenueMicros: number;
12481252 };
+9−0
1+-- Cloudflare's subscriptions (Workers Paid, add-ons), as last read from
2+-- Cloudflare: what g1t pays each month whatever it uses. One row; detail
3+-- is each subscription's name and monthly micros, as JSON.
4+CREATE TABLE IF NOT EXISTS cf_subscriptions (
5+ id TEXT PRIMARY KEY,
6+ monthly_micros INTEGER NOT NULL,
7+ detail TEXT NOT NULL,
8+ read_at TEXT NOT NULL
9+);
+5−1
503503 .await?
504504 .and_then(|s| s.micros)
505505 .unwrap_or(0);
506+ let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?;
506507 Ok(SpendCaps {
507508 day,
508509 month,
516517 month_buckets,
517518 comped,
518519 free_tier_micros,
519− fixed_monthly_micros: self.caps.fixed_monthly,
520+ fixed_monthly_micros: fixed.monthly_micros,
521+ fixed_source: fixed.source.into(),
522+ fixed_read_at: fixed.read_at,
523+ fixed_items: fixed.items,
520524 revenue_micros,
521525 })
522526 }
+6−0
9191 send_with(self.billing_token()?, Method::Get, &self.api(&format!("/billable-usage?from={from}&to={to}")), None).await
9292 }
9393
94+ /// The account's subscriptions (Workers Paid, add-ons), as Cloudflare
95+ /// answers them. Needs Account: Billing Read.
96+ pub(crate) async fn subscriptions_body(&self) -> Result<Value> {
97+ send_with(self.billing_token()?, Method::Get, &self.api("/subscriptions"), None).await
98+ }
99+
94100 /// A GraphQL Analytics query, as Cloudflare answers it, errors and all.
95101 pub(crate) async fn graphql(&self, body: Value) -> Result<Value> {
96102 send_with(self.billing_token()?, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await
+1−0
4141 mod retention;
4242 mod stripe;
4343 mod stripe_sync;
44+mod subscriptions;
4445 mod tokens;
4546
4647 use g1t_contracts::billing::*;
+6−0
696696 costs::window(last.as_deref(), now_ms())
697697 }
698698 };
699+ // Not a problem for the run: the last read, or the estimate, stays.
700+ if keeper.can_read_bill()
701+ && let Err(error) = self.read_subscriptions(keeper).await
702+ {
703+ worker::console_error!("Cloudflare's subscriptions were not read: {error}");
704+ }
699705 if let Err(error) = self.count_own(&since, &until).await {
700706 run.problems.push(format!("g1t's own counts could not be read: {error}"));
701707 }
+132−0
1+//! Cloudflare's subscriptions: what g1t pays each month whatever it uses
2+//! (Workers Paid, add-ons), read from Cloudflare each day.
3+//!
4+//! They are not on the billable-usage bill, so the costs statement and
5+//! g1t's own spend add them as a fixed cost. Read with the bill's token
6+//! (`CLOUDFLARE_BILLING_TOKEN`, Account: Billing Read) and kept in
7+//! `cf_subscriptions`; until a read has worked, `CLOUDFLARE_FIXED_MONTHLY_MICROS`
8+//! stands in as an estimate.
9+
10+use g1t_contracts::billing::FixedCost;
11+use g1t_contracts::time::rfc3339;
12+use g1t_kit::now_ms;
13+use serde::Deserialize;
14+use serde_json::Value;
15+use worker::Result;
16+
17+use crate::Billing;
18+
19+/// The states of a subscription that is paid for (or will be).
20+const PAID: [&str; 4] = ["Paid", "Provisioned", "AwaitingPayment", "Trial"];
21+
22+/// Each subscription that costs money, at what it comes to a month.
23+pub(crate) fn monthly(body: &Value) -> Vec<FixedCost> {
24+ let mut out = Vec::new();
25+ for s in body["result"].as_array().into_iter().flatten() {
26+ let state = s["state"].as_str().unwrap_or("Paid");
27+ if !PAID.contains(&state) {
28+ continue;
29+ }
30+ let price = s["price"].as_f64().unwrap_or(0.0);
31+ let months = match s["frequency"].as_str().unwrap_or("monthly") {
32+ "weekly" => 12.0 / 52.0,
33+ "quarterly" => 3.0,
34+ "yearly" => 12.0,
35+ _ => 1.0,
36+ };
37+ let monthly_micros = (price / months * 1_000_000.0).round() as i64;
38+ if monthly_micros <= 0 {
39+ continue;
40+ }
41+ let name = s["rate_plan"]["public_name"]
42+ .as_str()
43+ .or_else(|| s["product"]["name"].as_str())
44+ .or_else(|| s["rate_plan"]["id"].as_str())
45+ .unwrap_or("Subscription")
46+ .to_owned();
47+ out.push(FixedCost { name, monthly_micros });
48+ }
49+ out
50+}
51+
52+/// What the fixed cost is, and where the figure came from.
53+pub(crate) struct Fixed {
54+ pub monthly_micros: i64,
55+ /// `cloudflare` (read from Cloudflare) or `estimate` (the variable).
56+ pub source: &'static str,
57+ pub read_at: Option<String>,
58+ pub items: Vec<FixedCost>,
59+}
60+
61+impl Billing {
62+ /// Reads the subscriptions and keeps them; an error is returned for the
63+ /// run to log, and the last read (or the estimate) stays.
64+ pub(crate) async fn read_subscriptions(&self, keeper: &crate::keeper::Keeper) -> Result<usize> {
65+ let body = keeper.subscriptions_body().await?;
66+ let items = monthly(&body);
67+ let total: i64 = items.iter().map(|i| i.monthly_micros).sum();
68+ self.db
69+ .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",
72+ )
73+ .bind(&[(total as f64).into(), serde_json::to_string(&items)?.into(), rfc3339(now_ms()).into()])?
74+ .run()
75+ .await?;
76+ Ok(items.len())
77+ }
78+
79+ /// Cloudflare's subscriptions as last read, else the estimate.
80+ pub(crate) async fn fixed_monthly(&self, estimate: i64) -> Result<Fixed> {
81+ #[derive(Deserialize)]
82+ struct Row {
83+ monthly_micros: i64,
84+ detail: String,
85+ read_at: String,
86+ }
87+ let row = self
88+ .db
89+ .prepare("SELECT monthly_micros, detail, read_at FROM cf_subscriptions WHERE id = 'current'")
90+ .first::<Row>(None)
91+ .await?;
92+ Ok(match row {
93+ Some(row) => Fixed {
94+ monthly_micros: row.monthly_micros,
95+ source: "cloudflare",
96+ read_at: Some(row.read_at),
97+ items: serde_json::from_str(&row.detail).unwrap_or_default(),
98+ },
99+ None => Fixed { monthly_micros: estimate, source: "estimate", read_at: None, items: Vec::new() },
100+ })
101+ }
102+}
103+
104+#[cfg(test)]
105+mod tests {
106+ use super::*;
107+ use serde_json::json;
108+
109+ #[test]
110+ fn paid_subscriptions_come_to_a_month_each() {
111+ let body = json!({ "result": [
112+ { "state": "Paid", "price": 5.0, "frequency": "monthly", "rate_plan": { "public_name": "Workers Paid" } },
113+ { "state": "Paid", "price": 240.0, "frequency": "yearly", "product": { "name": "Zone Pro" } },
114+ { "state": "Cancelled", "price": 20.0, "frequency": "monthly", "rate_plan": { "public_name": "Old" } },
115+ { "state": "Paid", "price": 0.0, "frequency": "monthly", "rate_plan": { "public_name": "Free" } },
116+ ] });
117+ let items = monthly(&body);
118+ assert_eq!(
119+ items,
120+ vec![
121+ FixedCost { name: "Workers Paid".into(), monthly_micros: 5_000_000 },
122+ FixedCost { name: "Zone Pro".into(), monthly_micros: 20_000_000 },
123+ ]
124+ );
125+ }
126+
127+ #[test]
128+ fn nothing_answered_is_nothing_paid() {
129+ assert!(monthly(&json!({ "result": [] })).is_empty());
130+ assert!(monthly(&json!({})).is_empty());
131+ }
132+}