Skip to content

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

395 lines17,752 bytesCodeBlame
1import { ArrowLeft, ExternalLink, Mail, 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 />
161 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
162 <AllowancesForm
163 allowances={account.allowances}
164 comped={fullDiscount(account.terms)}
165 pathname={pathname}
166 error={error("allowances")}
167 />
168 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
169 </div>
170 <div className="space-y-6">
171 <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} />
172 <Section
173 id="credits"
174 title="Credits"
175 description={`${usd(credits.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })} left to spend across its workspaces.`}
176 >
177 {creditsError ? (
178 <Notice tone="warn">Billing did not answer for credits: {creditsError}</Notice>
179 ) : (
180 <CreditList grants={credits} pathname={pathname} error={error("credits")} showWorkspace />
181 )}
182 </Section>
183 <AuditSection audit={detail.audit} />
184 </div>
185 </div>
186 </main>
187 );
188}
189
190function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) {
191 return (
192 <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
193 {error && (
194 <div className="mb-4">
195 <Notice tone="error">{error.error}</Notice>
196 </div>
197 )}
198 {members.length === 0 ? (
199 <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState>
200 ) : (
201 <ul className="divide-y divide-line rounded-md border border-line">
202 {members.map((member) => (
203 <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
204 <div className="flex min-w-0 grow items-start gap-2.5">
205 <Avatar name={member.slug} size={22} />
206 <div className="min-w-0">
207 <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4">
208 {member.name ?? member.slug}
209 </Link>
210 <p className="truncate text-xs text-faint">
211 <span className="font-mono">{member.slug}</span>
212 {member.owners.length > 0 && (
213 <>
214 {" · "}
215 {member.owners.map((owner, index) => (
216 <span key={owner.username} title={owner.email ?? "No email"}>
217 {index > 0 && ", "}
218 {owner.username}
219 </span>
220 ))}
221 </>
222 )}
223 </p>
224 {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>}
225 </div>
226 </div>
227 <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end">
228 <p className="tabular text-right text-xs text-muted">
229 Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span>
230 {member.limit && <MemberState limit={member.limit} />}
231 </p>
232 <DetachButton workspace={member.slug} pathname={pathname} />
233 </div>
234 </li>
235 ))}
236 </ul>
237 )}
238 <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
239 <input type="hidden" name="intent" value="attach" />
240 <Field label="Add a workspace" className="grow">
241 <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
242 </Field>
243 <Button type="submit" variant="quiet">
244 <Plus size={14} />
245 Add
246 </Button>
247 </form>
248 </Section>
249 );
250}
251
252function MemberState({ limit }: { limit: Limit }) {
253 if (limit.state === "ok") return null;
254 return (
255 <span className="mt-1 block">
256 <StateBadge state={limit.state} />
257 </span>
258 );
259}
260
261function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
262 return (
263 <form method="post" action={`${pathname}#review`} className="shrink-0">
264 <input type="hidden" name="intent" value="detach" />
265 <input type="hidden" name="workspace" value={workspace} />
266 <button
267 type="submit"
268 aria-label={`Remove ${workspace}`}
269 title={`Remove ${workspace}`}
270 className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
271 >
272 <Trash2 size={14} />
273 </button>
274 </form>
275 );
276}
277
278// --- Invoices -----------------------------------------------------------------
279
280const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
281 open: { label: "Open", tone: "info" },
282 paid: { label: "Paid", tone: "mint" },
283 overdue: { label: "Overdue", tone: "danger" },
284 void: { label: "Void", tone: "plain" },
285};
286
287function InvoiceStatus({ status }: { status: string }) {
288 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
289 return <Badge tone={tone}>{label}</Badge>;
290}
291
292/** Stripe's hosted invoice page, the one the customer pays on; https only. */
293function StripeLink({ url }: { url: string | null }) {
294 if (!url || !url.startsWith("https://")) return <span className="text-faint">—</span>;
295 return (
296 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent hover:underline">
297 Stripe
298 <ExternalLink size={12} />
299 </a>
300 );
301}
302
303function InvoicesSection({
304 email,
305 invoices,
306 sent,
307 pathname,
308 error,
309}: {
310 email: string | null;
311 invoices: EnterpriseInvoice[];
312 sent: EnterpriseInvoice | null;
313 pathname: string;
314 error: SectionError;
315}) {
316 return (
317 <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
318 <div className="space-y-4">
319 {error && <Notice tone="error">{error.error}</Notice>}
320 {sent && (
321 <Notice tone="ok">
322 Invoice sent: {usd(sent.amountMicros)} for {sent.period}. <StripeLink url={sent.hostedUrl} />
323 </Notice>
324 )}
325
326 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
327 <input type="hidden" name="intent" value="billing-email" />
328 <Field label="Invoices go to" hint={email ? "Stripe emails each invoice here." : "Not set yet. Needed before an invoice can be sent."} className="grow">
329 <Input name="email" type="email" required placeholder="billing@acme.com" defaultValue={error?.values?.email ?? email ?? ""} />
330 </Field>
331 <Button type="submit" variant="quiet">
332 <Mail size={14} />
333 {email ? "Change" : "Set"}
334 </Button>
335 </form>
336
337 <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">
338 <input type="hidden" name="intent" value="invoice" />
339 <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>
340 <Button type="submit" variant="quiet" className="shrink-0">
341 <Send size={14} />
342 Send invoice now
343 </Button>
344 </form>
345
346 {invoices.length === 0 ? (
347 <EmptyState title="No invoices yet" />
348 ) : (
349 <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5">
350 <table className="w-full min-w-xl text-sm">
351 <thead>
352 <tr className="border-b border-line text-left text-xs text-muted">
353 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
354 <th className="px-4 py-2 font-medium">Status</th>
355 <th className="px-4 py-2 font-medium">Lines</th>
356 <th className="px-4 py-2 text-right font-medium">Amount</th>
357 <th className="px-4 py-2 font-medium sm:pr-5">Page</th>
358 </tr>
359 </thead>
360 <tbody>
361 {invoices.map((invoice) => (
362 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
363 <td className="px-4 py-2.5 sm:pl-5">
364 <p>{invoice.period}</p>
365 <p className="text-xs text-faint">
366 <When at={invoice.createdAt} />
367 </p>
368 </td>
369 <td className="px-4 py-2.5">
370 <InvoiceStatus status={invoice.status} />
371 </td>
372 <td className="px-4 py-2.5">
373 <ul className="space-y-0.5 text-xs">
374 {invoice.lines.map((line) => (
375 <li key={line.workspace} className="tabular">
376 <span className="font-mono text-fg-soft">{line.workspace}</span>
377 <span className="text-faint">: {usd(line.amountMicros)}</span>
378 </li>
379 ))}
380 </ul>
381 </td>
382 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
383 <td className="px-4 py-2.5 text-xs sm:pr-5">
384 <StripeLink url={invoice.hostedUrl} />
385 </td>
386 </tr>
387 ))}
388 </tbody>
389 </table>
390 </div>
391 )}
392 </div>
393 </Section>
394 );
395}