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

210 lines9,858 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1/**
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 */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays5import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily6
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays7import { parseDollars, usd } from "./money.ts";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily8
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}
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays167
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({ key: "fixed", title: "Cloudflare subscriptions", micros: caps.fixedMonthlyMicros, note: "A month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)" });
203 return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
204}
205
206/** How far a cap is used, 0 to 100, for a meter. */
207export function capPercent(usedMicros: number, capMicros: number): number {
208 if (capMicros <= 0) return 0;
209 return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100));
210}