Skip to content

g1t/apps/web/app/components/statement.tsx

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