g1t/apps/sudo/app/lib/forms.ts
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 accounts, terms and enterprises; g1t is no longer free | 1 | /** |
| 2 | * Reading sudo's forms. Everything typed is checked here before it goes | |
| 3 | * to the billing service, which checks it again. | |
| 4 | */ | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 5 | import type { Allowances, SalesStage, Terms } from "@g1t/contracts"; |
| Billing accounts, terms and enterprises; g1t is no longer free | 6 | |
| 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 8 | import { isStage } from "./signals.ts"; |
| Billing accounts, terms and enterprises; g1t is no longer free | 9 | |
| 10 | /** A workspace slug, as identity allows them (GitHub's rules). */ | |
| 11 | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 12 | /** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */ | |
| 13 | const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; | |
| 14 | ||
| 15 | /** The most one credit can be, against a slipped finger. */ | |
| 16 | export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR; | |
| 17 | const MAX_NOTE = 500; | |
| 18 | ||
| 19 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 20 | ||
| 21 | export function text(form: FormData, name: string): string { | |
| 22 | const value = form.get(name); | |
| 23 | return typeof value === "string" ? value.trim() : ""; | |
| 24 | } | |
| 25 | ||
| 26 | /** The form's own fields, to carry through a confirmation step. */ | |
| 27 | export function fields(form: FormData, ...names: string[]): Record<string, string> { | |
| 28 | return Object.fromEntries(names.map((name) => [name, text(form, name)])); | |
| 29 | } | |
| 30 | ||
| 31 | export function isSlug(value: string): boolean { | |
| 32 | return SLUG.test(value); | |
| 33 | } | |
| 34 | ||
| 35 | export function isAccountId(value: string): boolean { | |
| 36 | return ACCOUNT_ID.test(value); | |
| 37 | } | |
| 38 | ||
| 39 | export function parseSlug(raw: string): Parsed<string> { | |
| 40 | const slug = raw.trim().toLowerCase(); | |
| 41 | return isSlug(slug) ? { ok: true, value: slug } : { ok: false, error: `“${raw}” is not a workspace slug.` }; | |
| 42 | } | |
| 43 | ||
| 44 | /** Slugs separated by commas, spaces or lines; each once. */ | |
| 45 | export function parseSlugList(raw: string): Parsed<string[]> { | |
| 46 | const slugs: string[] = []; | |
| 47 | for (const part of raw.split(/[\s,]+/).filter(Boolean)) { | |
| 48 | const slug = parseSlug(part); | |
| 49 | if (!slug.ok) return slug; | |
| 50 | if (!slugs.includes(slug.value)) slugs.push(slug.value); | |
| 51 | } | |
| 52 | return { ok: true, value: slugs }; | |
| 53 | } | |
| 54 | ||
| 55 | export function parseNote(raw: string): Parsed<string> { | |
| 56 | if (!raw) return { ok: false, error: "A note is required: say why, for whoever looks next." }; | |
| 57 | if (raw.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` }; | |
| 58 | return { ok: true, value: raw }; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Terms from the terms form. Standard clears everything else; a ceiling | |
| 63 | * applies to comped and custom; a discount to custom only. An end date is | |
| 64 | * a day, and the terms last to its end, UTC. | |
| 65 | */ | |
| 66 | export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> { | |
| 67 | const kind = text(form, "kind"); | |
| 68 | if (kind !== "standard" && kind !== "comped" && kind !== "custom") return { ok: false, error: "Choose standard, comped or custom terms." }; | |
| 69 | const note = parseNote(text(form, "note")); | |
| 70 | if (!note.ok) return note; | |
| 71 | ||
| 72 | let discountPercent = 0; | |
| 73 | if (kind === "custom") { | |
| 74 | const raw = text(form, "discount"); | |
| 75 | if (raw !== "") { | |
| 76 | if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." }; | |
| 77 | discountPercent = Number(raw); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | let ceilingMicros: number | null = null; | |
| 82 | if (kind !== "standard") { | |
| 83 | const raw = text(form, "ceiling"); | |
| 84 | if (raw !== "") { | |
| 85 | const micros = parseDollars(raw); | |
| 86 | if (micros == null) return { ok: false, error: "The ceiling is a dollar amount, such as 250 or 1,000.00." }; | |
| 87 | ceilingMicros = micros; | |
| 88 | } | |
| 89 | } | |
| 90 | if (kind === "custom" && discountPercent === 0 && ceilingMicros == null) { | |
| 91 | return { ok: false, error: "Custom terms need a discount, a ceiling, or both." }; | |
| 92 | } | |
| 93 | ||
| 94 | let until: string | null = null; | |
| 95 | if (kind !== "standard") { | |
| 96 | const raw = text(form, "until"); | |
| 97 | if (raw !== "") { | |
| 98 | const end = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T23:59:59Z`) : null; | |
| 99 | if (!end || Number.isNaN(end.getTime()) || end.toISOString().slice(0, 10) !== raw) { | |
| 100 | return { ok: false, error: "The end date is not a date." }; | |
| 101 | } | |
| 102 | if (end.getTime() <= now.getTime()) return { ok: false, error: "The end date has to be in the future." }; | |
| 103 | until = end.toISOString().replace(".000Z", "Z"); | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | return { | |
| 108 | ok: true, | |
| 109 | value: { kind, discountPercent, ceilingMicros, note: note.value, until, setBy: by, setAt: now.toISOString() }, | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| Stripe webhooks, enterprise invoices, and sudo for both | 113 | /** An email address for invoices: one address, lowercased. */ |
| 114 | export function parseEmail(raw: string): Parsed<string> { | |
| 115 | const email = raw.trim().toLowerCase(); | |
| 116 | const [local, domain, ...rest] = email.split("@"); | |
| 117 | const ok = | |
| 118 | rest.length === 0 && | |
| 119 | email.length <= 254 && | |
| 120 | !!local && | |
| 121 | !!domain && | |
| 122 | /^[^\s@,;<>"]+$/.test(local) && | |
| 123 | /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain); | |
| 124 | return ok ? { ok: true, value: email } : { ok: false, error: "Enter one email address, such as billing@acme.com." }; | |
| 125 | } | |
| 126 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 127 | export type SalesUpdate = { stage: SalesStage; owner: string | null; nextStep: string | null; nextAt: string | null }; |
| 128 | ||
| 129 | const MAX_NEXT_STEP = 200; | |
| 130 | const MAX_SALES_NOTE = 2000; | |
| 131 | ||
| 132 | /** | |
| 133 | * A workspace's sales record from its form. The owner is a staff member's | |
| 134 | * email, or nobody; a next step may have a date (a day, UTC) or not. | |
| 135 | */ | |
| 136 | export function parseSales(form: FormData): Parsed<SalesUpdate> { | |
| 137 | const stage = text(form, "stage"); | |
| 138 | if (!isStage(stage)) return { ok: false, error: "Choose a stage." }; | |
| 139 | ||
| 140 | let owner: string | null = null; | |
| 141 | const rawOwner = text(form, "owner"); | |
| 142 | if (rawOwner !== "") { | |
| 143 | const email = parseEmail(rawOwner); | |
| 144 | if (!email.ok) return { ok: false, error: "The owner is a staff member's email, such as you@g1t.sh, or blank for nobody." }; | |
| 145 | owner = email.value; | |
| 146 | } | |
| 147 | ||
| 148 | const nextStep = text(form, "nextStep").replace(/\s+/g, " "); | |
| 149 | if (nextStep.length > MAX_NEXT_STEP) return { ok: false, error: `Keep the next step under ${MAX_NEXT_STEP} characters.` }; | |
| 150 | ||
| 151 | let nextAt: string | null = null; | |
| 152 | const rawDate = text(form, "nextAt"); | |
| 153 | if (rawDate !== "") { | |
| 154 | const day = /^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? new Date(`${rawDate}T00:00:00Z`) : null; | |
| 155 | if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== rawDate) { | |
| 156 | return { ok: false, error: "The follow-up date is not a date." }; | |
| 157 | } | |
| 158 | nextAt = rawDate; | |
| 159 | } | |
| 160 | if (nextAt && !nextStep) return { ok: false, error: "Say what the next step is, as well as when." }; | |
| 161 | ||
| 162 | return { ok: true, value: { stage, owner, nextStep: nextStep || null, nextAt } }; | |
| 163 | } | |
| 164 | ||
| 165 | /** A note on a workspace's sales record. */ | |
| 166 | export function parseSalesNote(raw: string): Parsed<string> { | |
| 167 | if (!raw) return { ok: false, error: "Write the note first." }; | |
| 168 | if (raw.length > MAX_SALES_NOTE) return { ok: false, error: `Keep a note under ${MAX_SALES_NOTE} characters.` }; | |
| 169 | return { ok: true, value: raw }; | |
| 170 | } | |
| 171 | ||
| Billing accounts, terms and enterprises; g1t is no longer free | 172 | /** A credit's amount: more than nothing, and no more than the cap. */ |
| 173 | export function parseCredit(raw: string): Parsed<number> { | |
| 174 | const micros = parseDollars(raw); | |
| 175 | if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 25 or 120.50." }; | |
| 176 | if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "One credit is at most $10,000. Issue more than one if it really is more." }; | |
| 177 | return { ok: true, value: micros }; | |
| 178 | } | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 179 | |
| 180 | /** The most staff can set an account's share of a pool to, against a slipped finger. */ | |
| 181 | export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR; | |
| 182 | ||
| 183 | /** | |
| 184 | * Allowances from the plan-and-pools form: Team without charge, and the | |
| 185 | * account's share of the open-source pool and of trials. A blank amount | |
| 186 | * means the default. | |
| 187 | */ | |
| 188 | export function parseAllowances(form: FormData): Parsed<Allowances> { | |
| 189 | const amount = (name: string, what: string): Parsed<number | null> => { | |
| 190 | const raw = text(form, name); | |
| 191 | if (!raw) return { ok: true, value: null }; | |
| 192 | const micros = parseDollars(raw); | |
| 193 | if (micros == null || micros < 0) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` }; | |
| 194 | if (micros > MAX_POOL_SHARE_MICROS) return { ok: false, error: `${what} is at most $1,000.` }; | |
| 195 | return { ok: true, value: micros }; | |
| 196 | }; | |
| 197 | const oss = amount("oss", "The open-source share"); | |
| 198 | if (!oss.ok) return oss; | |
| 199 | const trial = amount("trial", "The trial credit"); | |
| 200 | if (!trial.ok) return trial; | |
| 201 | return { ok: true, value: { team: text(form, "team") === "on", ossRepoMicros: oss.value, trialMicros: trial.value } }; | |
| 202 | } |