Skip to content

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

437 lines20,073 bytesCodeBlame
1import { ArrowLeft, ExternalLink, Mail, MapPin, Plus, Send, Trash2 } from "lucide-react";
2import { data, Link, redirect, useLocation } from "react-router";
3
4import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
5
6import type { Route } from "./+types/enterprise";
7import { AllowancesForm, AuditSection, CreditForm, CreditList, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
8import { 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";
13import { settle } from "~/lib/settle";
14import { requireStaff } from "~/lib/staff";
15import { fullDiscount } from "~/lib/terms";
16import { 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 });
38 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 };
46 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 }));
74 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 };
82}
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) {
94 const { detail, members, credits, creditsError, done } = loaderData;
95 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;
100 const sent = result && "invoice" in result ? result.invoice : null;
101 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} />
121 {!fullDiscount(account.terms) && <TrustBadge trust={limit.trust} />}
122 <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")} />
154 <InvoicesSection
155 email={account.billingEmail ?? null}
156 invoices={account.invoices ?? []}
157 sent={sent}
158 pathname={pathname}
159 error={error("invoices")}
160 addressError={error("address")}
161 />
162 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
163 <AllowancesForm
164 allowances={account.allowances}
165 comped={fullDiscount(account.terms)}
166 pathname={pathname}
167 error={error("allowances")}
168 />
169 <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")} />
173 <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>
184 <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}
278
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 (
297 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent hover:underline">
298 Stripe
299 <ExternalLink size={12} />
300 </a>
301 );
302}
303
304function InvoicesSection({
305 email,
306 invoices,
307 sent,
308 pathname,
309 error,
310 addressError,
311}: {
312 email: string | null;
313 invoices: EnterpriseInvoice[];
314 sent: EnterpriseInvoice | null;
315 pathname: string;
316 error: SectionError;
317 addressError: SectionError;
318}) {
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
340 <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
379 <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}