Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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