pr_01m47d24b0e6n91zwymwxg0vpx/apps/sudo/app/lib/billing-actions.server.ts
| 1 | /** |
| 2 | * The changes staff make to how a workspace or an enterprise pays: terms, |
| 3 | * moving workspaces on and off enterprises, credits, and Stripe billing |
| 4 | * links. What is acted on comes from the billing service and identity, not |
| 5 | * from the form; each change shows a confirmation first, and a credit |
| 6 | * needs the workspace's slug typed out. |
| 7 | */ |
| 8 | import type { Terms } from "@g1t/contracts"; |
| 9 | import { data, redirect } from "react-router"; |
| 10 | |
| 11 | import { fields, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms"; |
| 12 | import type { ActionData } from "./review"; |
| 13 | import { admin, identity } from "./services.server"; |
| 14 | import type { Staff } from "./staff"; |
| 15 | import type { Enterprise } from "./workspaces"; |
| 16 | |
| 17 | /** What a page acts on. `accountId` is billing's internal id, never shown. */ |
| 18 | export type Subject = |
| 19 | | { kind: "workspace"; slug: string; accountId: string; terms: Terms; billedTo: Enterprise | null } |
| 20 | | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[]; billingEmail: string | null }; |
| 21 | |
| 22 | function failed(section: string, error: string, values?: Record<string, string>) { |
| 23 | return data<ActionData>({ error, section, values }, { status: 422 }); |
| 24 | } |
| 25 | |
| 26 | export async function billingAction(request: Request, staff: Staff, subject: Subject, path: string) { |
| 27 | const form = await request.formData(); |
| 28 | const intent = text(form, "intent"); |
| 29 | const confirmed = text(form, "confirm") === "yes"; |
| 30 | const back = (done: string) => redirect(`${path}?done=${done}#top`); |
| 31 | const isEnterprise = subject.kind === "enterprise"; |
| 32 | |
| 33 | if (intent === "terms") { |
| 34 | const values = fields(form, "kind", "discount", "ceiling", "note", "until"); |
| 35 | if (subject.kind === "workspace" && subject.billedTo) { |
| 36 | return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values); |
| 37 | } |
| 38 | const terms = parseTerms(form, staff.email); |
| 39 | if (!terms.ok) return failed("terms", terms.error, values); |
| 40 | if (!confirmed) return { review: { intent, before: subject.terms, after: terms.value, fields: values } } satisfies ActionData; |
| 41 | const result = await admin.setTerms(subject.accountId, terms.value, staff.email); |
| 42 | if (!result.ok) return failed("terms", result.error.message, values); |
| 43 | return back("terms"); |
| 44 | } |
| 45 | |
| 46 | if (intent === "attach") { |
| 47 | const values = fields(form, "workspace", "target"); |
| 48 | const section = isEnterprise ? "members" : "billed-to"; |
| 49 | let slug: string; |
| 50 | let target: Enterprise; |
| 51 | if (subject.kind === "enterprise") { |
| 52 | const parsed = parseSlug(values.workspace); |
| 53 | if (!parsed.ok) return failed(section, parsed.error, values); |
| 54 | slug = parsed.value; |
| 55 | if (subject.workspaces.includes(slug)) return failed(section, `${slug} is already on this enterprise.`, values); |
| 56 | if (!(await identity.workspace(slug))) return failed(section, `There is no workspace called ${slug}.`, values); |
| 57 | target = { id: subject.accountId, name: subject.name }; |
| 58 | } else { |
| 59 | slug = subject.slug; |
| 60 | const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target); |
| 61 | if (!enterprise) return failed(section, "Choose an enterprise to move onto.", values); |
| 62 | target = { id: enterprise.account.id, name: enterprise.account.name }; |
| 63 | } |
| 64 | if (!confirmed) return { review: { intent, workspace: slug, targetName: target.name, fields: values } } satisfies ActionData; |
| 65 | const result = await admin.attach(slug, target.id, staff.email); |
| 66 | if (!result.ok) return failed(section, result.error.message, values); |
| 67 | return back("attach"); |
| 68 | } |
| 69 | |
| 70 | if (intent === "detach") { |
| 71 | const values = fields(form, "workspace"); |
| 72 | const section = isEnterprise ? "members" : "billed-to"; |
| 73 | let slug: string; |
| 74 | let from: string; |
| 75 | if (subject.kind === "enterprise") { |
| 76 | slug = values.workspace; |
| 77 | if (!subject.workspaces.includes(slug)) return failed(section, `${slug} is not on this enterprise.`); |
| 78 | from = subject.name; |
| 79 | } else { |
| 80 | if (!subject.billedTo) return failed(section, `${subject.slug} already pays for itself.`); |
| 81 | slug = subject.slug; |
| 82 | from = subject.billedTo.name; |
| 83 | } |
| 84 | if (!confirmed) return { review: { intent, workspace: slug, from, fields: values } } satisfies ActionData; |
| 85 | const result = await admin.attach(slug, null, staff.email); |
| 86 | if (!result.ok) return failed(section, result.error.message); |
| 87 | return back("detach"); |
| 88 | } |
| 89 | |
| 90 | if (intent === "credit") { |
| 91 | const values = fields(form, "workspace", "amount", "note", "confirmation"); |
| 92 | const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug; |
| 93 | if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) { |
| 94 | return failed("credit", "Choose one of this enterprise's workspaces.", values); |
| 95 | } |
| 96 | const amount = parseCredit(values.amount); |
| 97 | if (!amount.ok) return failed("credit", amount.error, values); |
| 98 | const note = parseNote(values.note); |
| 99 | if (!note.ok) return failed("credit", note.error, values); |
| 100 | if (values.confirmation !== workspace) { |
| 101 | return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" }); |
| 102 | } |
| 103 | const result = await admin.credit(workspace, amount.value, note.value, staff.email); |
| 104 | if (!result.ok) return failed("credit", result.error.message, values); |
| 105 | return back("credit"); |
| 106 | } |
| 107 | |
| 108 | if (intent === "billing-link") { |
| 109 | if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page."); |
| 110 | if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData; |
| 111 | const result = await admin.billingLink(subject.slug, staff.email); |
| 112 | if (!result.ok) return failed("billing-link", result.error.message); |
| 113 | // Shown once, in this response only: it is never stored or redirected to. |
| 114 | return { link: result.value, workspace: subject.slug } satisfies ActionData; |
| 115 | } |
| 116 | |
| 117 | if (intent === "billing-email") { |
| 118 | const values = fields(form, "email"); |
| 119 | if (subject.kind !== "enterprise") return failed("top", "Only an enterprise has an invoice email."); |
| 120 | const email = parseEmail(values.email); |
| 121 | if (!email.ok) return failed("invoices", email.error, values); |
| 122 | if (email.value === subject.billingEmail) return failed("invoices", "That is already where its invoices go.", values); |
| 123 | if (!confirmed) { |
| 124 | return { |
| 125 | review: { intent, name: subject.name, before: subject.billingEmail, after: email.value, fields: { email: email.value } }, |
| 126 | } satisfies ActionData; |
| 127 | } |
| 128 | const result = await admin.enterpriseBilling(subject.accountId, email.value, staff.email); |
| 129 | if (!result.ok) return failed("invoices", result.error.message, values); |
| 130 | return back("billing-email"); |
| 131 | } |
| 132 | |
| 133 | if (intent === "invoice") { |
| 134 | if (subject.kind !== "enterprise") return failed("top", "Only an enterprise is invoiced from sudo."); |
| 135 | if (!confirmed) { |
| 136 | return { |
| 137 | review: { intent, name: subject.name, email: subject.billingEmail, workspaces: subject.workspaces.length, fields: {} }, |
| 138 | } satisfies ActionData; |
| 139 | } |
| 140 | const result = await admin.invoiceEnterprise(subject.accountId, staff.email); |
| 141 | if (!result.ok) return failed("invoices", result.error.message); |
| 142 | // Shown in this response; the invoice is also in the list from now on. |
| 143 | return { invoice: result.value } satisfies ActionData; |
| 144 | } |
| 145 | |
| 146 | return failed("top", "Unknown action."); |
| 147 | } |