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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | /** |
| 2 | * How the billing pages speak of money, read their forms and word a | |
| 3 | * workspace's alerts. Pure, so it can be tested. | |
| 4 | */ | |
| 5 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 6 | import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 7 | |
| 8 | /** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */ | |
| 9 | const MICROS_PER_DOLLAR = 1_000_000; | |
| 10 | ||
| 11 | /** Millionths of a dollar as dollars, to the cent or finer. */ | |
| 12 | export function dollars(micros: number, digits = 2): string { | |
| 13 | const sign = micros < 0 ? "−" : ""; | |
| 14 | return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`; | |
| 15 | } | |
| 16 | ||
| 17 | /** Whole dollars when they are whole, with thousands separated: "$1,000", "$0.10". */ | |
| 18 | export function wholeDollars(micros: number): string { | |
| 19 | const d = micros / MICROS_PER_DOLLAR; | |
| 20 | const sign = d < 0 ? "−" : ""; | |
| 21 | const abs = Math.abs(d); | |
| 22 | const digits = Number.isInteger(abs) ? 0 : 2; | |
| 23 | return `${sign}$${abs.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`; | |
| 24 | } | |
| 25 | ||
| 26 | /** A form's dollar amount as micros, or null when it is empty or not a number. */ | |
| 27 | export function readDollars(value: FormDataEntryValue | null | undefined): number | null { | |
| 28 | const text = String(value ?? "").replace(/[$,\s]/g, ""); | |
| 29 | if (!text) return null; | |
| 30 | const amount = Number(text); | |
| 31 | if (!Number.isFinite(amount)) return null; | |
| 32 | return Math.round(amount * MICROS_PER_DOLLAR); | |
| 33 | } | |
| 34 | ||
| 35 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 36 | ||
| 37 | /** The smallest prepayment, the most by card, and where bank transfer starts, in dollars. */ | |
| 38 | export const PREPAY = { min: 25, maxCard: 10_000, bankFrom: 1_000, presets: [100, 500, 1_000] } as const; | |
| 39 | ||
| 40 | /** A prepayment: a preset or a custom amount, by card or bank transfer. */ | |
| 41 | export function parsePrepay(form: { | |
| 42 | amount?: FormDataEntryValue | null; | |
| 43 | custom?: FormDataEntryValue | null; | |
| 44 | method?: FormDataEntryValue | null; | |
| 45 | }): Parsed<{ amountCents: number; method: "card" | "bank_transfer" }> { | |
| 46 | const micros = readDollars(form.custom) ?? readDollars(form.amount); | |
| 47 | const method = String(form.method ?? "card") === "bank_transfer" ? "bank_transfer" : "card"; | |
| 48 | if (micros == null) return { ok: false, error: "Choose an amount to prepay." }; | |
| 49 | const amount = micros / MICROS_PER_DOLLAR; | |
| 50 | if (amount < PREPAY.min) return { ok: false, error: `The smallest prepayment is $${PREPAY.min}.` }; | |
| 51 | if (method === "card" && amount > PREPAY.maxCard) | |
| 52 | return { ok: false, error: `By card, the most is $${PREPAY.maxCard.toLocaleString("en-US")}; pay more by bank transfer.` }; | |
| 53 | if (method === "bank_transfer" && amount < PREPAY.bankFrom) | |
| 54 | return { ok: false, error: `Bank transfer is for $${PREPAY.bankFrom.toLocaleString("en-US")} or more.` }; | |
| 55 | return { ok: true, value: { amountCents: Math.round(amount * 100), method } }; | |
| 56 | } | |
| 57 | ||
| 58 | /** What owners may set each cap to, in micros, and what applies when they set none. */ | |
| 59 | export const CAPS = { | |
| 60 | run: { min: 100_000, max: 100_000_000, default: 2_000_000 }, | |
| 61 | issue: { min: 1_000_000, max: 1_000_000_000, default: 10_000_000 }, | |
| 62 | } as const; | |
| 63 | ||
| 64 | /** The owners' caps: empty goes back to the default. */ | |
| 65 | export function parseCaps(form: { | |
| 66 | run?: FormDataEntryValue | null; | |
| 67 | issue?: FormDataEntryValue | null; | |
| 68 | }): Parsed<{ runCapMicros: number | null; issueCapMicros: number | null }> { | |
| 69 | const run = readDollars(form.run); | |
| 70 | const issue = readDollars(form.issue); | |
| 71 | if (String(form.run ?? "").trim() && run == null) return { ok: false, error: "A run's cap is a dollar amount." }; | |
| 72 | if (String(form.issue ?? "").trim() && issue == null) return { ok: false, error: "An issue's cap is a dollar amount." }; | |
| 73 | if (run != null && (run < CAPS.run.min || run > CAPS.run.max)) | |
| 74 | return { ok: false, error: `A run's cap is between ${wholeDollars(CAPS.run.min)} and ${wholeDollars(CAPS.run.max)}.` }; | |
| 75 | if (issue != null && (issue < CAPS.issue.min || issue > CAPS.issue.max)) | |
| 76 | return { | |
| 77 | ok: false, | |
| 78 | error: `An issue's cap is between ${wholeDollars(CAPS.issue.min)} and ${wholeDollars(CAPS.issue.max)}.`, | |
| 79 | }; | |
| 80 | return { ok: true, value: { runCapMicros: run, issueCapMicros: issue } }; | |
| 81 | } | |
| 82 | ||
| 83 | /** A request for a higher limit, or for help with usage past what was meant. */ | |
| 84 | export function parseLimitRequest(form: { | |
| 85 | kind?: FormDataEntryValue | null; | |
| 86 | amount?: FormDataEntryValue | null; | |
| 87 | reason?: FormDataEntryValue | null; | |
| 88 | expected?: FormDataEntryValue | null; | |
| 89 | }): Parsed<{ kind: "limit" | "overage"; amountMicros: number; reason: string; expectedMonthlyMicros: number }> { | |
| 90 | const kind = String(form.kind ?? "limit") === "overage" ? "overage" : "limit"; | |
| 91 | const reason = String(form.reason ?? "").trim(); | |
| 92 | const amount = readDollars(form.amount); | |
| 93 | const expected = readDollars(form.expected); | |
| 94 | if (!reason) | |
| 95 | return { | |
| 96 | ok: false, | |
| 97 | error: kind === "limit" ? "Say what the higher limit is for." : "Say what happened, so we can look at it.", | |
| 98 | }; | |
| 99 | if (kind === "limit") { | |
| 100 | if (amount == null || amount <= 0) return { ok: false, error: "Say the limit you need, in dollars." }; | |
| 101 | if (expected == null || expected < 0) return { ok: false, error: "Say what you expect to spend in a month." }; | |
| 102 | } | |
| 103 | return { | |
| 104 | ok: true, | |
| 105 | value: { | |
| 106 | kind, | |
| 107 | amountMicros: Math.max(0, amount ?? 0), | |
| 108 | reason: reason.slice(0, 2000), | |
| 109 | expectedMonthlyMicros: Math.max(0, expected ?? 0), | |
| 110 | }, | |
| 111 | }; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * A spend limit as owners set it: automatic, fixed at an amount, as high as | |
| 116 | * is available, or the one-time raise (an amount, or blank for as high as | |
| 117 | * it goes, which the page fills in from the limit). | |
| 118 | */ | |
| 119 | export function parseSpendLimit(form: { | |
| 120 | mode?: FormDataEntryValue | null; | |
| 121 | limit?: FormDataEntryValue | null; | |
| 122 | }): Parsed<{ micros: number | null; useFull: boolean; raiseOnce: boolean }> { | |
| 123 | const mode = String(form.mode ?? "automatic"); | |
| 124 | if (mode === "full") return { ok: true, value: { micros: null, useFull: true, raiseOnce: false } }; | |
| 125 | if (mode !== "fixed" && mode !== "raise") return { ok: true, value: { micros: null, useFull: false, raiseOnce: false } }; | |
| 126 | const micros = readDollars(form.limit); | |
| 127 | if (mode === "raise" && micros == null) return { ok: true, value: { micros: null, useFull: false, raiseOnce: true } }; | |
| 128 | if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "A spend limit is a dollar amount, $1 or more." }; | |
| 129 | return { ok: true, value: { micros, useFull: false, raiseOnce: mode === "raise" } }; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * How far owners may set their spend limit themselves: up to the highest | |
| 134 | * ceiling the workspace has had plus what is prepaid, and, once, up to | |
| 135 | * twice that highest ceiling. Null `selfServeMicros` means no ceiling | |
| 136 | * (g1t's own, or staff set it). | |
| 137 | */ | |
| 138 | export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null }; | |
| 139 | ||
| 140 | export function spendRange( | |
| 141 | limit: Pick<Limit, "availableMicros" | "ceilingMicros" | "raiseOnceMicros" | "raisedAt">, | |
| 142 | ): SpendRange { | |
| 143 | const selfServe = limit.availableMicros !== undefined ? limit.availableMicros : limit.ceilingMicros; | |
| 144 | const once = limit.raiseOnceMicros ?? null; | |
| 145 | return { | |
| 146 | selfServeMicros: selfServe ?? null, | |
| 147 | // The raise only matters when it goes further than owners can already. | |
| 148 | raiseOnceMicros: once != null && (selfServe == null || once > selfServe) ? once : null, | |
| 149 | raisedAt: limit.raisedAt ?? null, | |
| 150 | }; | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * What setting the spend limit to `micros` takes, as billing decides it: | |
| 155 | * nothing (`self`), the one-time raise (`raise`), or a request to g1t | |
| 156 | * (`ask`). | |
| 157 | */ | |
| 158 | export function spendPath(micros: number, range: SpendRange): "self" | "raise" | "ask" { | |
| 159 | if (range.selfServeMicros == null || micros <= range.selfServeMicros) return "self"; | |
| 160 | if (range.raiseOnceMicros != null && micros <= range.raiseOnceMicros) return "raise"; | |
| 161 | return "ask"; | |
| 162 | } | |
| 163 | ||
| 164 | /** Where the workspace stands with the g1t plan, for the plan card. */ | |
| 165 | export type PlanStatus = { | |
| 166 | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise"; | |
| 167 | label: string; | |
| 168 | }; | |
| 169 | ||
| 170 | export function planStatus( | |
| 171 | plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined, | |
| 172 | entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined, | |
| 173 | ): PlanStatus { | |
| 174 | if (entitlements?.plan === "internal") return { kind: "comped", label: "Comped by g1t" }; | |
| 175 | if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" }; | |
| 176 | if (plan?.included) return { kind: "comped", label: "Included by g1t" }; | |
| 177 | const subscription = plan?.subscription; | |
| 178 | if (subscription?.status === "past_due") return { kind: "past_due", label: "Payment failed" }; | |
| 179 | if (plan?.on && subscription?.status === "canceling") return { kind: "canceling", label: "Ends at the end of the period" }; | |
| 180 | if ((plan?.on && subscription) || entitlements?.plan === "paid") { | |
| 181 | return { kind: "paid", label: entitlements?.firstMonth ? "On the g1t plan, first month" : "On the g1t plan" }; | |
| 182 | } | |
| 183 | if (entitlements?.trialVerified && entitlements.trialMicrosLeft > 0) return { kind: "trial", label: "Free, on the trial" }; | |
| 184 | return { kind: "free", label: "Free" }; | |
| 185 | } | |
| 186 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 187 | /** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */ |
| 188 | const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]); | |
| 189 | ||
| 190 | /** | |
| 191 | * The lines of "This month's usage": every meter on the plan, so it reads | |
| 192 | * the same each month; without it, only what a free workspace can use, | |
| 193 | * plus anything that was used anyway (from before the plan ended). | |
| 194 | */ | |
| 195 | export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] { | |
| 196 | return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0); | |
| 197 | } | |
| 198 | ||
| 199 | /** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */ | |
| 200 | export function gigabytes(bytes: number): string { | |
| 201 | if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`; | |
| 202 | return `${Math.round(bytes / 1e6)} MB`; | |
| 203 | } | |
| 204 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 205 | /** A share from 0 to 1 of `used` against `of`, for a meter. */ |
| 206 | export function share(used: number, of: number | null | undefined): number { | |
| 207 | if (!of || of <= 0) return 0; | |
| 208 | return Math.min(1, Math.max(0, used / of)); | |
| 209 | } | |
| 210 | ||
| 211 | /** Where the trial stands after a card check, in a sentence for the owner. */ | |
| 212 | export function cardCheckResult( | |
| 213 | entitlements: Pick<Entitlements, "trialVerified" | "trialMicrosLeft">, | |
| 214 | trialMicros: number, | |
| 215 | ): string { | |
| 216 | if (!entitlements.trialVerified) return "The card check did not finish. Try again; the card is never charged."; | |
| 217 | if (entitlements.trialMicrosLeft > 0) return `Card checked. Your ${wholeDollars(entitlements.trialMicrosLeft)} trial is ready to use.`; | |
| 218 | return `Card checked, but no trial started. The ${wholeDollars(trialMicros)} trial needs a credit or debit card that has not started one before, and comes from a monthly pool that can run out. Prepaid cards can still pay for the plan.`; | |
| 219 | } | |
| 220 | ||
| 221 | const METERS: Record<string, string> = { | |
| 222 | included: "the plan's included usage", | |
| 223 | spend_limit: "your spend limit", | |
| 224 | ceiling: "what g1t lets go unpaid", | |
| 225 | }; | |
| 226 | ||
| 227 | /** One alert as a sentence: "90% of the plan's included usage: $9.00 of $10.00." */ | |
| 228 | export function alertText(alert: UsageAlert): string { | |
| 229 | const what = METERS[alert.meter] ?? alert.meter.replace(/_/g, " "); | |
| 230 | const reached = alert.level >= 100 ? `All of ${what}` : `${alert.level}% of ${what}`; | |
| 231 | return `${reached}: ${dollars(alert.usedMicros)} of ${dollars(alert.limitMicros)}.`; | |
| 232 | } | |
| 233 | ||
| 234 | /** How loud an alert is. */ | |
| 235 | export function alertTone(level: number): "ok" | "warning" | "stopped" { | |
| 236 | if (level >= 100) return "stopped"; | |
| 237 | return level >= 75 ? "warning" : "ok"; | |
| 238 | } | |
| 239 | ||
| 240 | /** Whether the workspace needs an owner's eye now: compute paused, a spike waiting, or an alert at 90% or more. */ | |
| 241 | export function needsAttention(entitlements: Pick<Entitlements, "paused" | "spike" | "alerts"> | null | undefined): boolean { | |
| 242 | if (!entitlements) return false; | |
| 243 | if (entitlements.paused) return true; | |
| 244 | if (entitlements.spike && entitlements.spike.status !== "continued") return true; | |
| 245 | return (entitlements.alerts ?? []).some((alert) => alert.level >= 90); | |
| 246 | } | |
| 247 | ||
| 248 | /** A request's state as the owner sees it. */ | |
| 249 | export function requestStatus(request: LimitRequest): string { | |
| 250 | if (request.status === "approved") | |
| 251 | return request.decidedMicros != null ? `Approved at ${wholeDollars(request.decidedMicros)}` : "Approved"; | |
| 252 | if (request.status === "declined") return "Declined"; | |
| 253 | return "Waiting for an answer"; | |
| 254 | } | |
| 255 | ||
| 256 | /** What each kind of agent work is called, and its colour, on Usage and the workspace's overview. */ | |
| 257 | export const USAGE_TASKS: Record<string, { label: string; color: string }> = { | |
| 258 | implement: { label: "Making changes", color: "var(--color-merged)" }, | |
| 259 | review: { label: "Reviews", color: "var(--color-info)" }, | |
| 260 | revise: { label: "Revisions", color: "var(--color-warn)" }, | |
| 261 | update: { label: "Catching up", color: "var(--color-accent)" }, | |
| 262 | plan: { label: "Planning", color: "#f0a6ca" }, | |
| 263 | other: { label: "Other", color: "var(--color-faint)" }, | |
| 264 | }; | |
| 265 | ||
| 266 | /** A kind of work's words and colour, or Other's. */ | |
| 267 | export function usageTask(key: string): { label: string; color: string } { | |
| 268 | return USAGE_TASKS[key] ?? USAGE_TASKS.other!; | |
| 269 | } | |
| 270 | ||
| 271 | /** The workspace's month at a glance, for the Usage card on its overview. */ | |
| 272 | export type UsageGlance = { | |
| 273 | /** | |
| 274 | * `beta` while g1t charges nothing; `comped` when g1t or an enterprise | |
| 275 | * pays; `plan` on the g1t plan; `trial` on trial credit; `forge` with | |
| 276 | * neither, where only what runs no compute is open. | |
| 277 | */ | |
| 278 | kind: "beta" | "comped" | "plan" | "trial" | "forge"; | |
| 279 | /** Charged this month, or used at cost while g1t is free. */ | |
| 280 | spentMicros: number; | |
| 281 | /** What pays first, and how much of it is used: the plan's included usage, or the trial. */ | |
| 282 | credit: { label: string; usedMicros: number; ofMicros: number } | null; | |
| 283 | /** Charged past what is included, and the spend limit if there is one; null when it does not apply. */ | |
| 284 | onDemand: { micros: number; limitMicros: number | null } | null; | |
| 285 | /** What it went on, most first. */ | |
| 286 | lines: { key: string; label: string; micros: number; runs: number }[]; | |
| 287 | }; | |
| 288 | ||
| 289 | export function usageGlance(input: { | |
| 290 | usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask">; | |
| 291 | status: PlanStatus; | |
| 292 | entitlements: Pick<Entitlements, "includedMicros" | "includedUsedMicros" | "trialMicrosLeft"> | null; | |
| 293 | limit: Pick<Limit, "spentMicros" | "spendLimitMicros"> | null; | |
| 294 | trialMicros: number; | |
| 295 | }): UsageGlance { | |
| 296 | const { usage, status, entitlements, limit, trialMicros } = input; | |
| 297 | const lines = [...usage.byTask] | |
| 298 | .filter((slice) => slice.micros > 0) | |
| 299 | .sort((a, b) => b.micros - a.micros) | |
| 300 | .map((slice) => ({ key: slice.key, label: usageTask(slice.key).label, micros: slice.micros, runs: slice.runs })); | |
| 301 | const spentMicros = usage.free ? usage.usedMicros : usage.spentMicros; | |
| 302 | const plain = { spentMicros, credit: null, onDemand: null, lines }; | |
| 303 | if (usage.free) return { kind: "beta", ...plain }; | |
| 304 | if (status.kind === "comped" || status.kind === "enterprise") return { kind: "comped", ...plain }; | |
| 305 | if (status.kind === "trial") { | |
| 306 | const left = Math.max(0, entitlements?.trialMicrosLeft ?? 0); | |
| 307 | const of = Math.max(trialMicros, left); | |
| 308 | return { ...plain, kind: "trial", credit: { label: "Trial credit", usedMicros: of - left, ofMicros: of } }; | |
| 309 | } | |
| 310 | if (status.kind === "free") return { kind: "forge", ...plain }; | |
| 311 | const included = entitlements?.includedMicros ?? 0; | |
| 312 | const includedUsed = Math.min(entitlements?.includedUsedMicros ?? 0, included); | |
| 313 | return { | |
| 314 | ...plain, | |
| 315 | kind: "plan", | |
| 316 | credit: included > 0 ? { label: "Included usage", usedMicros: includedUsed, ofMicros: included } : null, | |
| 317 | onDemand: { | |
| 318 | micros: limit?.spentMicros ?? Math.max(0, spentMicros - includedUsed), | |
| 319 | limitMicros: limit?.spendLimitMicros ?? null, | |
| 320 | }, | |
| 321 | }; | |
| 322 | } |