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/components/billing.tsx

531 lines23,043 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.

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1/**
2 * The billing sections shared by a workspace's page and an enterprise's:
3 * confirmations, terms, credit, the Stripe billing link, the ledger and the
4 * audit log. Plain forms; sudo ships no JavaScript.
5 */
6import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react";
7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put10import type { AdminAction, AdminOwner, Allowances, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace11
12import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
sudo: Invoices, Audit log, and follow-ups due13import { actionLabel } from "~/lib/ledgers";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace14import { dollarsField, usd } from "~/lib/money";
15import type { Review, SectionError } from "~/lib/review";
16
17export function Hidden({ values }: { values: Record<string, string> }) {
18 return (
19 <>
20 {Object.entries(values).map(([name, value]) => (
21 <input key={name} type="hidden" name={name} value={value} />
22 ))}
23 </>
24 );
25}
26
27export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) {
28 return (
29 <div className="rounded-lg border border-line bg-surface px-4 py-3">
30 <p className="text-xs text-muted">{label}</p>
31 <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
32 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
33 </div>
34 );
35}
36
37/** Owners' usernames, each with their email beneath (and on hover). */
38export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) {
39 if (owners.length === 0) return <span className="text-faint">No owner</span>;
40 if (compact) {
41 return (
42 <span className="flex flex-col gap-0.5">
43 {owners.map((owner) => (
44 <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate">
45 <span className="font-mono text-fg-soft">{owner.username}</span>
46 {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>}
47 </span>
48 ))}
49 </span>
50 );
51 }
52 return (
53 <ul className="flex flex-wrap gap-2">
54 {owners.map((owner) => (
55 <li key={owner.username} title={owner.email ?? "No email"} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 text-xs">
56 <Avatar name={owner.username} size={14} square={false} />
57 <span className="font-mono">{owner.username}</span>
58 {owner.email && <span className="text-faint">{owner.email}</span>}
59 </li>
60 ))}
61 </ul>
62 );
63}
64
65// --- Confirmation ------------------------------------------------------------
66
67function describeTerms(terms: Terms): [string, string][] {
68 return [
69 ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
70 ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
71 ["Limit", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
72 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
73 ["Note", terms.note || "—"],
74 ];
75}
76
Stripe webhooks, enterprise invoices, and sudo for both77/** Where a confirmed change lands, when its result is shown in place. */
78const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" };
79
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace80export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
81 let title: string;
82 let body: ReactNode;
83 let danger = false;
84 let confirm = "Confirm";
85 if (review.intent === "terms") {
86 const before = describeTerms(review.before);
87 const after = describeTerms(review.after);
88 title = "Confirm the new terms";
89 danger = review.after.kind === "comped";
90 body = (
91 <>
92 <div className="overflow-x-auto">
93 <table className="w-full text-sm">
94 <thead>
95 <tr className="text-left text-xs text-muted">
96 <th className="py-1.5 pr-4 font-medium" />
97 <th className="py-1.5 pr-4 font-medium">Now</th>
98 <th className="py-1.5 font-medium">After</th>
99 </tr>
100 </thead>
101 <tbody>
102 {after.map(([label, value], index) => (
103 <tr key={label} className="border-t border-line align-top">
104 <td className="py-1.5 pr-4 text-muted">{label}</td>
105 <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
106 <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
107 </tr>
108 ))}
109 </tbody>
110 </table>
111 </div>
112 {review.after.kind === "comped" && (
113 <p className="mt-3 text-sm text-warn">
114 Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded
115 at cost.
116 </p>
117 )}
118 </>
119 );
120 } else if (review.intent === "attach") {
121 title = `Bill ${review.workspace} to ${review.targetName}?`;
122 body = (
123 <p className="text-sm text-muted">
124 From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
125 <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
126 </p>
127 );
128 } else if (review.intent === "detach") {
129 title = `Move ${review.workspace} off ${review.from}?`;
130 danger = true;
131 body = (
132 <p className="text-sm text-muted">
133 <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit
134 its trust gives it. It may stop at once if its own limit is lower than what it owes.
135 </p>
136 );
Stripe webhooks, enterprise invoices, and sudo for both137 } else if (review.intent === "billing-email") {
138 title = `Send ${review.name}'s invoices to ${review.after}?`;
139 body = (
140 <p className="text-sm text-muted">
141 Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span>
142 {review.before ? (
143 <>
144 {" "}
145 instead of <span className="font-mono">{review.before}</span>
146 </>
147 ) : null}
148 , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."}
149 </p>
150 );
151 } else if (review.intent === "invoice") {
152 title = `Invoice ${review.name} now?`;
153 confirm = "Send the invoice";
154 body = (
155 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
156 <li>Makes one Stripe invoice, with a line for each of its {review.workspaces} workspace{review.workspaces === 1 ? "" : "s"} for what it owes now.</li>
157 <li>Net 30: due in 30 days.</li>
158 <li>
159 Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to
160 pay on Stripe's page.
161 </li>
162 </ul>
163 );
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace164 } else {
165 title = `Make a Stripe billing link for ${review.workspace}?`;
166 confirm = "Make the link";
167 body = (
168 <p className="text-sm text-muted">
169 Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s
170 customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it
171 only to the workspace's owner. Making it is recorded with your email.
172 </p>
173 );
174 }
175 return (
176 <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
177 <h2 className="font-semibold tracking-tight">{title}</h2>
178 <div className="mt-3">{body}</div>
Stripe webhooks, enterprise invoices, and sudo for both179 <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace180 <Hidden values={review.fields} />
181 <input type="hidden" name="intent" value={review.intent} />
182 <input type="hidden" name="confirm" value="yes" />
183 <Button type="submit" variant={danger ? "danger" : "lavender"}>
184 {confirm}
185 </Button>
186 <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
187 Cancel
188 </Link>
189 </form>
190 </section>
191 );
192}
193
194// --- Terms -------------------------------------------------------------------
195
196const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
197 { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." },
198 { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
199 { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." },
200];
201
202export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
203 const values = error?.values;
204 const kind = values?.kind ?? terms.kind;
205 return (
206 <Section
207 id="terms"
208 title="Terms"
209 description={
210 terms.setBy ? (
211 <>
212 Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
213 {terms.note && <> · “{terms.note}”</>}
214 </>
215 ) : (
216 "Standard, as everyone starts."
217 )
218 }
219 >
220 <form method="post" action={`${pathname}#review`} className="space-y-4">
221 <input type="hidden" name="intent" value="terms" />
222 {error && <Notice tone="error">{error.error}</Notice>}
223 <fieldset>
224 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
225 <div className="grid gap-2 sm:grid-cols-3">
226 {KINDS.map((option) => (
227 <label
228 key={option.value}
229 className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
230 >
231 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
232 <span>
233 <span className="block text-sm font-medium">{option.title}</span>
234 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
235 </span>
236 </label>
237 ))}
238 </div>
239 </fieldset>
240 <div className="grid gap-4 sm:grid-cols-3">
241 <Field label="Discount %" hint="Custom only.">
242 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
243 </Field>
244 <Field label="Limit $" hint="Unpaid usage allowed. Blank: trust decides.">
245 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
246 </Field>
247 <Field label="Until" hint="Blank: no end. UTC.">
248 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
249 </Field>
250 </div>
251 <Field label="Note" hint="Required. Why, for whoever looks next.">
252 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
253 </Field>
254 <div className="flex justify-end">
255 <Button type="submit">Review terms</Button>
256 </div>
257 </form>
258 </Section>
259 );
260}
261
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put262// --- Plan and pools -----------------------------------------------------------
263
264/**
265 * The Team plan without charge, and the account's share of g1t's pools.
266 * Comped accounts have Team anyway; this is for partners on other terms.
267 */
268export function AllowancesForm({
269 allowances,
270 comped,
271 pathname,
272 error,
273}: {
274 allowances: Allowances | undefined;
275 comped: boolean;
276 pathname: string;
277 error: SectionError;
278}) {
279 const values = error?.values;
280 const current = allowances ?? { team: false, ossRepoMicros: null, trialMicros: null };
281 return (
282 <Section
283 id="allowances"
284 title="Plan and pools"
285 description={
286 comped
287 ? "Comped: the Team plan is on without charge whatever is set here. The pool shares still apply."
288 : "The Team plan without charge, and this account's share of g1t's open-source pool and of trials."
289 }
290 >
291 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
292 <input type="hidden" name="intent" value="allowances" />
293 {error && <Notice tone="error">{error.error}</Notice>}
294 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
295 <input type="checkbox" name="team" defaultChecked={values ? values.team === "on" : current.team} className="mt-0.5" />
296 <span>
297 <span className="block font-medium">Team, without charge</span>
298 <span className="block text-xs text-muted">Its credit, storage and audit log, with no plan to pay for.</span>
299 </span>
300 </label>
301 <div className="grid gap-4 sm:grid-cols-2">
302 <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default.">
303 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
304 </Field>
305 <Field label="Trial credit $" hint="Each workspace, outside the monthly pool. Blank: the default.">
306 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
307 </Field>
308 </div>
309 <Field label="Note" hint="Required. Why, for whoever looks next.">
310 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Open-source foundation, larger share through 2027" />
311 </Field>
312 <div className="flex justify-end">
313 <Button type="submit">Save plan and pools</Button>
314 </div>
315 </form>
316 </Section>
317 );
318}
319
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace320// --- Credit -------------------------------------------------------------------
321
322export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
323 const values = error?.values;
324 const single = workspaces.length === 1 ? workspaces[0] : null;
325 return (
326 <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
327 {workspaces.length === 0 ? (
328 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
329 ) : (
330 <form method="post" action={`${pathname}#credit`} className="space-y-4">
331 <input type="hidden" name="intent" value="credit" />
332 {error && <Notice tone="error">{error.error}</Notice>}
333 {single ? (
334 <input type="hidden" name="workspace" value={single} />
335 ) : (
336 <Field label="Workspace">
337 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
338 <option value="" disabled>
339 Choose a workspace
340 </option>
341 {workspaces.map((slug) => (
342 <option key={slug} value={slug}>
343 {slug}
344 </option>
345 ))}
346 </Select>
347 </Field>
348 )}
349 <Field label="Amount $" hint="Up to $10,000 at a time.">
350 <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
351 </Field>
352 <Field label="Note" hint="Required. Shown on the workspace's statement.">
353 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
354 </Field>
355 <Field
356 label="Confirm"
357 hint={
358 <>
359 Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
360 </>
361 }
362 >
363 <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
364 </Field>
365 <div className="flex justify-end">
366 <Button type="submit" variant="lavender">
367 <Gift size={14} />
368 Issue credit
369 </Button>
370 </div>
371 </form>
372 )}
373 </Section>
374 );
375}
376
377// --- Stripe billing link -----------------------------------------------------
378
379/**
380 * Card details, invoices and receipts live on Stripe's own billing page.
381 * sudo never shows a card field: staff send the customer a link instead.
382 */
383export function BillingLinkSection({
384 link,
385 pathname,
386 error,
387}: {
388 link: BillingLink | null;
389 pathname: string;
390 error: SectionError;
391}) {
392 return (
393 <Section
394 id="billing-link"
395 title="Stripe billing page"
396 description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
397 >
398 {error && (
399 <div className="mb-4">
400 <Notice tone="error">{error.error}</Notice>
401 </div>
402 )}
403 {link ? (
404 <div className="space-y-4">
405 <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
406 <Field label="One-time link" hint={link.expiresNote}>
407 <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
408 </Field>
409 {link.loginUrl && (
410 <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
411 <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
412 </Field>
413 )}
414 {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
415 </div>
416 ) : (
417 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
418 <input type="hidden" name="intent" value="billing-link" />
419 <p className="text-sm text-muted">
420 Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
421 </p>
422 <Button type="submit" variant="quiet" className="shrink-0">
423 <CreditCard size={14} />
424 Make a link
425 </Button>
426 </form>
427 )}
428 </Section>
429 );
430}
431
432// --- Ledger and audit ---------------------------------------------------------
433
434const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
435
436export function LedgerSection({
437 ledger,
438 showWorkspace = false,
439 description = "Recent lines of the statement, newest first.",
440}: {
441 ledger: LedgerEntry[];
442 showWorkspace?: boolean;
443 description?: ReactNode;
444}) {
445 return (
446 <Section title="Ledger" description={description}>
447 {ledger.length === 0 ? (
448 <EmptyState title="Nothing yet" />
449 ) : (
450 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
451 <table className="w-full min-w-[36rem] text-sm">
452 <thead>
453 <tr className="border-b border-line text-left text-xs text-muted">
454 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
455 <th className="px-4 py-2 font-medium">What</th>
456 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
457 </tr>
458 </thead>
459 <tbody>
460 {ledger.map((entry) => (
461 <tr key={entry.id} className="border-b border-line align-top last:border-0">
462 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
463 <When at={entry.createdAt} time />
464 </td>
465 <td className="px-4 py-2.5">
466 <div className="flex flex-wrap items-center gap-1.5">
467 <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
468 {showWorkspace && entry.workspace && (
469 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
470 {entry.workspace}
471 </Link>
472 )}
473 <span className="break-words">{entry.description}</span>
474 </div>
475 <p className="mt-0.5 font-mono text-xs text-faint">
476 {[
477 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
478 entry.task,
479 entry.model,
480 entry.billedTo === "workspace" ? "own provider" : null,
481 entry.createdBy && `by ${entry.createdBy}`,
482 ]
483 .filter(Boolean)
484 .join(" · ")}
485 </p>
486 </td>
487 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
488 {usd(entry.amountMicros, { signed: true })}
489 </td>
490 </tr>
491 ))}
492 </tbody>
493 </table>
494 </div>
495 )}
496 </Section>
497 );
498}
499
500
sudo: Invoices, Audit log, and follow-ups due501
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace502export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
503 return (
504 <Section title="Audit log" description={description}>
505 {audit.length === 0 ? (
506 <p className="flex items-center gap-2 text-sm text-muted">
507 <ScrollText size={14} />
508 No changes yet.
509 </p>
510 ) : (
511 <ol className="space-y-3">
512 {audit.map((entry) => (
513 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
514 <p className="flex flex-wrap items-center gap-x-2 text-sm">
sudo: Invoices, Audit log, and follow-ups due515 <span className="font-medium text-merged">{actionLabel(entry.action)}</span>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace516 <span className="text-xs text-faint">
517 <When at={entry.createdAt} time />
518 </span>
519 </p>
520 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
521 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
522 <UserRound size={11} />
523 {entry.by}
524 </p>
525 </li>
526 ))}
527 </ol>
528 )}
529 </Section>
530 );
531}