flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

375 lines16,905 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.

Stripe webhooks, enterprise invoices, and sudo for both1import { ArrowLeft, ExternalLink, Mail, 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";
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put7import { AllowancesForm, AuditSection, CreditForm, 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";
13import { requireStaff } from "~/lib/staff";
14import { legacyAccountPath } from "~/lib/workspaces";
15
16export const meta: Route.MetaFunction = ({ loaderData }) => [
17 { title: `${loaderData?.detail.summary.account.name ?? "Enterprise"} · sudo` },
18 { name: "robots", content: "noindex, nofollow" },
19];
20
21const ENTERPRISE_ID = /^ent_[a-z0-9_-]{1,80}$/;
22
23async function load(raw: string) {
24 const id = raw.trim().toLowerCase();
25 if (!ENTERPRISE_ID.test(id)) {
26 // A workspace's id or slug: its own page.
27 const path = legacyAccountPath(id);
28 if (path) throw redirect(path);
29 throw data("That is not an enterprise.", { status: 404 });
30 }
31 const result = await admin.account(id);
32 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
33 const detail = result.value;
34 const { account } = detail.summary;
35 if (account.kind !== "enterprise") throw data("That is not an enterprise.", { status: 404 });
Stripe webhooks, enterprise invoices, and sudo for both36 const subject: Subject = {
37 kind: "enterprise",
38 accountId: account.id,
39 name: account.name,
40 terms: account.terms,
41 workspaces: account.workspaces,
42 billingEmail: account.billingEmail ?? null,
43 };
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace44 return { detail, subject };
45}
46
47export async function loader({ request, params, context }: Route.LoaderArgs) {
48 requireStaff(context);
49 const { detail } = await load(params.id);
50 const slugs = detail.summary.account.workspaces;
51 const listed = await identity.workspaces();
52 const people = new Map(listed.map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]));
53 // Members older than identity's list: look them up one by one.
54 const missing = slugs.filter((slug) => !people.has(slug)).slice(0, 25);
55 for (const found of await Promise.all(missing.map((slug) => identity.workspace(slug)))) {
56 if (found) {
57 people.set(found.slug, {
58 name: found.name,
59 owners: found.members.filter((member) => member.role === "owner").map(({ username, email }) => ({ username, email })),
60 });
61 }
62 }
63 const limits = new Map(detail.workspaces.map((limit) => [limit.workspace, limit]));
64 const shares = new Map((detail.summary.byWorkspace ?? []).map((share) => [share.workspace, share]));
65 const members = slugs.map((slug) => ({
66 slug,
67 name: people.get(slug)?.name ?? null,
68 owners: people.get(slug)?.owners ?? ([] as AdminOwner[]),
69 limit: limits.get(slug) ?? null,
70 chargedMicros: shares.get(slug)?.chargedMicros ?? 0,
71 }));
72 return { detail, members, done: doneMessage(request.url) };
73}
74
75export async function action({ request, params, context }: Route.ActionArgs) {
76 const staff = requireStaff(context);
77 // What is acted on comes from billing, not from the form.
78 const { subject } = await load(params.id);
79 return billingAction(request, staff, subject, `/enterprises/${encodeURIComponent(subject.accountId)}`);
80}
81
82type Member = Route.ComponentProps["loaderData"]["members"][number];
83
84export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) {
85 const { detail, members, done } = loaderData;
86 const { summary } = detail;
87 const { account, limit } = summary;
88 const { pathname } = useLocation();
89 const result = actionData as ActionData | undefined;
90 const review = result && "review" in result ? result.review : null;
Stripe webhooks, enterprise invoices, and sudo for both91 const sent = result && "invoice" in result ? result.invoice : null;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace92 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
93
94 return (
95 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
96 <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
97 <ArrowLeft size={14} />
98 Enterprises
99 </Link>
100
101 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
102 <div className="flex min-w-0 items-start gap-3">
103 <Avatar name={account.name} size={40} />
104 <div className="min-w-0">
105 <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
106 <p className="mt-0.5 text-xs text-faint">
107 Enterprise since <When at={account.createdAt} /> · pays for {members.length} workspace{members.length === 1 ? "" : "s"}
108 </p>
109 <div className="mt-2 flex flex-wrap gap-1.5">
110 <Badge tone="lavender">Enterprise</Badge>
111 <TermsBadge terms={account.terms} />
112 {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
113 <StateBadge state={limit.state} />
114 </div>
115 </div>
116 </div>
117 <p className="text-xs text-faint">
118 Billing account id <span className="font-mono">{account.id}</span>
119 </p>
120 </div>
121
122 <div className="mt-6 space-y-3">
123 {done && <Notice tone="ok">{done}</Notice>}
124 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
125 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
126 {review && <ReviewPanel review={review} pathname={pathname} />}
127 </div>
128
129 <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
130 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
131 <p className="mb-2 text-xs text-muted">Usage this month against the limit, all workspaces together</p>
132 <ExposureBar limit={limit} wide />
133 <p className="mt-2 text-xs text-faint">
134 Limit from trust {usd(limit.trustCeilingMicros)}
135 {account.terms.ceilingMicros != null && <> · custom limit {usd(account.terms.ceilingMicros)}</>}
136 </p>
137 </div>
138 <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
139 <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
140 </div>
141
142 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
143 <div className="space-y-6">
144 <MembersSection members={members} pathname={pathname} error={error("members")} />
Stripe webhooks, enterprise invoices, and sudo for both145 <InvoicesSection
146 email={account.billingEmail ?? null}
147 invoices={account.invoices ?? []}
148 sent={sent}
149 pathname={pathname}
150 error={error("invoices")}
151 />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace152 <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 put153 <AllowancesForm
154 allowances={account.allowances}
155 comped={account.terms.kind === "comped"}
156 pathname={pathname}
157 error={error("allowances")}
158 />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace159 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
160 </div>
161 <div className="space-y-6">
162 <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} />
163 <AuditSection audit={detail.audit} />
164 </div>
165 </div>
166 </main>
167 );
168}
169
170function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) {
171 return (
172 <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
173 {error && (
174 <div className="mb-4">
175 <Notice tone="error">{error.error}</Notice>
176 </div>
177 )}
178 {members.length === 0 ? (
179 <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState>
180 ) : (
181 <ul className="divide-y divide-line rounded-md border border-line">
182 {members.map((member) => (
183 <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
184 <div className="flex min-w-0 grow items-start gap-2.5">
185 <Avatar name={member.slug} size={22} />
186 <div className="min-w-0">
187 <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4">
188 {member.name ?? member.slug}
189 </Link>
190 <p className="truncate text-xs text-faint">
191 <span className="font-mono">{member.slug}</span>
192 {member.owners.length > 0 && (
193 <>
194 {" · "}
195 {member.owners.map((owner, index) => (
196 <span key={owner.username} title={owner.email ?? "No email"}>
197 {index > 0 && ", "}
198 {owner.username}
199 </span>
200 ))}
201 </>
202 )}
203 </p>
204 {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>}
205 </div>
206 </div>
207 <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end">
208 <p className="tabular text-right text-xs text-muted">
209 Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span>
210 {member.limit && <MemberState limit={member.limit} />}
211 </p>
212 <DetachButton workspace={member.slug} pathname={pathname} />
213 </div>
214 </li>
215 ))}
216 </ul>
217 )}
218 <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
219 <input type="hidden" name="intent" value="attach" />
220 <Field label="Add a workspace" className="grow">
221 <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
222 </Field>
223 <Button type="submit" variant="quiet">
224 <Plus size={14} />
225 Add
226 </Button>
227 </form>
228 </Section>
229 );
230}
231
232function MemberState({ limit }: { limit: Limit }) {
233 if (limit.state === "ok") return null;
234 return (
235 <span className="mt-1 block">
236 <StateBadge state={limit.state} />
237 </span>
238 );
239}
240
241function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
242 return (
243 <form method="post" action={`${pathname}#review`} className="shrink-0">
244 <input type="hidden" name="intent" value="detach" />
245 <input type="hidden" name="workspace" value={workspace} />
246 <button
247 type="submit"
248 aria-label={`Remove ${workspace}`}
249 title={`Remove ${workspace}`}
250 className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
251 >
252 <Trash2 size={14} />
253 </button>
254 </form>
255 );
256}
Stripe webhooks, enterprise invoices, and sudo for both257
258// --- Invoices -----------------------------------------------------------------
259
260const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
261 open: { label: "Open", tone: "info" },
262 paid: { label: "Paid", tone: "mint" },
263 overdue: { label: "Overdue", tone: "danger" },
264 void: { label: "Void", tone: "plain" },
265};
266
267function InvoiceStatus({ status }: { status: string }) {
268 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
269 return <Badge tone={tone}>{label}</Badge>;
270}
271
272/** Stripe's hosted invoice page, the one the customer pays on; https only. */
273function StripeLink({ url }: { url: string | null }) {
274 if (!url || !url.startsWith("https://")) return <span className="text-faint">—</span>;
275 return (
276 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
277 Stripe
278 <ExternalLink size={12} />
279 </a>
280 );
281}
282
283function InvoicesSection({
284 email,
285 invoices,
286 sent,
287 pathname,
288 error,
289}: {
290 email: string | null;
291 invoices: EnterpriseInvoice[];
292 sent: EnterpriseInvoice | null;
293 pathname: string;
294 error: SectionError;
295}) {
296 return (
297 <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
298 <div className="space-y-4">
299 {error && <Notice tone="error">{error.error}</Notice>}
300 {sent && (
301 <Notice tone="ok">
302 Invoice sent: {usd(sent.amountMicros)} for {sent.period}. <StripeLink url={sent.hostedUrl} />
303 </Notice>
304 )}
305
306 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
307 <input type="hidden" name="intent" value="billing-email" />
308 <Field label="Invoices go to" hint={email ? "Stripe emails each invoice here." : "Not set yet. Needed before an invoice can be sent."} className="grow">
309 <Input name="email" type="email" required placeholder="billing@acme.com" defaultValue={error?.values?.email ?? email ?? ""} />
310 </Field>
311 <Button type="submit" variant="quiet">
312 <Mail size={14} />
313 {email ? "Change" : "Set"}
314 </Button>
315 </form>
316
317 <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">
318 <input type="hidden" name="intent" value="invoice" />
319 <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>
320 <Button type="submit" variant="quiet" className="shrink-0">
321 <Send size={14} />
322 Send invoice now
323 </Button>
324 </form>
325
326 {invoices.length === 0 ? (
327 <EmptyState title="No invoices yet" />
328 ) : (
329 <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5">
330 <table className="w-full min-w-xl text-sm">
331 <thead>
332 <tr className="border-b border-line text-left text-xs text-muted">
333 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
334 <th className="px-4 py-2 font-medium">Status</th>
335 <th className="px-4 py-2 font-medium">Lines</th>
336 <th className="px-4 py-2 text-right font-medium">Amount</th>
337 <th className="px-4 py-2 font-medium sm:pr-5">Page</th>
338 </tr>
339 </thead>
340 <tbody>
341 {invoices.map((invoice) => (
342 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
343 <td className="px-4 py-2.5 sm:pl-5">
344 <p>{invoice.period}</p>
345 <p className="text-xs text-faint">
346 <When at={invoice.createdAt} />
347 </p>
348 </td>
349 <td className="px-4 py-2.5">
350 <InvoiceStatus status={invoice.status} />
351 </td>
352 <td className="px-4 py-2.5">
353 <ul className="space-y-0.5 text-xs">
354 {invoice.lines.map((line) => (
355 <li key={line.workspace} className="tabular">
356 <span className="font-mono text-fg-soft">{line.workspace}</span>
357 <span className="text-faint">: {usd(line.amountMicros)}</span>
358 </li>
359 ))}
360 </ul>
361 </td>
362 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
363 <td className="px-4 py-2.5 text-xs sm:pr-5">
364 <StripeLink url={invoice.hostedUrl} />
365 </td>
366 </tr>
367 ))}
368 </tbody>
369 </table>
370 </div>
371 )}
372 </div>
373 </Section>
374 );
375}