Skip to content

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

327 lines13,861 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.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
145 <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
146 {totals.covered?.map((paid) => (
147 <li key={paid.source} className="flex justify-between gap-4">
148 <span className="text-muted">{paid.label}</span>
149 <span className="font-mono tabular-nums text-success">{charge(paid.micros)}</span>
150 </li>
151 ))}
152 {(totals.carriedMicros ?? 0) > 0 && (
153 <li className="flex justify-between gap-4">
154 <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
155 <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
156 </li>
157 )}
158 </ul>
159 )}
160
161 <div className="mt-3">
162 {statement.groups.length === 0 ? (
163 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
164 Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
165 </EmptyState>
166 ) : (
167 <div className="overflow-hidden rounded-xl border border-line">
168 {statement.groups.map((g) => (
169 <div key={g.key} className="border-b border-line last:border-b-0">
170 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
171 <span className="grow truncate font-medium text-muted">
172 {group === "day" ? dayLabel(g.key) : g.label}
173 </span>
174 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
175 </div>
176 <ul className="divide-y divide-line">
177 {g.lines.map((line) => (
178 <StatementLineRow
179 key={line.kind}
180 slug={slug}
181 month={statement.month}
182 kind={line.kind}
183 count={line.count}
184 chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
185 coveredMicros={line.coveredMicros ?? 0}
186 day={group === "day" ? g.key : null}
187 project={group === "project" ? g.key : null}
188 />
189 ))}
190 {(g.discountMicros ?? 0) > 0 && (
191 <li className="flex items-center gap-3 px-4 py-3 text-sm">
192 <span className="w-3.5 shrink-0" />
193 <span className="grow truncate text-muted">{discountLabel}</span>
194 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-success">{charge(-(g.discountMicros ?? 0))}</span>
195 </li>
196 )}
197 </ul>
198 </div>
199 ))}
200 </div>
201 )}
202 </div>
203 </section>
204 );
205}
206
207function StatementLineRow({
208 slug,
209 month,
210 kind,
211 count,
212 chargedMicros,
213 coveredMicros,
214 day,
215 project,
216}: {
217 slug: string;
218 month: string;
219 kind: string;
220 count: number;
221 chargedMicros: number;
222 coveredMicros: number;
223 day: string | null;
224 project: string | null;
225}) {
226 const [open, setOpen] = useState(false);
227 const [entries, setEntries] = useState<LedgerEntry[]>([]);
228 const [done, setDone] = useState(false);
229 const fetcher = useFetcher<LedgerEntry[] | { error: string }>();
230 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
231 const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null;
232
233 // Each page that arrives is added once. A page can come again: the page
234 // reloads what fetchers loaded after every change made on it, so an entry
235 // already listed is not listed twice.
236 if (Array.isArray(fetcher.data) && fetcher.data !== seen) {
237 const page = fetcher.data;
238 setSeen(page);
239 setEntries((before) => {
240 const listed = new Set(before.map((entry) => entry.id));
241 return [...before, ...page.filter((entry) => !listed.has(entry.id))];
242 });
243 if (page.length < 50) setDone(true);
244 }
245
246 const load = (before: string | null) => {
247 const query = new URLSearchParams({ month, kind });
248 if (day) query.set("day", day);
249 if (project !== null) query.set("project", project);
250 if (before) query.set("before", before);
251 fetcher.load(`/${slug}/-/billing/entries?${query}`);
252 };
253
254 const toggle = () => {
255 if (!open && entries.length === 0) load(null);
256 setOpen(!open);
257 };
258 const moneyIn = chargedMicros < 0;
259
260 return (
261 <li>
262 <button
263 type="button"
264 onClick={toggle}
265 aria-expanded={open}
266 className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
267 >
268 <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
269 <span className="grow truncate font-medium">{kind}</span>
270 <span className="shrink-0 text-xs text-faint tabular-nums">
271 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
272 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
273 </span>
274 <span
275 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-success" : "text-fg"}`}
276 >
277 {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
278 </span>
279 </button>
280 {open && (
281 <div className="border-t border-line bg-bg/40 pb-2">
282 <ul className="divide-y divide-line/60">
283 {entries.map((entry) => (
284 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
285 <div className="min-w-0 grow">
286 {entry.repo && entry.number ? (
287 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
288 {entry.description}
289 </Link>
290 ) : (
291 <p className="truncate">{entry.description}</p>
292 )}
293 <p className="mt-0.5 text-xs text-faint">
294 <TimeAgo at={entry.createdAt} />
295 {entry.model && ` · ${entry.model}`}
296 {entry.createdBy && ` · ${entry.createdBy}`}
297 </p>
298 </div>
299 <span className="shrink-0 text-right font-mono text-xs tabular-nums text-muted">
300 {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(entryPrice(entry))}
301 {(entry.discountMicros ?? 0) > 0 && (
302 <span className="block text-[0.6875rem] text-success">{charge(-(entry.discountMicros ?? 0))} discount</span>
303 )}
304 </span>
305 </li>
306 ))}
307 </ul>
308 {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
309 {fetcher.state === "loading" && (
310 <div aria-busy="true" className="pl-8">
311 <SkeletonRows rows={3} rowClassName="h-12" />
312 </div>
313 )}
314 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
315 <button
316 type="button"
317 onClick={() => load(entries[entries.length - 1].id)}
318 className="mt-1 ml-11 text-xs text-muted hover:text-fg"
319 >
320 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
321 </button>
322 )}
323 </div>
324 )}
325 </li>
326 );
327}