g1t/apps/sudo/app/lib/costs.ts

215 lines9,951 bytesCodeBlame
1/**
2 * Costs & margin: the arithmetic behind the page, apart from the SVG and
3 * the Workers runtime so it can be tested under Node. Money is in micros.
4 */
5import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts";
6
7import { parseDollars, usd } from "./money.ts";
8
9/** Buckets Cloudflare does not bill: their cost is g1t's own figure. */
10export const NOT_CLOUDFLARE = new Set(["models"]);
11
12/** A day's cost: Cloudflare's bill, or g1t's own figure where Cloudflare does not bill it. */
13export function dayCost(day: CostDay): number {
14 return NOT_CLOUDFLARE.has(day.bucket) ? day.ownCostMicros : day.cfCostMicros;
15}
16
17export type DayFigures = { day: string; revenueMicros: number; costMicros: number };
18
19/** Every day from `since` to `until`, inclusive (YYYY-MM-DD, UTC). */
20export function daysBetween(since: string, until: string): string[] {
21 const start = Date.parse(`${since}T00:00:00Z`);
22 const end = Date.parse(`${until}T00:00:00Z`);
23 if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return [];
24 const out: string[] = [];
25 for (let at = start; at <= end && out.length < 400; at += 86_400_000) out.push(new Date(at).toISOString().slice(0, 10));
26 return out;
27}
28
29/**
30 * A day per day of the range, every day there even with nothing on it.
31 * All of g1t: money in (what workspaces paid, the plan too) against every
32 * cost. One product: what customers were charged for it at price against
33 * what it cost.
34 */
35export function daySeries(days: CostDay[], since: string, until: string, bucket: string | null): DayFigures[] {
36 const totals = new Map<string, DayFigures>(daysBetween(since, until).map((day) => [day, { day, revenueMicros: 0, costMicros: 0 }]));
37 for (const row of days) {
38 if (bucket && row.bucket !== bucket) continue;
39 const figures = totals.get(row.day);
40 if (!figures) continue;
41 figures.revenueMicros += bucket ? row.valueMicros : row.cashMicros;
42 figures.costMicros += dayCost(row);
43 }
44 return [...totals.values()];
45}
46
47/** Margin as a whole percent of revenue; none when there was none. */
48export function marginPercent(revenueMicros: number, costMicros: number): number | null {
49 return revenueMicros > 0 ? ((revenueMicros - costMicros) / revenueMicros) * 100 : null;
50}
51
52/** `12.3%`, `−4.0%`, or a dash. */
53export function percentLabel(percent: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string {
54 if (percent == null || !Number.isFinite(percent)) return "—";
55 const text = `${Math.abs(percent).toFixed(1)}%`;
56 if (percent < 0) return `−${text}`;
57 return signed && percent > 0 ? `+${text}` : text;
58}
59
60/** How a margin reads against the floor: under it is danger, close to it a warning. */
61export function marginTone(percent: number | null | undefined, floor: number): "danger" | "warn" | "mint" | undefined {
62 if (percent == null) return undefined;
63 if (percent < floor) return "danger";
64 if (percent < floor + 5) return "warn";
65 return "mint";
66}
67
68/** A count: `1,234` or `1.2M`. */
69export function countLabel(value: number): string {
70 if (!Number.isFinite(value)) return "—";
71 if (Math.abs(value) >= 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
72 return Math.round(value).toLocaleString("en-US");
73}
74
75/** What a kind of drift is called. */
76export function driftLabel(kind: string): string {
77 return { count: "Count", cost: "Cost", leak: "Leak" }[kind] ?? kind;
78}
79
80/** A cost per unit in micros, as dollars with as many places as it needs: `$0.000016`, `$0.15`. */
81export function unitDollars(micros: number): string {
82 const dollars = micros / 1_000_000;
83 if (dollars === 0) return "$0";
84 if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`;
85 const places = Math.min(10, Math.max(2, 2 - Math.floor(Math.log10(Math.abs(dollars)))));
86 return `$${dollars.toFixed(places)}`;
87}
88
89/** The range shown: 7 to 90 days, 30 when not said. */
90export function parseRange(raw: string | null): number {
91 const days = Number(raw);
92 return Number.isInteger(days) && days >= 7 && days <= 90 ? days : 30;
93}
94
95/** The product to chart, if it is one the page has. */
96export function parseBucket(raw: string | null, known: string[]): string | null {
97 return raw && known.includes(raw) ? raw : null;
98}
99
100export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
101
102function numberField(form: FormData, name: string): number {
103 const raw = String(form.get(name) ?? "").trim();
104 return raw === "" ? Number.NaN : Number(raw);
105}
106
107/** The guardrails from the settings form. */
108export function parseCostSettings(form: FormData): Parsed<CostSettings> {
109 const autoApplyPercent = numberField(form, "autoApplyPercent");
110 const noticeDays = numberField(form, "noticeDays");
111 const marginFloorPercent = numberField(form, "marginFloorPercent");
112 const alertDays = numberField(form, "alertDays");
113 const anomalyFactor = numberField(form, "anomalyFactor");
114 const minDaily = parseDollars(String(form.get("minDailyCost") ?? ""));
115 const anomalyFloor = parseDollars(String(form.get("anomalyFloor") ?? ""));
116 if (!(autoApplyPercent >= 0 && autoApplyPercent <= 100)) return { ok: false, error: "The guardrail is a percentage from 0 to 100." };
117 if (!(Number.isInteger(noticeDays) && noticeDays >= 0 && noticeDays <= 90)) return { ok: false, error: "Notice is 0 to 90 days." };
118 if (!(marginFloorPercent >= -100 && marginFloorPercent <= 100)) return { ok: false, error: "The margin floor is a percentage." };
119 if (!(Number.isInteger(alertDays) && alertDays >= 1 && alertDays <= 30)) return { ok: false, error: "Alert after 1 to 30 days." };
120 if (!(anomalyFactor > 0 && anomalyFactor <= 100)) return { ok: false, error: "The factor is a positive number." };
121 if (minDaily == null || anomalyFloor == null) return { ok: false, error: "Amounts are dollars to the cent." };
122 return {
123 ok: true,
124 value: {
125 autoApply: form.get("autoApply") === "on",
126 autoApplyPercent,
127 noticeDays,
128 marginFloorPercent,
129 alertDays,
130 minDailyCostMicros: minDaily,
131 anomalyFactor,
132 anomalyFloorMicros: anomalyFloor,
133 },
134 };
135}
136
137const NAME = /^(\*|[a-z0-9][a-z0-9_]{0,79})$/;
138
139/** A mapping from the mapping form: Cloudflare's product and meter (or `*`) to one of g1t's products. */
140export function parseMapping(form: FormData): Parsed<CostMappingInput> {
141 const value = (name: string) => String(form.get(name) ?? "").trim();
142 const product = value("product").toLowerCase();
143 const meter = value("meter").toLowerCase() || "*";
144 const remove = form.get("remove") === "1";
145 if (!NAME.test(product) || product === "*") return { ok: false, error: "Cloudflare's product, as the lines table names it." };
146 if (!NAME.test(meter)) return { ok: false, error: "A meter prefix as the lines table names it, or * for all of the product." };
147 if (remove) return { ok: true, value: { product, meter, remove: true } };
148 const bucket = value("bucket").toLowerCase();
149 if (!NAME.test(bucket) || bucket === "*") return { ok: false, error: "Which of g1t's products it is a cost of." };
150 const drift = value("driftPercent");
151 const driftPercent = drift === "" ? null : Number(drift);
152 if (driftPercent != null && !(driftPercent > 0 && driftPercent <= 1000)) return { ok: false, error: "Drift is a percentage above zero." };
153 return {
154 ok: true,
155 value: {
156 product,
157 meter,
158 bucket,
159 priceMeter: value("priceMeter") || null,
160 ownMeter: value("ownMeter") || null,
161 scaleToOwn: form.get("scaleToOwn") === "on",
162 driftPercent,
163 note: value("note").slice(0, 200),
164 },
165 };
166}
167
168// --- g1t's own spend (billing's budget) ---------------------------------------
169
170/**
171 * The red bar on every sudo page: the daily breaker open, or a comped
172 * account's monthly budget used up. Null when neither.
173 */
174export function spendBanner(caps: SpendCaps): string | null {
175 const parts: string[] = [];
176 if (caps.tripped) {
177 parts.push(
178 `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new hosted-model agent runs g1t pays for wait until 00:00 UTC`,
179 );
180 }
181 for (const budget of caps.comped) {
182 if (budget.ceilingMicros > 0 && budget.usedMicros >= budget.ceilingMicros) {
183 parts.push(`${budget.name} used its ${usd(budget.ceilingMicros)} monthly budget, so new work on it is refused`);
184 }
185 }
186 if (parts.length === 0) return null;
187 const text = parts.join("; and ");
188 return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
189}
190
191/** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */
192export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } {
193 const notes: Record<string, string> = {
194 comped: "Work on comped accounts, at cost",
195 trial: "Trial credit, at cost",
196 oss: "Checks and workflows on public repositories, at cost",
197 given: "Free workspaces' overruns past their trial",
198 unpaid: "Charged, but no real money yet (test-mode payments)",
199 };
200 const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" }));
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({
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 });
208 return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
209}
210
211/** How far a cap is used, 0 to 100, for a meter. */
212export function capPercent(usedMicros: number, capMicros: number): number {
213 if (capMicros <= 0) return 0;
214 return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100));
215}