Skip to content

g1t/apps/sudo/app/routes/enterprise.tsx

437 lines20,073 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.

Merge Stripe Tax, the card fee on card payments, and one free workspace per person1import { ArrowLeft, ExternalLink, Mail, MapPin, Plus, Send, Trash2 } from "lucide-react";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace2import { data, Link, redirect, useLocation } from "react-router";
3
Stripe webhooks, enterprise invoices, and sudo for both4import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace5
6import type { Route } from "./+types/enterprise";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging7import { AllowancesForm, AuditSection, CreditForm, CreditList, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace8import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
9import { type Subject, billingAction } from "~/lib/billing-actions.server";
10import { usd } from "~/lib/money";
11import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
12import { admin, identity } from "~/lib/services.server";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging13import { settle } from "~/lib/settle";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace14import { requireStaff } from "~/lib/staff";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging15import { fullDiscount } from "~/lib/terms";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace16import { legacyAccountPath } from "~/lib/workspaces";
17
18export const meta: Route.MetaFunction = ({ loaderData }) => [
19 { title: `${loaderData?.detail.summary.account.name ?? "Enterprise"} · sudo` },
20 { name: "robots", content: "noindex, nofollow" },
21];
22
23const ENTERPRISE_ID = /^ent_[a-z0-9_-]{1,80}$/;
24
25async function load(raw: string) {
26 const id = raw.trim().toLowerCase();
27 if (!ENTERPRISE_ID.test(id)) {
28 // A workspace's id or slug: its own page.
29 const path = legacyAccountPath(id);
30 if (path) throw redirect(path);
31 throw data("That is not an enterprise.", { status: 404 });
32 }
33 const result = await admin.account(id);
34 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
35 const detail = result.value;
36 const { account } = detail.summary;
37 if (account.kind !== "enterprise") throw data("That is not an enterprise.", { status: 404 });
Stripe webhooks, enterprise invoices, and sudo for both38 const subject: Subject = {
39 kind: "enterprise",
40 accountId: account.id,
41 name: account.name,
42 terms: account.terms,
43 workspaces: account.workspaces,
44 billingEmail: account.billingEmail ?? null,
45 };
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace46 return { detail, subject };
47}
48
49export async function loader({ request, params, context }: Route.LoaderArgs) {
50 requireStaff(context);
51 const { detail } = await load(params.id);
52 const slugs = detail.summary.account.workspaces;
53 const listed = await identity.workspaces();
54 const people = new Map(listed.map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]));
55 // Members older than identity's list: look them up one by one.
56 const missing = slugs.filter((slug) => !people.has(slug)).slice(0, 25);
57 for (const found of await Promise.all(missing.map((slug) => identity.workspace(slug)))) {
58 if (found) {
59 people.set(found.slug, {
60 name: found.name,
61 owners: found.members.filter((member) => member.role === "owner").map(({ username, email }) => ({ username, email })),
62 });
63 }
64 }
65 const limits = new Map(detail.workspaces.map((limit) => [limit.workspace, limit]));
66 const shares = new Map((detail.summary.byWorkspace ?? []).map((share) => [share.workspace, share]));
67 const members = slugs.map((slug) => ({
68 slug,
69 name: people.get(slug)?.name ?? null,
70 owners: people.get(slug)?.owners ?? ([] as AdminOwner[]),
71 limit: limits.get(slug) ?? null,
72 chargedMicros: shares.get(slug)?.chargedMicros ?? 0,
73 }));
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging74 const credits = await settle(admin.credits());
75 return {
76 detail,
77 members,
78 credits: credits.ok ? credits.value.grants.filter((grant) => slugs.includes(grant.workspace)) : [],
79 creditsError: credits.ok ? null : credits.error,
80 done: doneMessage(request.url),
81 };
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace82}
83
84export async function action({ request, params, context }: Route.ActionArgs) {
85 const staff = requireStaff(context);
86 // What is acted on comes from billing, not from the form.
87 const { subject } = await load(params.id);
88 return billingAction(request, staff, subject, `/enterprises/${encodeURIComponent(subject.accountId)}`);
89}
90
91type Member = Route.ComponentProps["loaderData"]["members"][number];
92
93export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging94 const { detail, members, credits, creditsError, done } = loaderData;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace95 const { summary } = detail;
96 const { account, limit } = summary;
97 const { pathname } = useLocation();
98 const result = actionData as ActionData | undefined;
99 const review = result && "review" in result ? result.review : null;
Stripe webhooks, enterprise invoices, and sudo for both100 const sent = result && "invoice" in result ? result.invoice : null;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace101 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
102
103 return (
104 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
105 <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
106 <ArrowLeft size={14} />
107 Enterprises
108 </Link>
109
110 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
111 <div className="flex min-w-0 items-start gap-3">
112 <Avatar name={account.name} size={40} />
113 <div className="min-w-0">
114 <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
115 <p className="mt-0.5 text-xs text-faint">
116 Enterprise since <When at={account.createdAt} /> · pays for {members.length} workspace{members.length === 1 ? "" : "s"}
117 </p>
118 <div className="mt-2 flex flex-wrap gap-1.5">
119 <Badge tone="lavender">Enterprise</Badge>
120 <TermsBadge terms={account.terms} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging121 {!fullDiscount(account.terms) && <TrustBadge trust={limit.trust} />}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace122 <StateBadge state={limit.state} />
123 </div>
124 </div>
125 </div>
126 <p className="text-xs text-faint">
127 Billing account id <span className="font-mono">{account.id}</span>
128 </p>
129 </div>
130
131 <div className="mt-6 space-y-3">
132 {done && <Notice tone="ok">{done}</Notice>}
133 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
134 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
135 {review && <ReviewPanel review={review} pathname={pathname} />}
136 </div>
137
138 <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
139 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
140 <p className="mb-2 text-xs text-muted">Usage this month against the limit, all workspaces together</p>
141 <ExposureBar limit={limit} wide />
142 <p className="mt-2 text-xs text-faint">
143 Limit from trust {usd(limit.trustCeilingMicros)}
144 {account.terms.ceilingMicros != null && <> · custom limit {usd(account.terms.ceilingMicros)}</>}
145 </p>
146 </div>
147 <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
148 <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
149 </div>
150
151 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
152 <div className="space-y-6">
153 <MembersSection members={members} pathname={pathname} error={error("members")} />
Stripe webhooks, enterprise invoices, and sudo for both154 <InvoicesSection
155 email={account.billingEmail ?? null}
156 invoices={account.invoices ?? []}
157 sent={sent}
158 pathname={pathname}
159 error={error("invoices")}
Merge Stripe Tax, the card fee on card payments, and one free workspace per person160 addressError={error("address")}
Stripe webhooks, enterprise invoices, and sudo for both161 />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace162 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put163 <AllowancesForm
164 allowances={account.allowances}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging165 comped={fullDiscount(account.terms)}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put166 pathname={pathname}
167 error={error("allowances")}
168 />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace169 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
170 </div>
171 <div className="space-y-6">
172 <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging173 <Section
174 id="credits"
175 title="Credits"
176 description={`${usd(credits.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })} left to spend across its workspaces.`}
177 >
178 {creditsError ? (
179 <Notice tone="warn">Billing did not answer for credits: {creditsError}</Notice>
180 ) : (
181 <CreditList grants={credits} pathname={pathname} error={error("credits")} showWorkspace />
182 )}
183 </Section>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace184 <AuditSection audit={detail.audit} />
185 </div>
186 </div>
187 </main>
188 );
189}
190
191function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) {
192 return (
193 <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
194 {error && (
195 <div className="mb-4">
196 <Notice tone="error">{error.error}</Notice>
197 </div>
198 )}
199 {members.length === 0 ? (
200 <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState>
201 ) : (
202 <ul className="divide-y divide-line rounded-md border border-line">
203 {members.map((member) => (
204 <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
205 <div className="flex min-w-0 grow items-start gap-2.5">
206 <Avatar name={member.slug} size={22} />
207 <div className="min-w-0">
208 <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4">
209 {member.name ?? member.slug}
210 </Link>
211 <p className="truncate text-xs text-faint">
212 <span className="font-mono">{member.slug}</span>
213 {member.owners.length > 0 && (
214 <>
215 {" · "}
216 {member.owners.map((owner, index) => (
217 <span key={owner.username} title={owner.email ?? "No email"}>
218 {index > 0 && ", "}
219 {owner.username}
220 </span>
221 ))}
222 </>
223 )}
224 </p>
225 {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>}
226 </div>
227 </div>
228 <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end">
229 <p className="tabular text-right text-xs text-muted">
230 Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span>
231 {member.limit && <MemberState limit={member.limit} />}
232 </p>
233 <DetachButton workspace={member.slug} pathname={pathname} />
234 </div>
235 </li>
236 ))}
237 </ul>
238 )}
239 <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
240 <input type="hidden" name="intent" value="attach" />
241 <Field label="Add a workspace" className="grow">
242 <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
243 </Field>
244 <Button type="submit" variant="quiet">
245 <Plus size={14} />
246 Add
247 </Button>
248 </form>
249 </Section>
250 );
251}
252
253function MemberState({ limit }: { limit: Limit }) {
254 if (limit.state === "ok") return null;
255 return (
256 <span className="mt-1 block">
257 <StateBadge state={limit.state} />
258 </span>
259 );
260}
261
262function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
263 return (
264 <form method="post" action={`${pathname}#review`} className="shrink-0">
265 <input type="hidden" name="intent" value="detach" />
266 <input type="hidden" name="workspace" value={workspace} />
267 <button
268 type="submit"
269 aria-label={`Remove ${workspace}`}
270 title={`Remove ${workspace}`}
271 className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
272 >
273 <Trash2 size={14} />
274 </button>
275 </form>
276 );
277}
Stripe webhooks, enterprise invoices, and sudo for both278
279// --- Invoices -----------------------------------------------------------------
280
281const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
282 open: { label: "Open", tone: "info" },
283 paid: { label: "Paid", tone: "mint" },
284 overdue: { label: "Overdue", tone: "danger" },
285 void: { label: "Void", tone: "plain" },
286};
287
288function InvoiceStatus({ status }: { status: string }) {
289 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
290 return <Badge tone={tone}>{label}</Badge>;
291}
292
293/** Stripe's hosted invoice page, the one the customer pays on; https only. */
294function StripeLink({ url }: { url: string | null }) {
295 if (!url || !url.startsWith("https://")) return <span className="text-faint">—</span>;
296 return (
Merge branch 'worktree-agent-ad7c6d88d93adc817'297 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent hover:underline">
Stripe webhooks, enterprise invoices, and sudo for both298 Stripe
299 <ExternalLink size={12} />
300 </a>
301 );
302}
303
304function InvoicesSection({
305 email,
306 invoices,
307 sent,
308 pathname,
309 error,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person310 addressError,
Stripe webhooks, enterprise invoices, and sudo for both311}: {
312 email: string | null;
313 invoices: EnterpriseInvoice[];
314 sent: EnterpriseInvoice | null;
315 pathname: string;
316 error: SectionError;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person317 addressError: SectionError;
Stripe webhooks, enterprise invoices, and sudo for both318}) {
319 return (
320 <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
321 <div className="space-y-4">
322 {error && <Notice tone="error">{error.error}</Notice>}
323 {sent && (
324 <Notice tone="ok">
325 Invoice sent: {usd(sent.amountMicros)} for {sent.period}. <StripeLink url={sent.hostedUrl} />
326 </Notice>
327 )}
328
329 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
330 <input type="hidden" name="intent" value="billing-email" />
331 <Field label="Invoices go to" hint={email ? "Stripe emails each invoice here." : "Not set yet. Needed before an invoice can be sent."} className="grow">
332 <Input name="email" type="email" required placeholder="billing@acme.com" defaultValue={error?.values?.email ?? email ?? ""} />
333 </Field>
334 <Button type="submit" variant="quiet">
335 <Mail size={14} />
336 {email ? "Change" : "Set"}
337 </Button>
338 </form>
339
Merge Stripe Tax, the card fee on card payments, and one free workspace per person340 <form method="post" action={`${pathname}#invoices`} className="space-y-2 border-t border-line pt-4">
341 <input type="hidden" name="intent" value="billing-address" />
342 <p className="text-sm text-muted">
343 Billing address, saved on its Stripe customer. Stripe Tax works its invoices&apos; tax out from it, so no invoice goes out without
344 one. Leave the tax ID blank to keep the one there is.
345 </p>
346 {addressError && <Notice tone="error">{addressError.error}</Notice>}
347 <div className="grid gap-2 sm:grid-cols-2">
348 <Field label="Street">
349 <Input name="line1" defaultValue={addressError?.values?.line1 ?? ""} autoComplete="off" />
350 </Field>
351 <Field label="Suite, floor">
352 <Input name="line2" defaultValue={addressError?.values?.line2 ?? ""} autoComplete="off" />
353 </Field>
354 <Field label="City">
355 <Input name="city" defaultValue={addressError?.values?.city ?? ""} autoComplete="off" />
356 </Field>
357 <Field label="State or region">
358 <Input name="state" defaultValue={addressError?.values?.state ?? ""} autoComplete="off" />
359 </Field>
360 <Field label="Postal code" hint="Needed in the US">
361 <Input name="postalCode" defaultValue={addressError?.values?.postalCode ?? ""} autoComplete="off" />
362 </Field>
363 <Field label="Country" hint="Two letters, such as US">
364 <Input name="country" required maxLength={2} defaultValue={addressError?.values?.country ?? ""} className="uppercase" autoComplete="off" />
365 </Field>
366 <Field label="Tax ID kind" hint="Stripe's name, such as eu_vat or us_ein">
367 <Input name="taxIdType" defaultValue={addressError?.values?.taxIdType ?? ""} autoComplete="off" />
368 </Field>
369 <Field label="Tax ID">
370 <Input name="taxId" defaultValue={addressError?.values?.taxId ?? ""} autoComplete="off" />
371 </Field>
372 </div>
373 <Button type="submit" variant="quiet">
374 <MapPin size={14} />
375 Save address
376 </Button>
377 </form>
378
Stripe webhooks, enterprise invoices, and sudo for both379 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
380 <input type="hidden" name="intent" value="invoice" />
381 <p className="text-sm text-muted">An invoice goes out on its own when each month closes. Send one now for what it owes so far.</p>
382 <Button type="submit" variant="quiet" className="shrink-0">
383 <Send size={14} />
384 Send invoice now
385 </Button>
386 </form>
387
388 {invoices.length === 0 ? (
389 <EmptyState title="No invoices yet" />
390 ) : (
391 <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5">
392 <table className="w-full min-w-xl text-sm">
393 <thead>
394 <tr className="border-b border-line text-left text-xs text-muted">
395 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
396 <th className="px-4 py-2 font-medium">Status</th>
397 <th className="px-4 py-2 font-medium">Lines</th>
398 <th className="px-4 py-2 text-right font-medium">Amount</th>
399 <th className="px-4 py-2 font-medium sm:pr-5">Page</th>
400 </tr>
401 </thead>
402 <tbody>
403 {invoices.map((invoice) => (
404 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
405 <td className="px-4 py-2.5 sm:pl-5">
406 <p>{invoice.period}</p>
407 <p className="text-xs text-faint">
408 <When at={invoice.createdAt} />
409 </p>
410 </td>
411 <td className="px-4 py-2.5">
412 <InvoiceStatus status={invoice.status} />
413 </td>
414 <td className="px-4 py-2.5">
415 <ul className="space-y-0.5 text-xs">
416 {invoice.lines.map((line) => (
417 <li key={line.workspace} className="tabular">
418 <span className="font-mono text-fg-soft">{line.workspace}</span>
419 <span className="text-faint">: {usd(line.amountMicros)}</span>
420 </li>
421 ))}
422 </ul>
423 </td>
424 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
425 <td className="px-4 py-2.5 text-xs sm:pr-5">
426 <StripeLink url={invoice.hostedUrl} />
427 </td>
428 </tr>
429 ))}
430 </tbody>
431 </table>
432 </div>
433 )}
434 </div>
435 </Section>
436 );
437}

This file's history is long; its oldest lines are credited to the oldest commit read.