Skip to content
354 linesCodeBlameRaw
1import { 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";
8import { Button } from "./ui/button";
9import { Card } from "./ui/card";
10import { SkeletonRows } from "./ui/skeleton";
11import { 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
25/** 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
36function 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;
73 // 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);
76
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
110 {atPrice ? (
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>
131 ) : (
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>
148 )}
149
150 {((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 )}
156 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
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>
173 )}
174
175 <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 ) : (
181 <Card tone="plain" className="overflow-hidden">
182 {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">
191 {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 ) : (
202 <StatementLineRow
203 key={line.kind}
204 slug={slug}
205 month={statement.month}
206 kind={line.kind}
207 count={line.count}
208 chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
209 coveredMicros={line.coveredMicros ?? 0}
210 day={group === "day" ? g.key : null}
211 project={group === "project" ? g.key : null}
212 />
213 ),
214 )}
215 {(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>
219 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-success">{charge(-(g.discountMicros ?? 0))}</span>
220 </li>
221 )}
222 </ul>
223 </div>
224 ))}
225 </Card>
226 )}
227 </div>
228 </section>
229 );
230}
231
232function StatementLineRow({
233 slug,
234 month,
235 kind,
236 count,
237 chargedMicros,
238 coveredMicros,
239 day,
240 project,
241}: {
242 slug: string;
243 month: string;
244 kind: string;
245 count: number;
246 chargedMicros: number;
247 coveredMicros: number;
248 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);
254 const fetcher = useFetcher<LedgerEntry[] | { error: string }>();
255 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
256 const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null;
257
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);
269 }
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"}
297 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
298 </span>
299 <span
300 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-success" : "text-fg"}`}
301 >
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>
324 <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 && (
327 <span className="block text-[0.6875rem] text-success">{charge(-(entry.discountMicros ?? 0))} discount</span>
328 )}
329 </span>
330 </li>
331 ))}
332 </ul>
333 {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
334 {fetcher.state === "loading" && (
335 <div aria-busy="true" className="pl-8">
336 <SkeletonRows rows={3} rowClassName="h-12" />
337 </div>
338 )}
339 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
340 <Button
341 type="button"
342 onClick={() => load(entries[entries.length - 1].id)}
343 variant="link"
344 size="inline"
345 className="mt-1 ml-11 text-xs text-muted hover:text-fg font-normal"
346 >
347 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
348 </Button>
349 )}
350 </div>
351 )}
352 </li>
353 );
354}