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

395 lines18,316 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";
Agents as a team: lifecycle, merge queue, billing and a new shell2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
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";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look20import { Button } from "../../components/ui";
21import {
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 busy = useNavigation().state === "submitting";
213 const error = actionData as SectionError;
214 const err = (section: string) => (error && error.section === section ? error.error : undefined);
215 const standing = planStatus(plan, entitlements);
216 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
217 const free = standing.kind === "free" || standing.kind === "trial";
218 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
219
Agents as a team: lifecycle, merge queue, billing and a new shell220 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look221 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell222 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks223 {status.free && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look224 <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 forks225 <h2 className="font-medium">Free while g1t is being built out</h2>
226 <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 look227 Runs are recorded with what they cost, so Usage shows what you use, but nothing is charged for now. We will say so well
228 before anything is.
Free while g1t is being built out; agents can check out their own forks229 </p>
230 </div>
231 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look232 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
233 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
234
235 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
236 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
237 <Alerts alerts={entitlements?.alerts ?? []} />
238
239 <PlanCard
240 state={plan}
241 status={standing}
242 entitlements={entitlements}
243 owner={owner}
244 enabled={status.enabled}
245 live={status.live}
246 busy={busy}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas247 meters={meters}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look248 error={err("plan")}
249 />
250
251 {free && status.enabled && (
252 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} busy={busy} error={err("trial")} />
A flat project menu with Code first, views as tabs, and Stripe for every owner253 )}
254
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look255 {paying && limit && limit.trust !== "internal" && (
256 <>
257 <SpendLimitCard limit={limit} owner={owner} busy={busy} error={err("limit")} />
258 <RaiseCard requests={requests} owner={owner} busy={busy} error={err("raise")} />
259 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} busy={busy} error={err("prepay")} />
260 </>
261 )}
262
263 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
264 <CapsCard entitlements={entitlements} owner={owner} busy={busy} error={err("caps")} />
Usage limits: unpaid usage can only go so far265 )}
266
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look267 {paying && <OverageCard requests={requests} owner={owner} busy={busy} error={err("overage")} />}
268
269 {status.enabled && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace270 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
271 <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 look272 <div className="min-w-0">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace273 <h2 className="font-medium">Card and invoices</h2>
274 {account.card ? (
275 <p className="mt-1 text-sm">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 <span className="capitalize">{account.card.brand}</span> ending <span className="font-mono">{account.card.last4}</span>
277 <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 workspace278 </p>
279 ) : (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look280 <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 workspace281 )}
282 <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 look283 Cards, invoices, receipts and the billing email, address and tax ID are on Stripe's billing page. g1t never sees card
284 numbers.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace285 </p>
286 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look287 {owner && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace288 <Form method="post">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look289 <Button variant="quiet" type="submit" name="intent" value="portal" disabled={busy}>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace290 <CreditCard size={14} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look291 Open Stripe billing
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace292 </Button>
293 </Form>
294 )}
295 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look296 {!status.live && (
297 <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 workspace298 )}
299 </section>
300 )}
301
Two limits, real invoices, trust that grows by itself, sales signals302 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
Agents as a team: lifecycle, merge queue, billing and a new shell303
The statement is a month at a time, a line per kind of charge304 <StatementView slug={slug} statement={statement} group={group} />
Agents as a team: lifecycle, merge queue, billing and a new shell305 </div>
306
307 <aside className="space-y-5 text-sm">
308 <section className="rounded-xl border border-line bg-surface p-5">
309 <h3 className="font-medium">How it is charged</h3>
310 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
311 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace. Usage is charged at what it
313 costs g1t plus {account.marginPercent}%, from the first second, after what is included.
Agents as a team: lifecycle, merge queue, billing and a new shell314 </li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 <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 shell316 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read317 With your own model provider, connected under{" "}
318 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
319 Integrations
320 </Link>
Prices are what g1t pays plus 20%, from the first second321 , 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 shell322 </li>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put323 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look324 Each month closes with an itemised invoice. No card is charged less than{" "}
325 {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 put326 </li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look327 <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 owner328 <li>No seats: add as many people and agents as you like.</li>
Agents as a team: lifecycle, merge queue, billing and a new shell329 </ul>
A flat project menu with Code first, views as tabs, and Stripe for every owner330 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
331 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
332 <FileText size={14} /> Usage, run by run
333 </Link>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look334 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
335 <CreditCard size={14} /> Pricing and today's prices
336 </Link>
337 <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 owner338 <ArrowUpRight size={14} /> How usage and billing work
339 </a>
340 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell341 </section>
342 </aside>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put343 </div>
344 );
345}
346
Two limits, real invoices, trust that grows by itself, sales signals347/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
348function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
349 return (
350 <section className="mb-10">
351 <h2 className="font-medium">Invoices</h2>
352 <p className="mt-1 text-sm text-muted">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also on Stripe's
354 billing page.
Two limits, real invoices, trust that grows by itself, sales signals355 </p>
356 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
357 {invoices.map((invoice) => (
358 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
359 <div className="flex flex-wrap items-center gap-3">
360 <span className="font-medium">
361 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
362 </span>
363 <span
364 className={`rounded-full border px-2 py-0.5 text-xs ${
365 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
366 }`}
367 >
368 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
369 </span>
370 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
371 {invoice.hostedUrl && (
372 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
373 View
374 </a>
375 )}
376 {invoice.pdfUrl && (
377 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
378 PDF
379 </a>
380 )}
381 </div>
382 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
383 {invoice.lines.map((line) => (
384 <li key={line.description} className="flex justify-between gap-4">
385 <span>{line.description}</span>
386 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
387 </li>
388 ))}
389 </ul>
390 </li>
391 ))}
392 </ul>
393 </section>
394 );
395}