g1t/apps/sudo/app/lib/forms.ts
| 1 | /** |
| 2 | * Reading sudo's forms. Everything typed is checked here before it goes |
| 3 | * to the billing service, which checks it again. |
| 4 | */ |
| 5 | import type { Allowances, SalesStage, Terms } from "@g1t/contracts"; |
| 6 | |
| 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; |
| 8 | import { isStage } from "./signals.ts"; |
| 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 | |
| 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 | |
| 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 | |
| 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 | } |
| 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 | /** The most agents at once staff can allow one account. */ |
| 183 | export const MAX_AGENTS_AT_ONCE = 100; |
| 184 | /** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */ |
| 185 | export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR; |
| 186 | export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR; |
| 187 | /** The smallest cap: ten cents, as owners may set. */ |
| 188 | const MIN_CAP_MICROS = 100_000; |
| 189 | const MAX_HOLD = 200; |
| 190 | |
| 191 | /** |
| 192 | * Allowances from the plan-and-pools form: the g1t plan without its price, |
| 193 | * the account's share of the open-source pool and of trials, and staff's |
| 194 | * overrides of agents at once, the run cap and the issue cap. A blank |
| 195 | * amount means the default (for a cap, no override). A hold is a line |
| 196 | * saying why new compute is held; blank is no hold. |
| 197 | */ |
| 198 | export function parseAllowances(form: FormData): Parsed<Allowances> { |
| 199 | const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => { |
| 200 | const raw = text(form, name); |
| 201 | if (!raw) return { ok: true, value: null }; |
| 202 | const micros = parseDollars(raw); |
| 203 | if (micros == null) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` }; |
| 204 | if (micros < min) return { ok: false, error: `${what} is at least $${(min / MICROS_PER_DOLLAR).toFixed(2)}, or blank for the default.` }; |
| 205 | if (micros > max) return { ok: false, error: `${what} is at most ${maxText}.` }; |
| 206 | return { ok: true, value: micros }; |
| 207 | }; |
| 208 | const oss = amount("oss", "The open-source share", MAX_POOL_SHARE_MICROS, "$1,000"); |
| 209 | if (!oss.ok) return oss; |
| 210 | const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000"); |
| 211 | if (!trial.ok) return trial; |
| 212 | const runCap = amount("runCap", "The run cap", MAX_RUN_CAP_MICROS, "$1,000", MIN_CAP_MICROS); |
| 213 | if (!runCap.ok) return runCap; |
| 214 | const issueCap = amount("issueCap", "The issue cap", MAX_ISSUE_CAP_MICROS, "$10,000", MIN_CAP_MICROS); |
| 215 | if (!issueCap.ok) return issueCap; |
| 216 | |
| 217 | let maxConcurrentAgents: number | null = null; |
| 218 | const rawAgents = text(form, "agents"); |
| 219 | if (rawAgents) { |
| 220 | if (!/^\d{1,3}$/.test(rawAgents) || Number(rawAgents) < 1 || Number(rawAgents) > MAX_AGENTS_AT_ONCE) { |
| 221 | return { ok: false, error: `Agents at once is a whole number from 1 to ${MAX_AGENTS_AT_ONCE}, or blank for the default.` }; |
| 222 | } |
| 223 | maxConcurrentAgents = Number(rawAgents); |
| 224 | } |
| 225 | |
| 226 | const hold = text(form, "hold").replace(/\s+/g, " "); |
| 227 | if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` }; |
| 228 | |
| 229 | return { |
| 230 | ok: true, |
| 231 | value: { |
| 232 | plan: text(form, "plan") === "on", |
| 233 | ossRepoMicros: oss.value, |
| 234 | trialMicros: trial.value, |
| 235 | maxConcurrentAgents, |
| 236 | runCapMicros: runCap.value, |
| 237 | issueCapMicros: issueCap.value, |
| 238 | hold: hold || null, |
| 239 | }, |
| 240 | }; |
| 241 | } |
| 242 | |
| 243 | /** The most one recorded payment can be; billing refuses more. */ |
| 244 | export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR; |
| 245 | const MAX_REFERENCE = 100; |
| 246 | |
| 247 | export type PaymentInput = { amountMicros: number; reference: string; note: string }; |
| 248 | |
| 249 | /** |
| 250 | * A bank transfer that reached g1t outside Stripe's page, from its form: |
| 251 | * the amount, the transfer's reference (each is recorded once), a note, |
| 252 | * and the workspace's slug typed out to confirm. |
| 253 | */ |
| 254 | export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> { |
| 255 | const amountMicros = parseDollars(text(form, "amount")); |
| 256 | if (amountMicros == null || amountMicros <= 0) { |
| 257 | return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." }; |
| 258 | } |
| 259 | if (amountMicros > MAX_PAYMENT_MICROS) { |
| 260 | return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." }; |
| 261 | } |
| 262 | const reference = text(form, "reference").replace(/\s+/g, " "); |
| 263 | if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." }; |
| 264 | if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` }; |
| 265 | const note = parseNote(text(form, "note")); |
| 266 | if (!note.ok) return note; |
| 267 | if (text(form, "confirmation") !== workspace) { |
| 268 | return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` }; |
| 269 | } |
| 270 | return { ok: true, value: { amountMicros, reference, note: note.value } }; |
| 271 | } |
| 272 | |
| 273 | /** The most staff can approve on a request; billing refuses more. */ |
| 274 | export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR; |
| 275 | |
| 276 | export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string }; |
| 277 | |
| 278 | /** |
| 279 | * A decision on a limit or overage request. Approve takes the amount asked |
| 280 | * (blank) or another; decline needs a note, which the owner reads. |
| 281 | */ |
| 282 | export function parseDecision(form: FormData): Parsed<Decision> { |
| 283 | const id = text(form, "id"); |
| 284 | if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." }; |
| 285 | const decision = text(form, "decision"); |
| 286 | if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." }; |
| 287 | const note = text(form, "note"); |
| 288 | if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` }; |
| 289 | if (decision === "decline") { |
| 290 | if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." }; |
| 291 | return { ok: true, value: { id, decision, amountMicros: null, note } }; |
| 292 | } |
| 293 | let amountMicros: number | null = null; |
| 294 | const raw = text(form, "amount"); |
| 295 | if (raw) { |
| 296 | const micros = parseDollars(raw); |
| 297 | if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." }; |
| 298 | if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." }; |
| 299 | amountMicros = micros; |
| 300 | } |
| 301 | return { ok: true, value: { id, decision, amountMicros, note } }; |
| 302 | } |
| 303 | |
| 304 | export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null }; |
| 305 | |
| 306 | /** A typed reason is a sentence: billing asks for at least this many characters. */ |
| 307 | export const MIN_REASON = 10; |
| 308 | |
| 309 | /** |
| 310 | * A goodwill credit from its form. No amount is the one-click credit. A |
| 311 | * reason is required when `needsReason` says so (more than the one-click |
| 312 | * credit, or a second within 12 months). The day is when the accidental |
| 313 | * usage happened; blank lets billing choose it. |
| 314 | */ |
| 315 | export function parseGoodwill( |
| 316 | form: FormData, |
| 317 | needsReason: (amountMicros: number | null) => boolean, |
| 318 | now = new Date(), |
| 319 | ): Parsed<GoodwillInput> { |
| 320 | let amountMicros: number | null = null; |
| 321 | const raw = text(form, "amount"); |
| 322 | if (raw) { |
| 323 | const micros = parseDollars(raw); |
| 324 | if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." }; |
| 325 | if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." }; |
| 326 | amountMicros = micros; |
| 327 | } |
| 328 | const reason = text(form, "reason").replace(/\s+/g, " "); |
| 329 | if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` }; |
| 330 | if (needsReason(amountMicros) && reason.length < MIN_REASON) { |
| 331 | return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." }; |
| 332 | } |
| 333 | let day: string | null = null; |
| 334 | const rawDay = text(form, "day"); |
| 335 | if (rawDay) { |
| 336 | const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null; |
| 337 | if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." }; |
| 338 | if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." }; |
| 339 | day = rawDay; |
| 340 | } |
| 341 | return { ok: true, value: { amountMicros, reason, day } }; |
| 342 | } |