Skip to content
354 linesCodeBlameRaw

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.

The statement is a month at a time, a line per kind of charge1import { ChevronRight, Download } from "lucide-react";
2import { useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import { MICROS_PER_DOLLAR, type LedgerEntry, type Statement } from "@g1t/contracts";
6
7import { EmptyState, TimeAgo } from "./ui";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.8import { Button } from "./ui/button";
9import { Card } from "./ui/card";
Fast pages, required checks on the branch, self-hosted runners, honest incidents10import { SkeletonRows } from "./ui/skeleton";
The statement is a month at a time, a line per kind of charge11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
12import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
13
14/** Millionths of a dollar as dollars, to the cent or finer. */
15function dollars(micros: number, digits = 2): string {
16 const sign = micros < 0 ? "−" : "";
17 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
18}
19
20/** Charges to the cent, but a fraction of a cent shown as such rather than $0.00. */
21function charge(micros: number): string {
22 return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
23}
24
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging25/** A usage entry at its price: what was charged, what paid for it first, and what the discount took off. */
26export function entryPrice(entry: Pick<LedgerEntry, "amountMicros" | "creditMicros" | "trialMicros" | "ossMicros" | "givenMicros" | "discountMicros">): number {
27 if ((entry.discountMicros ?? 0) === 0) return -entry.amountMicros;
28 return -entry.amountMicros + (entry.creditMicros ?? 0) + (entry.trialMicros ?? 0) + (entry.ossMicros ?? 0) + (entry.givenMicros ?? 0) + (entry.discountMicros ?? 0);
29}
30
31/** `Discount (100%)`, or `Discount` when the percentage is not known (a discount since ended). */
32export function discountName(percent: number | null | undefined): string {
33 return percent ? `Discount (${percent}%)` : "Discount";
34}
35
The statement is a month at a time, a line per kind of charge36function monthLabel(month: string): string {
37 const [year, number] = month.split("-").map(Number);
38 return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
39 month: "long",
40 year: "numeric",
41 timeZone: "UTC",
42 });
43}
44
45function dayLabel(day: string): string {
46 return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
47 weekday: "short",
48 month: "short",
49 day: "numeric",
50 timeZone: "UTC",
51 });
52}
53
54/**
55 * A month of the workspace's ledger: totals, then a group per day (or per
56 * project) with one line per kind of charge. A line opens to its entries,
57 * 50 at a time.
58 */
59export function StatementView({
60 slug,
61 statement,
62 group,
63}: {
64 slug: string;
65 statement: Statement;
66 group: "day" | "project";
67}) {
68 const navigate = useNavigate();
69 const go = (month: string, by: string) =>
70 navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
71 const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
72 const { totals } = statement;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging73 // With a discount, every line at its price, and the discount beside it.
74 const atPrice = (totals.discountMicros ?? 0) > 0;
75 const discountLabel = discountName(totals.discountPercent);
The statement is a month at a time, a line per kind of charge76
77 return (
78 <section id="statement" className="mt-10 scroll-mt-20">
79 <div className="flex flex-wrap items-center gap-3">
80 <h3 className="grow text-sm font-medium text-muted">Statement</h3>
81 <Select value={statement.month} onValueChange={(month) => go(month, group)}>
82 <SelectTrigger size="sm" className="w-40" aria-label="Month">
83 <SelectValue />
84 </SelectTrigger>
85 <SelectContent>
86 {months.map((month) => (
87 <SelectItem key={month} value={month}>
88 {monthLabel(month)}
89 </SelectItem>
90 ))}
91 </SelectContent>
92 </Select>
93 <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
94 <TabsList>
95 <TabsTrigger value="day">By day</TabsTrigger>
96 <TabsTrigger value="project">By project</TabsTrigger>
97 </TabsList>
98 </Tabs>
99 {totals.entries > 0 && (
100 <a
101 href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
102 className="flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-muted hover:border-line-strong hover:text-fg"
103 download
104 >
105 <Download size={13} /> CSV
106 </a>
107 )}
108 </div>
109
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging110 {atPrice ? (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.111 <Card asChild className="mt-3 grid grid-cols-2 divide-line text-sm sm:grid-cols-4 sm:divide-x">
112 <dl>
113 <div className="px-4 py-3">
114 <dt className="text-xs text-faint">Usage at price</dt>
115 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.priceMicros ?? 0)}</dd>
116 </div>
117 <div className="px-4 py-3">
118 <dt className="text-xs text-faint">{discountLabel}</dt>
119 <dd className="mt-0.5 font-mono tabular-nums text-success">{charge(-(totals.discountMicros ?? 0))}</dd>
120 </div>
121 <div className="px-4 py-3">
122 <dt className="text-xs text-faint">Charged</dt>
123 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
124 </div>
125 <div className="px-4 py-3">
126 <dt className="text-xs text-faint">Paid and credited</dt>
127 <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
128 </div>
129 </dl>
130 </Card>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging131 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.132 <Card asChild divided className="mt-3 grid grid-cols-1 sm:grid-cols-3 sm:divide-x sm:divide-y-0 text-sm">
133 <dl>
134 <div className="px-4 py-3">
135 <dt className="text-xs text-faint">Charged</dt>
136 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
137 </div>
138 <div className="px-4 py-3">
139 <dt className="text-xs text-faint">Paid and credited</dt>
140 <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
141 </div>
142 <div className="px-4 py-3">
143 <dt className="text-xs text-faint">Entries</dt>
144 <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
145 </div>
146 </dl>
147 </Card>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging148 )}
The statement is a month at a time, a line per kind of charge149
Merge Stripe Tax, the card fee on card payments, and one free workspace per person150 {((totals.taxMicros ?? 0) !== 0 || (totals.cardFeeMicros ?? 0) !== 0) && (
151 <p className="mt-2 text-xs text-faint">
152 Paid with this month's payments on top of what they credited: tax {dollars(totals.taxMicros ?? 0)}, card processing fees{" "}
153 {dollars(totals.cardFeeMicros ?? 0)}. Prices exclude tax; neither comes from your balance.
154 </p>
155 )}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put156 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.157 <Card asChild className="mt-2 space-y-1 px-4 py-3 text-sm">
158 <ul>
159 {totals.covered?.map((paid) => (
160 <li key={paid.source} className="flex justify-between gap-4">
161 <span className="text-muted">{paid.label}</span>
162 <span className="font-mono tabular-nums text-success">{charge(paid.micros)}</span>
163 </li>
164 ))}
165 {(totals.carriedMicros ?? 0) > 0 && (
166 <li className="flex justify-between gap-4">
167 <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
168 <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
169 </li>
170 )}
171 </ul>
172 </Card>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put173 )}
174
The statement is a month at a time, a line per kind of charge175 <div className="mt-3">
176 {statement.groups.length === 0 ? (
177 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
178 Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
179 </EmptyState>
180 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.181 <Card tone="plain" className="overflow-hidden">
The statement is a month at a time, a line per kind of charge182 {statement.groups.map((g) => (
183 <div key={g.key} className="border-b border-line last:border-b-0">
184 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
185 <span className="grow truncate font-medium text-muted">
186 {group === "day" ? dayLabel(g.key) : g.label}
187 </span>
188 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
189 </div>
190 <ul className="divide-y divide-line">
Merge Stripe Tax, the card fee on card payments, and one free workspace per person191 {g.lines.map((line) =>
192 line.kind === "Tax" || line.kind === "Card processing fees" ? (
193 // Paid with the day's payments, on top of what reached the balance: never charged.
194 <li key={line.kind} className="flex items-center gap-3 px-4 py-3 text-sm">
195 <span className="w-3.5 shrink-0" />
196 <span className="grow truncate text-muted">
197 {line.kind} <span className="text-xs text-faint">· paid with the payment, not from the balance</span>
198 </span>
199 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-muted">{dollars(line.passedMicros ?? 0)}</span>
200 </li>
201 ) : (
The statement is a month at a time, a line per kind of charge202 <StatementLineRow
203 key={line.kind}
204 slug={slug}
205 month={statement.month}
206 kind={line.kind}
207 count={line.count}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging208 chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put209 coveredMicros={line.coveredMicros ?? 0}
The statement is a month at a time, a line per kind of charge210 day={group === "day" ? g.key : null}
211 project={group === "project" ? g.key : null}
212 />
Merge Stripe Tax, the card fee on card payments, and one free workspace per person213 ),
214 )}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging215 {(g.discountMicros ?? 0) > 0 && (
216 <li className="flex items-center gap-3 px-4 py-3 text-sm">
217 <span className="w-3.5 shrink-0" />
218 <span className="grow truncate text-muted">{discountLabel}</span>
Merge branch 'worktree-agent-ad7c6d88d93adc817'219 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-success">{charge(-(g.discountMicros ?? 0))}</span>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging220 </li>
221 )}
The statement is a month at a time, a line per kind of charge222 </ul>
223 </div>
224 ))}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.225 </Card>
The statement is a month at a time, a line per kind of charge226 )}
227 </div>
228 </section>
229 );
230}
231
232function StatementLineRow({
233 slug,
234 month,
235 kind,
236 count,
237 chargedMicros,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put238 coveredMicros,
The statement is a month at a time, a line per kind of charge239 day,
240 project,
241}: {
242 slug: string;
243 month: string;
244 kind: string;
245 count: number;
246 chargedMicros: number;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put247 coveredMicros: number;
The statement is a month at a time, a line per kind of charge248 day: string | null;
249 project: string | null;
250}) {
251 const [open, setOpen] = useState(false);
252 const [entries, setEntries] = useState<LedgerEntry[]>([]);
253 const [done, setDone] = useState(false);
Merge branch 'worktree-agent-a985c01f43538af01'254 const fetcher = useFetcher<LedgerEntry[] | { error: string }>();
The statement is a month at a time, a line per kind of charge255 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
Merge branch 'worktree-agent-a985c01f43538af01'256 const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null;
The statement is a month at a time, a line per kind of charge257
Merge branch 'worktree-agent-a985c01f43538af01'258 // Each page that arrives is added once. A page can come again: the page
259 // reloads what fetchers loaded after every change made on it, so an entry
260 // already listed is not listed twice.
261 if (Array.isArray(fetcher.data) && fetcher.data !== seen) {
262 const page = fetcher.data;
263 setSeen(page);
264 setEntries((before) => {
265 const listed = new Set(before.map((entry) => entry.id));
266 return [...before, ...page.filter((entry) => !listed.has(entry.id))];
267 });
268 if (page.length < 50) setDone(true);
The statement is a month at a time, a line per kind of charge269 }
270
271 const load = (before: string | null) => {
272 const query = new URLSearchParams({ month, kind });
273 if (day) query.set("day", day);
274 if (project !== null) query.set("project", project);
275 if (before) query.set("before", before);
276 fetcher.load(`/${slug}/-/billing/entries?${query}`);
277 };
278
279 const toggle = () => {
280 if (!open && entries.length === 0) load(null);
281 setOpen(!open);
282 };
283 const moneyIn = chargedMicros < 0;
284
285 return (
286 <li>
287 <button
288 type="button"
289 onClick={toggle}
290 aria-expanded={open}
291 className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
292 >
293 <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
294 <span className="grow truncate font-medium">{kind}</span>
295 <span className="shrink-0 text-xs text-faint tabular-nums">
296 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put297 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
The statement is a month at a time, a line per kind of charge298 </span>
299 <span
Merge branch 'worktree-agent-ad7c6d88d93adc817'300 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-success" : "text-fg"}`}
The statement is a month at a time, a line per kind of charge301 >
302 {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
303 </span>
304 </button>
305 {open && (
306 <div className="border-t border-line bg-bg/40 pb-2">
307 <ul className="divide-y divide-line/60">
308 {entries.map((entry) => (
309 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
310 <div className="min-w-0 grow">
311 {entry.repo && entry.number ? (
312 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
313 {entry.description}
314 </Link>
315 ) : (
316 <p className="truncate">{entry.description}</p>
317 )}
318 <p className="mt-0.5 text-xs text-faint">
319 <TimeAgo at={entry.createdAt} />
320 {entry.model && ` · ${entry.model}`}
321 {entry.createdBy && ` · ${entry.createdBy}`}
322 </p>
323 </div>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging324 <span className="shrink-0 text-right font-mono text-xs tabular-nums text-muted">
325 {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(entryPrice(entry))}
326 {(entry.discountMicros ?? 0) > 0 && (
Merge branch 'worktree-agent-ad7c6d88d93adc817'327 <span className="block text-[0.6875rem] text-success">{charge(-(entry.discountMicros ?? 0))} discount</span>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging328 )}
The statement is a month at a time, a line per kind of charge329 </span>
330 </li>
331 ))}
332 </ul>
Merge branch 'worktree-agent-a985c01f43538af01'333 {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
Fast pages, required checks on the branch, self-hosted runners, honest incidents334 {fetcher.state === "loading" && (
335 <div aria-busy="true" className="pl-8">
336 <SkeletonRows rows={3} rowClassName="h-12" />
337 </div>
338 )}
The statement is a month at a time, a line per kind of charge339 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.340 <Button
The statement is a month at a time, a line per kind of charge341 type="button"
342 onClick={() => load(entries[entries.length - 1].id)}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.343 variant="link"
344 size="inline"
345 className="mt-1 ml-11 text-xs text-muted hover:text-fg font-normal"
The statement is a month at a time, a line per kind of charge346 >
347 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.348 </Button>
The statement is a month at a time, a line per kind of charge349 )}
350 </div>
351 )}
352 </li>
353 );
354}

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