Skip to content

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

152 lines6,902 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.

Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1/**
2 * Giving a workspace credit from sudo: a preset or custom amount, a kind,
3 * an optional expiry, and a note. Small credits are one step; past
4 * `CONFIRM_OVER_MICROS` the workspace's slug is typed out as well. No
5 * Workers or React imports, so it can be tested under Node.
6 */
7import type { CreditGrant, CreditKind } from "@g1t/contracts";
8
9import { type Parsed, parseCredit, parseNote, text } from "./forms.ts";
10import { MICROS_PER_DOLLAR } from "./money.ts";
11
12/** The amounts offered as one click, in dollars. */
13export const CREDIT_PRESETS = [10, 20, 25, 50, 100] as const;
14
15/** Over this, the slug is typed out to give it. */
16export const CONFIRM_OVER_MICROS = 100 * MICROS_PER_DOLLAR;
17
18export const CREDIT_KINDS: { value: CreditKind; title: string; text: string }[] = [
19 { value: "promotional", title: "Promotional", text: "A welcome, a referral, an event. Given away when spent." },
20 { value: "goodwill", title: "Goodwill", text: "An apology. Given away when spent." },
21 { value: "refund", title: "Refund", text: "Money back for something that went wrong. Never expires." },
22];
23
24/** How long unused credit lasts. */
25export const EXPIRIES: { value: string; label: string }[] = [
26 { value: "none", label: "Never" },
27 { value: "30", label: "30 days" },
28 { value: "90", label: "90 days" },
29 { value: "365", label: "1 year" },
30 { value: "date", label: "On a date" },
31];
32
33const MAX_REFUND_FOR = 200;
34const MAX_EXPIRY_DAYS = 5 * 366;
35const DAY_MS = 24 * 60 * 60 * 1000;
36
37export function kindLabel(kind: CreditKind): string {
38 return CREDIT_KINDS.find((k) => k.value === kind)?.title ?? kind;
39}
40
41export function isCreditKind(value: string): value is CreditKind {
42 return CREDIT_KINDS.some((k) => k.value === value);
43}
44
45export type CreditInput = {
46 amountMicros: number;
47 kind: CreditKind;
48 /** The last second of the day it expires, UTC; null never. */
49 expiresAt: string | null;
50 refundFor: string | null;
51 refundDay: string | null;
52 note: string;
53};
54
55/** The last second of a day, UTC, as billing keeps times: `2027-01-05T23:59:59Z`. */
56export function endOfDay(day: string): string {
57 return `${day}T23:59:59Z`;
58}
59
60function isDay(raw: string): boolean {
61 if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return false;
62 const date = new Date(`${raw}T00:00:00Z`);
63 return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === raw;
64}
65
66/** The amount: a preset (`preset=25`) or, with `preset=custom`, what was typed in `amount`. */
67export function parseCreditAmount(preset: string, typed: string): Parsed<number> {
68 if (preset !== "custom") {
69 const dollars = Number(preset);
70 if (!CREDIT_PRESETS.includes(dollars as (typeof CREDIT_PRESETS)[number])) return { ok: false, error: "Choose an amount, or Custom and type one." };
71 return { ok: true, value: dollars * MICROS_PER_DOLLAR };
72 }
73 return parseCredit(typed);
74}
75
76/** When unused credit stops counting: never, in 30, 90 or 365 days, or at the end of a chosen day (UTC). */
77export function parseExpiry(choice: string, day: string, now = new Date()): Parsed<string | null> {
78 if (choice === "" || choice === "none") return { ok: true, value: null };
79 if (choice === "date") {
80 if (!isDay(day)) return { ok: false, error: "Choose the day the credit expires." };
81 const end = new Date(endOfDay(day));
82 if (end.getTime() <= now.getTime()) return { ok: false, error: "The expiry has to be in the future." };
83 if (end.getTime() > now.getTime() + MAX_EXPIRY_DAYS * DAY_MS) return { ok: false, error: "The expiry is within five years." };
84 return { ok: true, value: endOfDay(day) };
85 }
86 const days = Number(choice);
87 if (![30, 90, 365].includes(days)) return { ok: false, error: "Choose when the credit expires." };
88 return { ok: true, value: endOfDay(new Date(now.getTime() + days * DAY_MS).toISOString().slice(0, 10)) };
89}
90
91/**
92 * The credit form: amount, kind, expiry (never for a refund), what a refund
93 * is for and the day it refunds, the note, and the slug typed out past
94 * `CONFIRM_OVER_MICROS`.
95 */
96export function parseCreditForm(form: FormData, workspace: string, now = new Date()): Parsed<CreditInput> {
97 const amount = parseCreditAmount(text(form, "preset") || "custom", text(form, "amount"));
98 if (!amount.ok) return amount;
99 const kind = text(form, "kind");
100 if (!isCreditKind(kind)) return { ok: false, error: "Choose what kind of credit it is: promotional, goodwill or a refund." };
101
102 let refundFor: string | null = null;
103 let refundDay: string | null = null;
104 let expiresAt: string | null = null;
105 if (kind === "refund") {
106 refundFor = text(form, "refundFor").replace(/\s+/g, " ");
107 if (!refundFor) return { ok: false, error: "Say what the refund is for, such as the failed runs on Oct 2." };
108 if (refundFor.length > MAX_REFUND_FOR) return { ok: false, error: `Keep what the refund is for under ${MAX_REFUND_FOR} characters.` };
109 const day = text(form, "refundDay");
110 if (day) {
111 if (!isDay(day)) return { ok: false, error: "The day refunded is not a date." };
112 if (day > now.toISOString().slice(0, 10)) return { ok: false, error: "The day refunded cannot be in the future." };
113 refundDay = day;
114 }
115 } else {
116 const expiry = parseExpiry(text(form, "expires"), text(form, "expiresOn"), now);
117 if (!expiry.ok) return expiry;
118 expiresAt = expiry.value;
119 }
120
121 const note = parseNote(text(form, "note"));
122 if (!note.ok) return note;
123 if (amount.value > CONFIRM_OVER_MICROS && text(form, "confirmation") !== workspace) {
124 return { ok: false, error: `Over $100: type the workspace's slug, ${workspace}, exactly, to give it.` };
125 }
126 return { ok: true, value: { amountMicros: amount.value, kind, expiresAt, refundFor, refundDay, note: note.value } };
127}
128
129/** `$25.00 credit, $12.40 left, expires Jan 5`: a grant in a line, as the Billing page says it. */
130export function grantSummary(
131 grant: Pick<CreditGrant, "amountMicros" | "leftMicros" | "expiresAt" | "state">,
132 usd: (micros: number) => string,
133 now = new Date(),
134): string {
135 const parts = [`${usd(grant.amountMicros)} credit`];
136 if (grant.state === "open") parts.push(`${usd(grant.leftMicros)} left`);
137 else parts.push(grant.state === "used" ? "all used" : grant.state);
138 if (grant.state === "open" && grant.expiresAt) parts.push(`expires ${shortDay(grant.expiresAt, now)}`);
139 return parts.join(", ");
140}
141
142/** `Jan 5`, or `Jan 5, 2028` outside this year, UTC. */
143export function shortDay(at: string, now = new Date()): string {
144 const date = new Date(at);
145 const sameYear = date.getUTCFullYear() === now.getUTCFullYear();
146 return date.toLocaleDateString("en-US", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" });
147}
148
149/** The months of the credits list: `YYYY-MM`, or null. */
150export function parseMonth(value: string | null): string | null {
151 return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : null;
152}