Skip to content
349 linesCodeBlameRaw
1import { ChevronRight, Download } from "lucide-react";
2import { useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import type { LedgerEntry, Statement } from "@g1t/contracts";
6import { money } from "../lib/money";
7
8import { EmptyState, TimeAgo } from "./ui";
9import { Button } from "./ui/button";
10import { Card } from "./ui/card";
11import { SkeletonRows } from "./ui/skeleton";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
13import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
14
15/** A charge, with the fraction of a cent it carries (`lib/money.ts`): a statement adds up to the micro. */
16function charge(micros: number): string {
17 return money(micros, { precise: true });
18}
19
20/** A usage entry at its price: what was charged, what paid for it first, and what the discount took off. */
21export function entryPrice(entry: Pick<LedgerEntry, "amountMicros" | "creditMicros" | "trialMicros" | "ossMicros" | "givenMicros" | "discountMicros">): number {
22 if ((entry.discountMicros ?? 0) === 0) return -entry.amountMicros;
23 return -entry.amountMicros + (entry.creditMicros ?? 0) + (entry.trialMicros ?? 0) + (entry.ossMicros ?? 0) + (entry.givenMicros ?? 0) + (entry.discountMicros ?? 0);
24}
25
26/** `Discount (100%)`, or `Discount` when the percentage is not known (a discount since ended). */
27export function discountName(percent: number | null | undefined): string {
28 return percent ? `Discount (${percent}%)` : "Discount";
29}
30
31function monthLabel(month: string): string {
32 const [year, number] = month.split("-").map(Number);
33 return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
34 month: "long",
35 year: "numeric",
36 timeZone: "UTC",
37 });
38}
39
40function dayLabel(day: string): string {
41 return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
42 weekday: "short",
43 month: "short",
44 day: "numeric",
45 timeZone: "UTC",
46 });
47}
48
49/**
50 * A month of the workspace's ledger: totals, then a group per day (or per
51 * project) with one line per kind of charge. A line opens to its entries,
52 * 50 at a time.
53 */
54export function StatementView({
55 slug,
56 statement,
57 group,
58}: {
59 slug: string;
60 statement: Statement;
61 group: "day" | "project";
62}) {
63 const navigate = useNavigate();
64 const go = (month: string, by: string) =>
65 navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
66 const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
67 const { totals } = statement;
68 // With a discount, every line at its price, and the discount beside it.
69 const atPrice = (totals.discountMicros ?? 0) > 0;
70 const discountLabel = discountName(totals.discountPercent);
71
72 return (
73 <section id="statement" className="mt-10 scroll-mt-20">
74 <div className="flex flex-wrap items-center gap-3">
75 <h3 className="grow text-sm font-medium text-muted">Statement</h3>
76 <Select value={statement.month} onValueChange={(month) => go(month, group)}>
77 <SelectTrigger size="sm" className="w-40" aria-label="Month">
78 <SelectValue />
79 </SelectTrigger>
80 <SelectContent>
81 {months.map((month) => (
82 <SelectItem key={month} value={month}>
83 {monthLabel(month)}
84 </SelectItem>
85 ))}
86 </SelectContent>
87 </Select>
88 <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
89 <TabsList>
90 <TabsTrigger value="day">By day</TabsTrigger>
91 <TabsTrigger value="project">By project</TabsTrigger>
92 </TabsList>
93 </Tabs>
94 {totals.entries > 0 && (
95 <a
96 href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
97 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"
98 download
99 >
100 <Download size={13} /> CSV
101 </a>
102 )}
103 </div>
104
105 {atPrice ? (
106 <Card asChild className="mt-3 grid grid-cols-2 divide-line text-sm sm:grid-cols-4 sm:divide-x">
107 <dl>
108 <div className="px-4 py-3">
109 <dt className="text-xs text-faint">Usage at price</dt>
110 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.priceMicros ?? 0)}</dd>
111 </div>
112 <div className="px-4 py-3">
113 <dt className="text-xs text-faint">{discountLabel}</dt>
114 <dd className="mt-0.5 font-mono tabular-nums text-success">{charge(-(totals.discountMicros ?? 0))}</dd>
115 </div>
116 <div className="px-4 py-3">
117 <dt className="text-xs text-faint">Charged</dt>
118 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
119 </div>
120 <div className="px-4 py-3">
121 <dt className="text-xs text-faint">Paid and credited</dt>
122 <dd className="mt-0.5 font-mono tabular-nums">{money(totals.paidMicros)}</dd>
123 </div>
124 </dl>
125 </Card>
126 ) : (
127 <Card asChild divided className="mt-3 grid grid-cols-1 sm:grid-cols-3 sm:divide-x sm:divide-y-0 text-sm">
128 <dl>
129 <div className="px-4 py-3">
130 <dt className="text-xs text-faint">Charged</dt>
131 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
132 </div>
133 <div className="px-4 py-3">
134 <dt className="text-xs text-faint">Paid and credited</dt>
135 <dd className="mt-0.5 font-mono tabular-nums">{money(totals.paidMicros)}</dd>
136 </div>
137 <div className="px-4 py-3">
138 <dt className="text-xs text-faint">Entries</dt>
139 <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
140 </div>
141 </dl>
142 </Card>
143 )}
144
145 {((totals.taxMicros ?? 0) !== 0 || (totals.cardFeeMicros ?? 0) !== 0) && (
146 <p className="mt-2 text-xs text-faint">
147 Paid with this month's payments on top of what they credited: tax {money(totals.taxMicros ?? 0)}, card processing fees{" "}
148 {money(totals.cardFeeMicros ?? 0)}. Prices exclude tax; neither comes from your balance.
149 </p>
150 )}
151 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
152 <Card asChild className="mt-2 space-y-1 px-4 py-3 text-sm">
153 <ul>
154 {totals.covered?.map((paid) => (
155 <li key={paid.source} className="flex justify-between gap-4">
156 <span className="text-muted">{paid.label}</span>
157 <span className="font-mono tabular-nums text-success">{charge(paid.micros)}</span>
158 </li>
159 ))}
160 {(totals.carriedMicros ?? 0) > 0 && (
161 <li className="flex justify-between gap-4">
162 <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
163 <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
164 </li>
165 )}
166 </ul>
167 </Card>
168 )}
169
170 <div className="mt-3">
171 {statement.groups.length === 0 ? (
172 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
173 Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
174 </EmptyState>
175 ) : (
176 <Card tone="plain" className="overflow-hidden">
177 {statement.groups.map((g) => (
178 <div key={g.key} className="border-b border-line last:border-b-0">
179 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
180 <span className="grow truncate font-medium text-muted">
181 {group === "day" ? dayLabel(g.key) : g.label}
182 </span>
183 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
184 </div>
185 <ul className="divide-y divide-line">
186 {g.lines.map((line) =>
187 line.kind === "Tax" || line.kind === "Card processing fees" ? (
188 // Paid with the day's payments, on top of what reached the balance: never charged.
189 <li key={line.kind} className="flex items-center gap-3 px-4 py-3 text-sm">
190 <span className="w-3.5 shrink-0" />
191 <span className="grow truncate text-muted">
192 {line.kind} <span className="text-xs text-faint">· paid with the payment, not from the balance</span>
193 </span>
194 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-muted">{money(line.passedMicros ?? 0)}</span>
195 </li>
196 ) : (
197 <StatementLineRow
198 key={line.kind}
199 slug={slug}
200 month={statement.month}
201 kind={line.kind}
202 count={line.count}
203 chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
204 coveredMicros={line.coveredMicros ?? 0}
205 day={group === "day" ? g.key : null}
206 project={group === "project" ? g.key : null}
207 />
208 ),
209 )}
210 {(g.discountMicros ?? 0) > 0 && (
211 <li className="flex items-center gap-3 px-4 py-3 text-sm">
212 <span className="w-3.5 shrink-0" />
213 <span className="grow truncate text-muted">{discountLabel}</span>
214 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-success">{charge(-(g.discountMicros ?? 0))}</span>
215 </li>
216 )}
217 </ul>
218 </div>
219 ))}
220 </Card>
221 )}
222 </div>
223 </section>
224 );
225}
226
227function StatementLineRow({
228 slug,
229 month,
230 kind,
231 count,
232 chargedMicros,
233 coveredMicros,
234 day,
235 project,
236}: {
237 slug: string;
238 month: string;
239 kind: string;
240 count: number;
241 chargedMicros: number;
242 coveredMicros: number;
243 day: string | null;
244 project: string | null;
245}) {
246 const [open, setOpen] = useState(false);
247 const [entries, setEntries] = useState<LedgerEntry[]>([]);
248 const [done, setDone] = useState(false);
249 const fetcher = useFetcher<LedgerEntry[] | { error: string }>();
250 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
251 const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null;
252
253 // Each page that arrives is added once. A page can come again: the page
254 // reloads what fetchers loaded after every change made on it, so an entry
255 // already listed is not listed twice.
256 if (Array.isArray(fetcher.data) && fetcher.data !== seen) {
257 const page = fetcher.data;
258 setSeen(page);
259 setEntries((before) => {
260 const listed = new Set(before.map((entry) => entry.id));
261 return [...before, ...page.filter((entry) => !listed.has(entry.id))];
262 });
263 if (page.length < 50) setDone(true);
264 }
265
266 const load = (before: string | null) => {
267 const query = new URLSearchParams({ month, kind });
268 if (day) query.set("day", day);
269 if (project !== null) query.set("project", project);
270 if (before) query.set("before", before);
271 fetcher.load(`/${slug}/-/billing/entries?${query}`);
272 };
273
274 const toggle = () => {
275 if (!open && entries.length === 0) load(null);
276 setOpen(!open);
277 };
278 const moneyIn = chargedMicros < 0;
279
280 return (
281 <li>
282 <button
283 type="button"
284 onClick={toggle}
285 aria-expanded={open}
286 className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
287 >
288 <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
289 <span className="grow truncate font-medium">{kind}</span>
290 <span className="shrink-0 text-xs text-faint tabular-nums">
291 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
292 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
293 </span>
294 <span
295 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-success" : "text-fg"}`}
296 >
297 {moneyIn ? `+${money(-chargedMicros)}` : charge(chargedMicros)}
298 </span>
299 </button>
300 {open && (
301 <div className="border-t border-line bg-bg/40 pb-2">
302 <ul className="divide-y divide-line/60">
303 {entries.map((entry) => (
304 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
305 <div className="min-w-0 grow">
306 {entry.repo && entry.number ? (
307 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
308 {entry.description}
309 </Link>
310 ) : (
311 <p className="truncate">{entry.description}</p>
312 )}
313 <p className="mt-0.5 text-xs text-faint">
314 <TimeAgo at={entry.createdAt} />
315 {entry.model && ` · ${entry.model}`}
316 {entry.createdBy && ` · ${entry.createdBy}`}
317 </p>
318 </div>
319 <span className="shrink-0 text-right font-mono text-xs tabular-nums text-muted">
320 {entry.amountMicros > 0 ? `+${money(entry.amountMicros)}` : charge(entryPrice(entry))}
321 {(entry.discountMicros ?? 0) > 0 && (
322 <span className="block text-[0.6875rem] text-success">{charge(-(entry.discountMicros ?? 0))} discount</span>
323 )}
324 </span>
325 </li>
326 ))}
327 </ul>
328 {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
329 {fetcher.state === "loading" && (
330 <div aria-busy="true" className="pl-8">
331 <SkeletonRows rows={3} rowClassName="h-12" />
332 </div>
333 )}
334 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
335 <Button
336 type="button"
337 onClick={() => load(entries[entries.length - 1].id)}
338 variant="link"
339 size="inline"
340 className="mt-1 ml-11 text-xs text-muted hover:text-fg font-normal"
341 >
342 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
343 </Button>
344 )}
345 </div>
346 )}
347 </li>
348 );
349}