Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing
16 files+833−5180/16 viewed
| 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 & 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 & 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 | + | } |
| 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 | + | } |
| 199 | 199 | }; | |
| 200 | 200 | const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" })); | |
| 201 | 201 | 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 | + | }); | |
| 203 | 208 | return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) }; | |
| 204 | 209 | } | |
| 205 | 210 |
| 17 | 17 | route("overages", "routes/overages.tsx"), | |
| 18 | 18 | route("velocity", "routes/velocity.tsx"), | |
| 19 | 19 | route("costs", "routes/costs.tsx"), | |
| 20 | + | route("costs/bill", "routes/costs-bill.tsx"), | |
| 20 | 21 | route("abuse", "routes/abuse.tsx"), | |
| 21 | 22 | route("invoices", "routes/invoices.tsx"), | |
| 22 | 23 | route("stripe", "routes/stripe.tsx"), |
| 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 | + | } |
| 1 | 1 | import type { ReactNode } from "react"; | |
| 2 | − | import { Link, data, redirect } from "react-router"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { CostsReport, PriceProposal } from "@g1t/contracts"; | |
| 4 | + | import type { CostsReport } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/costs"; | |
| 7 | 7 | 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"; | |
| 27 | 13 | ||
| 28 | 14 | export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 29 | 15 | ||
| 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); | |
| 110 | 18 | ||
| 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 | + | */ | |
| 125 | 23 | export default function Costs({ loaderData, actionData }: Route.ComponentProps) { | |
| 126 | 24 | 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."; | |
| 128 | 28 | if (!report) { | |
| 129 | 29 | return ( | |
| 130 | 30 | <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} /> | |
| 132 | 32 | <div className="mt-5"> | |
| 133 | 33 | <Notice tone="warn">Billing did not answer for costs: {error}</Notice> | |
| 134 | 34 | </div> | |
| 140 | 40 | const series = daySeries(report.days, report.since, report.until, bucket); | |
| 141 | 41 | const floor = report.settings.marginFloorPercent; | |
| 142 | 42 | 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; | |
| 146 | 43 | return ( | |
| 147 | 44 | <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} /> | |
| 188 | 46 | ||
| 189 | 47 | <Statement report={report} floor={floor} range={range} proposals={open.length} /> | |
| 190 | 48 | ||
| 200 | 58 | } | |
| 201 | 59 | > | |
| 202 | 60 | <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)}> | |
| 204 | 62 | All of g1t | |
| 205 | 63 | </Link> | |
| 206 | 64 | {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)}> | |
| 208 | 66 | {p.title} | |
| 209 | 67 | </Link> | |
| 210 | 68 | ))} | |
| 236 | 94 | return ( | |
| 237 | 95 | <tr key={p.bucket} className="border-b border-line last:border-0"> | |
| 238 | 96 | <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"> | |
| 240 | 98 | {p.title} | |
| 241 | 99 | </Link> | |
| 242 | 100 | <span className="block text-xs text-faint"> | |
| 257 | 115 | </tr> | |
| 258 | 116 | ); | |
| 259 | 117 | })} | |
| 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 | − | ))} | |
| 359 | 118 | </tbody> | |
| 360 | 119 | </table> | |
| 361 | 120 | </div> | |
| 404 | 163 | )} | |
| 405 | 164 | </Section> | |
| 406 | 165 | ||
| 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> | |
| 538 | 166 | </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> | |
| 593 | 167 | ); | |
| 594 | 168 | } | |
| 595 | 169 | ||
| 609 | 183 | </div> | |
| 610 | 184 | <p className="mt-1.5 text-xs text-muted">{hint}</p> | |
| 611 | 185 | </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> | |
| 633 | 186 | ); | |
| 634 | 187 | } | |
| 635 | 188 | ||
| 685 | 238 | }, | |
| 686 | 239 | { | |
| 687 | 240 | 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", | |
| 689 | 245 | in: null, | |
| 690 | 246 | cost: subscriptions, | |
| 691 | 247 | result: -subscriptions, | |
| 726 | 282 | <Stat | |
| 727 | 283 | label="Proposals waiting" | |
| 728 | 284 | 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 & 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 | + | } | |
| 730 | 300 | tone={proposals > 0 ? "warn" : undefined} | |
| 731 | 301 | /> | |
| 732 | 302 | </div> | |
| 881 | 451 | ); | |
| 882 | 452 | } | |
| 883 | 453 | ||
| 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 | − | } |
| 2649 | 2649 | ||
| 2650 | 2650 | /// What g1t itself pays for, against its caps (billing's `budget`): the | |
| 2651 | 2651 | /// 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 | + | ||
| 2652 | 2660 | /// At cost, never at price. What sudo's Costs page and its red bar show. | |
| 2653 | 2661 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 2654 | 2662 | #[serde(rename_all = "camelCase")] | |
| 2678 | 2686 | /// Free workspaces' share of this month's reconciled costs (git, | |
| 2679 | 2687 | /// storage, platform), through yesterday. | |
| 2680 | 2688 | 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. | |
| 2682 | 2691 | 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>, | |
| 2683 | 2700 | /// Money in this month, through the last reconciled day. | |
| 2684 | 2701 | pub revenue_micros: i64, | |
| 2685 | 2702 | } |
| 25 | 25 | | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` | | |
| 26 | 26 | | `plan_payments` | The plan's $20. | read | | |
| 27 | 27 | | 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) | | |
| 28 | 29 | | 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) | | |
| 29 | 30 | ||
| 30 | 31 | A meter's slug is Cloudflare's name lower-cased with words joined by `_` | |
| 37 | 38 | ||
| 38 | 39 | | Secret on g1t-billing | Permissions | Used for | | |
| 39 | 40 | | --- | --- | --- | | |
| 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 | | |
| 41 | 42 | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper; also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set | | |
| 42 | 43 | ||
| 43 | 44 | With neither, the daily run reconciles only what g1t counted itself, and | |
| 75 | 76 | 8. Open, update and close margin alerts; email new ones. | |
| 76 | 77 | 9. Email owners on the plan about rises to come. | |
| 77 | 78 | ||
| 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 | |
| 79 | 80 | (`admin_run_costs`) runs all of step 2 at once, alerts included, with no | |
| 80 | 81 | need to wait for 04:17 UTC. Running it twice is safe: every step replaces | |
| 81 | 82 | what it wrote. | |
| 137 | 138 | sits near 16.7%. | |
| 138 | 139 | - **Running g1t**: the plan's price against `platform` less its given | |
| 139 | 140 | 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. | |
| 142 | 144 | - **Not mapped**: billed, charged for by nothing. | |
| 143 | 145 | - **Given away**: by why. A budget, watched under g1t's own spend, never | |
| 144 | 146 | shown as a loss. | |
| 196 | 198 | ||
| 197 | 199 | ## Changing a mapping | |
| 198 | 200 | ||
| 199 | − | In sudo, Costs & margin → **Mappings**: Cloudflare's product and meter | |
| 201 | + | In sudo, Costs & margin → Bill & pricing → **Mappings**: Cloudflare's product and meter | |
| 200 | 202 | prefix (as **Cloudflare's lines** lists them; `*` for the rest of the | |
| 201 | 203 | product), g1t's product, and optionally: | |
| 202 | 204 | ||
| 311 | 313 | | 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). | | |
| 312 | 314 | | 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. | | |
| 313 | 315 | ||
| 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. | |
| 316 | 319 | ||
| 317 | 320 | The checks are cheap: `reserve` reads today's total (one indexed sum) and, | |
| 318 | 321 | for a comped account, its month's comped rows. Refusals come back as |
| 1241 | 1241 | comped: CompedBudget[]; | |
| 1242 | 1242 | /** Free workspaces' share of reconciled costs this month (git, storage, platform). */ | |
| 1243 | 1243 | 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. */ | |
| 1245 | 1245 | fixedMonthlyMicros: number; | |
| 1246 | + | fixedSource?: "cloudflare" | "estimate" | string; | |
| 1247 | + | fixedReadAt?: string | null; | |
| 1248 | + | /** Each subscription, when read from Cloudflare. */ | |
| 1249 | + | fixedItems?: { name: string; monthlyMicros: number }[]; | |
| 1246 | 1250 | /** Money in this month, through the last reconciled day. */ | |
| 1247 | 1251 | revenueMicros: number; | |
| 1248 | 1252 | }; |
| 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 | + | ); |
| 503 | 503 | .await? | |
| 504 | 504 | .and_then(|s| s.micros) | |
| 505 | 505 | .unwrap_or(0); | |
| 506 | + | let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?; | |
| 506 | 507 | Ok(SpendCaps { | |
| 507 | 508 | day, | |
| 508 | 509 | month, | |
| 516 | 517 | month_buckets, | |
| 517 | 518 | comped, | |
| 518 | 519 | 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, | |
| 520 | 524 | revenue_micros, | |
| 521 | 525 | }) | |
| 522 | 526 | } |
| 91 | 91 | send_with(self.billing_token()?, Method::Get, &self.api(&format!("/billable-usage?from={from}&to={to}")), None).await | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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 | + | ||
| 94 | 100 | /// A GraphQL Analytics query, as Cloudflare answers it, errors and all. | |
| 95 | 101 | pub(crate) async fn graphql(&self, body: Value) -> Result<Value> { | |
| 96 | 102 | send_with(self.billing_token()?, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await |
| 41 | 41 | mod retention; | |
| 42 | 42 | mod stripe; | |
| 43 | 43 | mod stripe_sync; | |
| 44 | + | mod subscriptions; | |
| 44 | 45 | mod tokens; | |
| 45 | 46 | ||
| 46 | 47 | use g1t_contracts::billing::*; |
| 696 | 696 | costs::window(last.as_deref(), now_ms()) | |
| 697 | 697 | } | |
| 698 | 698 | }; | |
| 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 | + | } | |
| 699 | 705 | if let Err(error) = self.count_own(&since, &until).await { | |
| 700 | 706 | run.problems.push(format!("g1t's own counts could not be read: {error}")); | |
| 701 | 707 | } |
| 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 | + | } |