| 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 | */ |
| 5 | import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts"; |
| 6 | |
| 7 | import { parseDollars, usd } from "./money.ts"; |
| 8 | |
| 9 | /** Buckets Cloudflare does not bill: their cost is g1t's own figure. */ |
| 10 | export 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. */ |
| 13 | export function dayCost(day: CostDay): number { |
| 14 | return NOT_CLOUDFLARE.has(day.bucket) ? day.ownCostMicros : day.cfCostMicros; |
| 15 | } |
| 16 | |
| 17 | export type DayFigures = { day: string; revenueMicros: number; costMicros: number }; |
| 18 | |
| 19 | /** Every day from `since` to `until`, inclusive (YYYY-MM-DD, UTC). */ |
| 20 | export 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 | */ |
| 35 | export 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. */ |
| 48 | export 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. */ |
| 53 | export 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. */ |
| 61 | export 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`. */ |
| 69 | export 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. */ |
| 76 | export 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`. */ |
| 81 | export 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. */ |
| 90 | export 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. */ |
| 96 | export function parseBucket(raw: string | null, known: string[]): string | null { |
| 97 | return raw && known.includes(raw) ? raw : null; |
| 98 | } |
| 99 | |
| 100 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; |
| 101 | |
| 102 | function 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. */ |
| 108 | export 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 | |
| 137 | const 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. */ |
| 140 | export 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 | */ |
| 174 | export 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. */ |
| 192 | export 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. */ |
| 212 | export 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 | } |