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

166 lines7,584 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 } from "@g1t/contracts";
6
7import { parseDollars } 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}