Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

278 linesCodeBlameRaw
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 over
6 * $100 needs the workspace's slug typed out.
7 */
8import type { Terms } from "@g1t/contracts";
9import { data, redirect } from "react-router";
10
11import { parseCreditForm } from "./credits";
12import { fields, parseAllowances, parseComplimentary, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms";
13import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing";
14import type { ActionData } from "./review";
15import { admin, identity, priceBook } from "./services.server";
16import { settle } from "./settle";
17import type { Staff } from "./staff";
18import type { Enterprise } from "./workspaces";
19
20/** What a page acts on. `accountId` is billing's internal id, never shown. */
21export type Subject =
22 | { kind: "workspace"; slug: string; accountId: string; terms: Terms; billedTo: Enterprise | null }
23 | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[]; billingEmail: string | null };
24
25function failed(section: string, error: string, values?: Record<string, string>) {
26 return data<ActionData>({ error, section, values }, { status: 422 });
27}
28
29export async function billingAction(request: Request, staff: Staff, subject: Subject, path: string) {
30 const form = await request.formData();
31 const intent = text(form, "intent");
32 const confirmed = text(form, "confirm") === "yes";
33 const back = (done: string) => redirect(`${path}?done=${done}#top`);
34 const isEnterprise = subject.kind === "enterprise";
35
36 if (intent === "terms") {
37 const values = fields(form, "preset", "discount", "ceiling", "note", "until");
38 if (subject.kind === "workspace" && subject.billedTo) {
39 return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values);
40 }
41 const terms = parseTerms(form, staff.email);
42 if (!terms.ok) return failed("terms", terms.error, values);
43 if (!confirmed) return { review: { intent, before: subject.terms, after: terms.value, fields: values } } satisfies ActionData;
44 const result = await admin.setTerms(subject.accountId, terms.value, staff.email);
45 if (!result.ok) return failed("terms", result.error.message, values);
46 return back("terms");
47 }
48
49 if (intent === "allowances") {
50 // The plan without its price, the account's share of g1t's pools, and staff's overrides.
51 const values = fields(form, "plan", "oss", "trial", "hold", "note");
52 if (subject.kind === "workspace" && subject.billedTo) {
53 return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values);
54 }
55 const allowances = parseAllowances(form);
56 if (!allowances.ok) return failed("allowances", allowances.error, values);
57 const note = parseNote(values.note);
58 if (!note.ok) return failed("allowances", note.error, values);
59 const result = await admin.setAllowances(subject.accountId, allowances.value, note.value, staff.email);
60 if (!result.ok) return failed("allowances", result.error.message, values);
61 return back("allowances");
62 }
63
64 if (intent === "complimentary" || intent === "end-complimentary") {
65 // Enrollment is per workspace, never an enterprise's.
66 const values = fields(form, "allowance", "unlimited", "fee", "past", "note", "confirmation");
67 if (subject.kind !== "workspace") return failed("complimentary", "Enroll each workspace on its own page.", values);
68 if (subject.billedTo) return failed("complimentary", `${subject.billedTo.name} pays for this workspace. Detach it first.`, values);
69 if (intent === "end-complimentary") {
70 const note = parseNote(values.note);
71 if (!note.ok) return failed("complimentary", note.error, values);
72 if (values.confirmation !== subject.slug) {
73 return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to end its enrollment. It goes back to Free now.`, values);
74 }
75 const ended = await admin.endComplimentary(subject.slug, note.value, staff.email);
76 if (!ended.ok) return failed("complimentary", ended.error.message, values);
77 return back("complimentary-ended");
78 }
79 const terms = parseComplimentary(form);
80 if (!terms.ok) return failed("complimentary", terms.error, values);
81 if (values.confirmation !== subject.slug) {
82 return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to confirm these terms.`, values);
83 }
84 const { note, ...chosen } = terms.value;
85 const saved = await admin.setComplimentary(subject.slug, chosen, note, staff.email);
86 if (!saved.ok) return failed("complimentary", saved.error.message, values);
87 return back("complimentary");
88 }
89
90 if (intent === "attach") {
91 const values = fields(form, "workspace", "target");
92 const section = isEnterprise ? "members" : "billed-to";
93 let slug: string;
94 let target: Enterprise;
95 if (subject.kind === "enterprise") {
96 const parsed = parseSlug(values.workspace);
97 if (!parsed.ok) return failed(section, parsed.error, values);
98 slug = parsed.value;
99 if (subject.workspaces.includes(slug)) return failed(section, `${slug} is already on this enterprise.`, values);
100 if (!(await identity.workspace(slug))) return failed(section, `There is no workspace called ${slug}.`, values);
101 target = { id: subject.accountId, name: subject.name };
102 } else {
103 slug = subject.slug;
104 const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target);
105 if (!enterprise) return failed(section, "Choose an enterprise to move onto.", values);
106 target = { id: enterprise.account.id, name: enterprise.account.name };
107 }
108 if (!confirmed) return { review: { intent, workspace: slug, targetName: target.name, fields: values } } satisfies ActionData;
109 const result = await admin.attach(slug, target.id, staff.email);
110 if (!result.ok) return failed(section, result.error.message, values);
111 return back("attach");
112 }
113
114 if (intent === "detach") {
115 const values = fields(form, "workspace");
116 const section = isEnterprise ? "members" : "billed-to";
117 let slug: string;
118 let from: string;
119 if (subject.kind === "enterprise") {
120 slug = values.workspace;
121 if (!subject.workspaces.includes(slug)) return failed(section, `${slug} is not on this enterprise.`);
122 from = subject.name;
123 } else {
124 if (!subject.billedTo) return failed(section, `${subject.slug} already pays for itself.`);
125 slug = subject.slug;
126 from = subject.billedTo.name;
127 }
128 if (!confirmed) return { review: { intent, workspace: slug, from, fields: values } } satisfies ActionData;
129 const result = await admin.attach(slug, null, staff.email);
130 if (!result.ok) return failed(section, result.error.message);
131 return back("detach");
132 }
133
134 if (intent === "credit") {
135 const values = fields(form, "workspace", "preset", "amount", "kind", "expires", "expiresOn", "refundFor", "refundDay", "note");
136 const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug;
137 if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) {
138 return failed("credit", "Choose one of this enterprise's workspaces.", values);
139 }
140 const credit = parseCreditForm(form, workspace);
141 if (!credit.ok) return failed("credit", credit.error, values);
142 const { amountMicros, note, ...options } = credit.value;
143 const result = await admin.credit(workspace, amountMicros, note, staff.email, options);
144 if (!result.ok) return failed("credit", result.error.message, values);
145 return back("credit");
146 }
147
148 if (intent === "revoke-credit") {
149 // What is left of a grant, taken back. The grant must be one of the page's.
150 const values = fields(form, "id", "reason");
151 const workspaces = subject.kind === "enterprise" ? subject.workspaces : [subject.slug];
152 const reason = parseNote(values.reason);
153 if (!reason.ok) return failed("credits", reason.error, values);
154 const { grants } = await admin.credits(subject.kind === "workspace" ? { workspace: subject.slug } : {});
155 const grant = grants.find((row) => row.id === values.id && workspaces.includes(row.workspace));
156 if (!grant) return failed("credits", "That credit is not this page's, or is gone.", values);
157 const result = await admin.revokeCredit(grant.id, reason.value, staff.email);
158 if (!result.ok) return failed("credits", result.error.message, values);
159 return back("revoked");
160 }
161
162 if (intent === "reset") {
163 // A test workspace's billing wiped. Billing refuses it on a live Stripe
164 // key, for comped workspaces and for an enterprise's.
165 const values = fields(form, "note", "confirmation");
166 if (subject.kind !== "workspace") return failed("top", "Reset a workspace, not an enterprise.");
167 const note = parseNote(values.note);
168 if (!note.ok) return failed("reset", note.error, values);
169 if (values.confirmation !== subject.slug) {
170 return failed("reset", `Type the workspace's slug, ${subject.slug}, exactly, to reset it.`, { ...values, confirmation: "" });
171 }
172 const result = await settle(admin.resetBilling(subject.slug, values.confirmation, note.value, staff.email));
173 if (!result.ok) return failed("reset", `Billing did not answer: ${result.error}`, values);
174 if (!result.value.ok) return failed("reset", result.value.error.message, values);
175 return back(result.value.value.refreshed === false ? "reset-stale" : "reset");
176 }
177
178 if (intent === "payment") {
179 // A bank transfer that reached g1t outside Stripe's page.
180 const values = fields(form, "amount", "reference", "note", "confirmation");
181 if (subject.kind !== "workspace") return failed("top", "Record a payment on the workspace it is for.");
182 const payment = parsePayment(form, subject.slug);
183 if (!payment.ok) return failed("payment", payment.error, { ...values, confirmation: "" });
184 const { amountMicros, reference, note } = payment.value;
185 const result = await admin.recordPayment(subject.slug, amountMicros, reference, note, staff.email);
186 if (!result.ok) return failed("payment", result.error.message, values);
187 return back("payment");
188 }
189
190 if (intent === "goodwill") {
191 // A goodwill credit from the workspace's page: the overage, as billing quotes it now.
192 const values = fields(form, "amount", "reason", "day");
193 if (subject.kind !== "workspace") return failed("top", "Goodwill is given to one workspace.");
194 const quote = await goodwillQuote(subject.slug);
195 const parsed = parseGoodwill(form, (amount) =>
196 // A reason past the one-click credit, a second within 12 months, or past the cap on real cost.
197 quote ? ((w) => w.needsReason || w.overCap)(goodwillWarning(amount, quote.goodwill, quote.lastGoodwillAt, quote.cap)) : amount != null,
198 );
199 if (!parsed.ok) return failed("goodwill", parsed.error, values);
200 const result = await admin.goodwill(subject.slug, parsed.value.amountMicros, parsed.value.reason, staff.email, parsed.value.day);
201 if (!result.ok) return failed("goodwill", result.error.message, values);
202 return back("goodwill");
203 }
204
205 if (intent === "billing-link") {
206 if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page.");
207 if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData;
208 const result = await admin.billingLink(subject.slug, staff.email);
209 if (!result.ok) return failed("billing-link", result.error.message);
210 // Shown once, in this response only: it is never stored or redirected to.
211 return { link: result.value, workspace: subject.slug } satisfies ActionData;
212 }
213
214 if (intent === "billing-email") {
215 const values = fields(form, "email");
216 if (subject.kind !== "enterprise") return failed("top", "Only an enterprise has an invoice email.");
217 const email = parseEmail(values.email);
218 if (!email.ok) return failed("invoices", email.error, values);
219 if (email.value === subject.billingEmail) return failed("invoices", "That is already where its invoices go.", values);
220 if (!confirmed) {
221 return {
222 review: { intent, name: subject.name, before: subject.billingEmail, after: email.value, fields: { email: email.value } },
223 } satisfies ActionData;
224 }
225 const result = await admin.enterpriseBilling(subject.accountId, email.value, staff.email);
226 if (!result.ok) return failed("invoices", result.error.message, values);
227 return back("billing-email");
228 }
229
230 if (intent === "billing-address") {
231 // Where Stripe Tax places the enterprise: an invoice is not sent without it.
232 const values = fields(form, "line1", "line2", "city", "state", "postalCode", "country", "taxIdType", "taxId");
233 if (subject.kind !== "enterprise") return failed("top", "Only an enterprise's address is set here; a workspace's owners set their own.");
234 if (!subject.billingEmail) return failed("address", "Set where its invoices go first: that makes its Stripe customer.", values);
235 const country = values.country.trim().toUpperCase();
236 if (!/^[A-Z]{2}$/.test(country)) return failed("address", "The country is two letters, such as US or DE.", values);
237 if (country === "US" && !values.postalCode.trim()) return failed("address", "In the US, Stripe Tax needs the ZIP code.", values);
238 const taxIdType = values.taxIdType.trim();
239 const taxId = values.taxId.trim();
240 if (Boolean(taxIdType) !== Boolean(taxId)) return failed("address", "Give the tax ID's kind and its number together, or neither.", values);
241 const address = {
242 line1: values.line1.trim(),
243 line2: values.line2.trim(),
244 city: values.city.trim(),
245 state: values.state.trim(),
246 postalCode: values.postalCode.trim(),
247 country,
248 };
249 const result = await admin.enterpriseAddress(subject.accountId, address, taxIdType || null, taxId || null, staff.email);
250 if (!result.ok) return failed("address", result.error.message, values);
251 return back("billing-address");
252 }
253
254 if (intent === "invoice") {
255 if (subject.kind !== "enterprise") return failed("top", "Only an enterprise is invoiced from sudo.");
256 if (!confirmed) {
257 return {
258 review: { intent, name: subject.name, email: subject.billingEmail, workspaces: subject.workspaces.length, fields: {} },
259 } satisfies ActionData;
260 }
261 const result = await admin.invoiceEnterprise(subject.accountId, staff.email);
262 if (!result.ok) return failed("invoices", result.error.message);
263 // Shown in this response; the invoice is also in the list from now on.
264 return { invoice: result.value } satisfies ActionData;
265 }
266
267 return failed("top", "Unknown action.");
268}
269
270/** The workspace's goodwill quote from the Overages queue, if it is in it, and the cap on real cost. */
271export async function goodwillQuote(
272 workspace: string,
273): Promise<{ goodwill: { creditMicros: number; marginMicros: number }; lastGoodwillAt: string | null; cap: number } | null> {
274 const [queue, book] = await Promise.all([admin.overages().catch(() => []), priceBook().catch(() => null)]);
275 const row = queue.find((overage) => overage.workspace === workspace);
276 const cap = book?.free?.overageForgiveCostMicros || FORGIVE_COST_MICROS;
277 return row ? { goodwill: row.goodwill, lastGoodwillAt: row.lastGoodwillAt, cap } : null;
278}