g1t/apps/web/app/routes/workspace/billing.tsx

393 lines18,168 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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { ArrowUpRight, CreditCard, FileText } from "lucide-react";
Merge branch 'worktree-agent-a985c01f43538af01'2import { Form, Link, data, redirect } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell3
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4import { MICROS_PER_DOLLAR, type WorkspaceInvoice } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell5
6import type { Route } from "./+types/billing";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7import {
8 Alerts,
9 CapsCard,
10 OverageCard,
11 PlanCard,
12 PrepayCard,
13 RaiseCard,
14 type SectionError,
15 SpendLimitCard,
16 SpikeBanner,
17 TrialCard,
18} from "../../components/billing";
The statement is a month at a time, a line per kind of charge19import { StatementView } from "../../components/statement";
Merge branch 'worktree-agent-a985c01f43538af01'20import { SubmitButton } from "../../components/ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21import {
22 cardCheckResult,
23 dollars,
24 parseCaps,
25 parseLimitRequest,
26 parsePrepay,
27 parseSpendLimit,
28 planStatus,
29 wholeDollars,
30} from "../../lib/billing";
31import { page } from "../../lib/meta";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32import { billing } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
Agents as a team: lifecycle, merge queue, billing and a new shell34
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35/** The trial and pools as published, when the price book cannot be read. */
36const DEFAULT_TRIAL_MICROS = 5_000_000;
Agents as a team: lifecycle, merge queue, billing and a new shell37
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look38/** What a change says once it is done (`?done=`). */
39const DONE: Record<string, string> = {
40 subscribed: "The g1t plan is on.",
41 prepaid: "Payment received. Your prepaid balance and what you can use went up by as much.",
42 limit: "Spend limit saved.",
43 caps: "Caps saved. They apply to runs that start from now on.",
44 requested: "Sent. g1t answers within one business day, here and by email.",
45 told: "Thanks. g1t looks at it and answers within one business day, here and by email.",
46 keep: "Compute is running again for 24 hours, unless spending doubles again first.",
47 stop: "New compute stays paused until an owner chooses Keep going.",
48 canceled: "The plan ends at the end of the period. Nothing more is charged for it.",
49 resumed: "The plan continues.",
50};
51
Social cards for every page: og.g1t.sh52export function meta({ params, ...args }: Route.MetaArgs) {
53 return page(args, { title: `Billing · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell54}
55
56export async function loader({ params, context, request }: Route.LoaderArgs) {
57 const viewer = getViewer(context);
58 const role = roleIn(viewer, params.owner);
59 // Members only; to anyone else the page does not exist.
60 if (!role) throw data(null, { status: 404 });
61 const slug = params.owner.toLowerCase();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look62 const url = new URL(request.url);
63 const here = `/${slug}/-/billing`;
Agents as a team: lifecycle, merge queue, billing and a new shell64
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 // Back from Stripe: record what happened, then drop the ids from the address.
66 const cardCheck = url.searchParams.get("card_check");
67 if (cardCheck) {
68 const checked = await billing.confirmCardCheck(slug, viewer, cardCheck);
69 throw redirect(checked.ok ? `${here}?checked=1#trial` : `${here}?problem=${encodeURIComponent(checked.error.message)}#trial`);
70 }
Agents as a team: lifecycle, merge queue, billing and a new shell71 const session = url.searchParams.get("session");
72 if (session) {
Paid features: a workspace turns on Deployments with a monthly plan73 if (url.searchParams.get("plan")) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 const started = await billing.confirmSubscription(slug, viewer, session);
75 throw redirect(started.ok ? `${here}?done=subscribed` : `${here}?problem=${encodeURIComponent(started.error.message)}`);
Paid features: a workspace turns on Deployments with a monthly plan76 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look77 const paid = await billing.confirm(slug, viewer, session);
78 throw redirect(paid.ok ? `${here}?done=prepaid#prepay` : `${here}?problem=${encodeURIComponent(paid.error.message)}#prepay`);
Agents as a team: lifecycle, merge queue, billing and a new shell79 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look80 // A plan started on the card already checked comes straight back.
81 if (url.searchParams.get("plan") === "started") throw redirect(`${here}?done=subscribed`);
82
The statement is a month at a time, a line per kind of charge83 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas84 const [account, statement, features, meters, limit, invoices, entitlements, requests, book] = await Promise.all([
Agents as a team: lifecycle, merge queue, billing and a new shell85 billing.account(slug, viewer),
The statement is a month at a time, a line per kind of charge86 billing.statement(slug, viewer, url.searchParams.get("month"), group),
Paid features: a workspace turns on Deployments with a monthly plan87 billing.features(slug, viewer),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas88 billing.usageMeters(slug, viewer).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look89 billing.limit(slug, viewer).catch(() => null),
Two limits, real invoices, trust that grows by itself, sales signals90 billing.invoices(slug, viewer).catch(() => null),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put91 billing.entitlements(slug).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look92 billing.limitRequests(slug, viewer).catch(() => null),
93 billing.prices().catch(() => null),
Agents as a team: lifecycle, merge queue, billing and a new shell94 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look95 const featureStates = unwrap(features);
96 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
97 const done = url.searchParams.get("done");
Agents as a team: lifecycle, merge queue, billing and a new shell98 return {
99 slug,
100 role,
101 account: unwrap(account),
The statement is a month at a time, a line per kind of charge102 statement: unwrap(statement),
103 group,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look104 plan: featureStates.find((state) => state.plan.feature === "plan") ?? null,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas105 meters: meters?.ok ? meters.value : null,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look106 limit: limit?.ok ? limit.value : null,
Two limits, real invoices, trust that grows by itself, sales signals107 invoices: invoices?.ok ? invoices.value : [],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put108 entitlements,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look109 requests: requests?.ok ? requests.value : [],
110 trialMicros,
111 notice:
112 url.searchParams.has("checked") && entitlements
113 ? cardCheckResult(entitlements, trialMicros)
114 : done && DONE[done]
115 ? DONE[done]
116 : null,
117 problem: url.searchParams.get("problem"),
Agents as a team: lifecycle, merge queue, billing and a new shell118 };
119}
120
121export async function action({ request, params, context }: Route.ActionArgs) {
122 assertSameOrigin(request);
123 const user = requireUser(context, request);
124 const form = await request.formData();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125 const slug = params.owner.toLowerCase();
126 // Spelled out: a form post arrives at a data address, not the page's.
127 const here = `${new URL(request.url).origin}/${slug}/-/billing`;
128 const intent = String(form.get("intent") ?? "");
129 const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 });
130 const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`);
131
132 switch (intent) {
133 case "portal": {
134 // Card, invoices and billing details live on Stripe's own page.
135 const started = await billing.billingPortal(user, slug, here);
136 if (!started.ok) return fail("plan", started.error.message);
137 throw redirect(started.value.url);
138 }
139 case "subscribe": {
140 const started = await billing.subscribe(user, slug, "plan", `${here}?plan=plan`);
141 if (!started.ok) return fail("plan", started.error.message);
142 throw redirect(started.value.url);
143 }
144 case "cancel":
145 case "resume": {
146 const changed = await billing.cancelSubscription(user, slug, "plan", intent === "resume");
147 if (!changed.ok) return fail("plan", changed.error.message);
148 throw done(intent === "resume" ? "resumed" : "canceled");
149 }
150 case "card-check": {
151 const started = await billing.cardCheck(user, slug, here);
152 if (!started.ok) return fail("trial", started.error.message);
153 throw redirect(started.value.url);
154 }
155 case "spend-limit": {
156 const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") });
157 if (!parsed.ok) return fail("limit", parsed.error);
158 let micros = parsed.value.micros;
159 if (parsed.value.raiseOnce && micros == null) {
160 // The one-time raise with no amount: as high as it goes.
161 const limit = await billing.limit(slug, user);
162 micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null;
163 if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit.");
164 }
165 const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce);
166 if (!set.ok) return fail("limit", set.error.message);
167 throw done("limit", "#limit");
168 }
169 case "request": {
170 const parsed = parseLimitRequest({
171 kind: form.get("kind"),
172 amount: form.get("amount"),
173 reason: form.get("reason"),
174 expected: form.get("expected"),
175 });
176 const section = String(form.get("kind")) === "overage" ? "overage" : "raise";
177 if (!parsed.ok) return fail(section, parsed.error);
178 const sent = await billing.requestLimit(user, slug, parsed.value);
179 if (!sent.ok) return fail(section, sent.error.message);
180 throw done(section === "overage" ? "told" : "requested", `#${section}`);
181 }
182 case "caps": {
183 const parsed = parseCaps({ run: form.get("run"), issue: form.get("issue") });
184 if (!parsed.ok) return fail("caps", parsed.error);
185 const set = await billing.setCaps(user, slug, parsed.value);
186 if (!set.ok) return fail("caps", set.error.message);
187 throw done("caps", "#caps");
188 }
189 case "spike": {
190 const keep = String(form.get("decision")) === "keep";
191 const answered = await billing.confirmSpike(user, slug, keep);
192 if (!answered.ok) return fail("spike", answered.error.message);
193 throw done(keep ? "keep" : "stop");
Usage limits: unpaid usage can only go so far194 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look195 case "prepay": {
196 const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") });
197 if (!parsed.ok) return fail("prepay", parsed.error);
198 const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method);
199 if (!started.ok) return fail("prepay", started.error.message);
200 throw redirect(started.value.url);
Paid features: a workspace turns on Deployments with a monthly plan201 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look202 default:
203 return fail("plan", "Unknown action.");
Paid features: a workspace turns on Deployments with a monthly plan204 }
Agents as a team: lifecycle, merge queue, billing and a new shell205}
206
207export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas208 const { slug, role, account, statement, group, plan, meters, limit, invoices, entitlements, requests, trialMicros, notice, problem } =
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put209 loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell210 const { status } = account;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look211 const owner = role === "owner";
212 const error = actionData as SectionError;
213 const err = (section: string) => (error && error.section === section ? error.error : undefined);
214 const standing = planStatus(plan, entitlements);
215 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
216 const free = standing.kind === "free" || standing.kind === "trial";
217 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
218
Agents as a team: lifecycle, merge queue, billing and a new shell219 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell221 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks222 {status.free && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look223 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
Free while g1t is being built out; agents can check out their own forks224 <h2 className="font-medium">Free while g1t is being built out</h2>
225 <p className="mt-1.5 max-w-2xl text-sm text-muted">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look226 Runs are recorded with what they cost, so Usage shows what you use, but nothing is charged for now. We will say so well
227 before anything is.
Free while g1t is being built out; agents can check out their own forks228 </p>
229 </div>
230 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look231 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
232 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
233
234 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
235 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
236 <Alerts alerts={entitlements?.alerts ?? []} />
237
238 <PlanCard
239 state={plan}
240 status={standing}
241 entitlements={entitlements}
242 owner={owner}
243 enabled={status.enabled}
244 live={status.live}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas245 meters={meters}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look246 error={err("plan")}
247 />
248
249 {free && status.enabled && (
Merge branch 'worktree-agent-a985c01f43538af01'250 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
A flat project menu with Code first, views as tabs, and Stripe for every owner251 )}
252
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look253 {paying && limit && limit.trust !== "internal" && (
254 <>
Merge branch 'worktree-agent-a985c01f43538af01'255 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
256 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
257 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} error={err("prepay")} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258 </>
259 )}
260
261 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
Merge branch 'worktree-agent-a985c01f43538af01'262 <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
Usage limits: unpaid usage can only go so far263 )}
264
Merge branch 'worktree-agent-a985c01f43538af01'265 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look266
267 {status.enabled && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace268 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
269 <div className="flex flex-wrap items-center justify-between gap-3">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look270 <div className="min-w-0">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace271 <h2 className="font-medium">Card and invoices</h2>
272 {account.card ? (
273 <p className="mt-1 text-sm">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274 <span className="capitalize">{account.card.brand}</span> ending <span className="font-mono">{account.card.last4}</span>
275 <span className="text-muted"> · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}</span>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace276 </p>
277 ) : (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 <p className="mt-1 max-w-xl text-sm text-muted">No card yet. The plan, the trial and prepaying each ask for one on Stripe.</p>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace279 )}
280 <p className="mt-2 max-w-xl text-xs text-faint">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look281 Cards, invoices, receipts and the billing email, address and tax ID are on Stripe's billing page. g1t never sees card
282 numbers.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace283 </p>
284 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look285 {owner && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace286 <Form method="post">
Merge branch 'worktree-agent-a985c01f43538af01'287 <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace288 <CreditCard size={14} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look289 Open Stripe billing
Merge branch 'worktree-agent-a985c01f43538af01'290 </SubmitButton>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace291 </Form>
292 )}
293 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 {!status.live && (
295 <p className="mt-3 text-xs text-faint">Test mode: use card 4242 4242 4242 4242, any future date and code.</p>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace296 )}
297 </section>
298 )}
299
Two limits, real invoices, trust that grows by itself, sales signals300 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
Agents as a team: lifecycle, merge queue, billing and a new shell301
The statement is a month at a time, a line per kind of charge302 <StatementView slug={slug} statement={statement} group={group} />
Agents as a team: lifecycle, merge queue, billing and a new shell303 </div>
304
305 <aside className="space-y-5 text-sm">
306 <section className="rounded-xl border border-line bg-surface p-5">
307 <h3 className="font-medium">How it is charged</h3>
308 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
309 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look310 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace. Usage is charged at what it
311 costs g1t plus {account.marginPercent}%, from the first second, after what is included.
Agents as a team: lifecycle, merge queue, billing and a new shell312 </li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 <li>What paid first is on each statement line: the plan's included usage, the trial, the open-source pool, or g1t.</li>
Agents as a team: lifecycle, merge queue, billing and a new shell314 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read315 With your own model provider, connected under{" "}
316 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
317 Integrations
318 </Link>
Prices are what g1t pays plus 20%, from the first second319 , the provider bills you for the model, and a run here is charged only its sandbox time.
Agents as a team: lifecycle, merge queue, billing and a new shell320 </li>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put321 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look322 Each month closes with an itemised invoice. No card is charged less than{" "}
323 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put324 </li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look325 <li>Alerts at 50, 75, 90 and 100% of the included usage and your limits, here and by email.</li>
A flat project menu with Code first, views as tabs, and Stripe for every owner326 <li>No seats: add as many people and agents as you like.</li>
Agents as a team: lifecycle, merge queue, billing and a new shell327 </ul>
A flat project menu with Code first, views as tabs, and Stripe for every owner328 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
329 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
330 <FileText size={14} /> Usage, run by run
331 </Link>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look332 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
333 <CreditCard size={14} /> Pricing and today's prices
334 </Link>
335 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
A flat project menu with Code first, views as tabs, and Stripe for every owner336 <ArrowUpRight size={14} /> How usage and billing work
337 </a>
338 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell339 </section>
340 </aside>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put341 </div>
342 );
343}
344
Two limits, real invoices, trust that grows by itself, sales signals345/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
346function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
347 return (
348 <section className="mb-10">
349 <h2 className="font-medium">Invoices</h2>
350 <p className="mt-1 text-sm text-muted">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look351 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also on Stripe's
352 billing page.
Two limits, real invoices, trust that grows by itself, sales signals353 </p>
354 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
355 {invoices.map((invoice) => (
356 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
357 <div className="flex flex-wrap items-center gap-3">
358 <span className="font-medium">
359 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
360 </span>
361 <span
362 className={`rounded-full border px-2 py-0.5 text-xs ${
363 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
364 }`}
365 >
366 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
367 </span>
368 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
369 {invoice.hostedUrl && (
370 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
371 View
372 </a>
373 )}
374 {invoice.pdfUrl && (
375 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
376 PDF
377 </a>
378 )}
379 </div>
380 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
381 {invoice.lines.map((line) => (
382 <li key={line.description} className="flex justify-between gap-4">
383 <span>{line.description}</span>
384 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
385 </li>
386 ))}
387 </ul>
388 </li>
389 ))}
390 </ul>
391 </section>
392 );
393}

This file's history is long; its oldest lines are credited to the oldest commit read.