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