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.

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