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.

g1t.sh
355 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)}
148 {(totals.cardFeeMicros ?? 0) !== 0 ? `, and card processing fees on earlier payments ${money(totals.cardFeeMicros ?? 0)}` : ""}. Prices
149 exclude tax, and it never comes from your balance.
150 </p>
151 )}
152 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
153 <Card asChild className="mt-2 space-y-1 px-4 py-3 text-sm">
154 <ul>
155 {totals.covered?.map((paid) => (
156 <li key={paid.source} className="flex justify-between gap-4">
157 <span className="text-muted">{paid.label}</span>
158 <span className="font-mono tabular-nums text-success">{charge(paid.micros)}</span>
159 </li>
160 ))}
161 {(totals.carriedMicros ?? 0) > 0 && (
162 <li className="flex justify-between gap-4">
163 <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
164 <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
165 </li>
166 )}
167 </ul>
168 </Card>
169 )}
170
171 <div className="mt-3">
172 {statement.groups.length === 0 ? (
173 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
174 Sandbox time, deployments and payments appear here, a line per kind each day.
175 </EmptyState>
176 ) : (
177 <Card tone="plain" className="overflow-hidden">
178 {statement.groups.map((g) => (
179 <div key={g.key} className="border-b border-line last:border-b-0">
180 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
181 <span className="grow truncate font-medium text-muted">
182 {group === "day" ? dayLabel(g.key) : g.label}
183 </span>
184 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
185 </div>
186 <ul className="divide-y divide-line">
187 {g.lines.map((line) =>
188 line.kind === "Tax" || line.kind === "Card processing fees" ? (
189 // Paid with the day's payments, on top of what reached the balance: never charged.
190 <li key={line.kind} className="flex items-center gap-3 px-4 py-3 text-sm">
191 <span className="w-3.5 shrink-0" />
192 <span className="grow truncate text-muted">
193 {line.kind} <span className="text-xs text-faint">· paid with the payment, not from the balance</span>
194 </span>
195 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-muted">{money(line.passedMicros ?? 0)}</span>
196 </li>
197 ) : (
198 <StatementLineRow
199 key={line.kind}
200 slug={slug}
201 month={statement.month}
202 kind={line.kind}
203 count={line.count}
204 minutes={line.minutes ?? null}
205 chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
206 coveredMicros={line.coveredMicros ?? 0}
207 day={group === "day" ? g.key : null}
208 project={group === "project" ? g.key : null}
209 />
210 ),
211 )}
212 {(g.discountMicros ?? 0) > 0 && (
213 <li className="flex items-center gap-3 px-4 py-3 text-sm">
214 <span className="w-3.5 shrink-0" />
215 <span className="grow truncate text-muted">{discountLabel}</span>
216 <span className="w-24 shrink-0 text-right font-mono tabular-nums text-success">{charge(-(g.discountMicros ?? 0))}</span>
217 </li>
218 )}
219 </ul>
220 </div>
221 ))}
222 </Card>
223 )}
224 </div>
225 </section>
226 );
227}
228
229function StatementLineRow({
230 slug,
231 month,
232 kind,
233 count,
234 minutes,
235 chargedMicros,
236 coveredMicros,
237 day,
238 project,
239}: {
240 slug: string;
241 month: string;
242 kind: string;
243 count: number;
244 /** Runner minutes on the line, as charged. */
245 minutes: number | null;
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 {minutes !== null && `${minutes.toLocaleString("en-US")} ${minutes === 1 ? "minute" : "minutes"} · `}
297 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
298 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
299 </span>
300 <span
301 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-success" : "text-fg"}`}
302 >
303 {moneyIn ? `+${money(-chargedMicros)}` : charge(chargedMicros)}
304 </span>
305 </button>
306 {open && (
307 <div className="border-t border-line bg-bg/40 pb-2">
308 <ul className="divide-y divide-line/60">
309 {entries.map((entry) => (
310 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
311 <div className="min-w-0 grow">
312 {entry.repo && entry.number ? (
313 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
314 {entry.description}
315 </Link>
316 ) : (
317 <p className="truncate">{entry.description}</p>
318 )}
319 <p className="mt-0.5 text-xs text-faint">
320 <TimeAgo at={entry.createdAt} />
321 {entry.model && ` · ${entry.model}`}
322 {entry.createdBy && ` · ${entry.createdBy}`}
323 </p>
324 </div>
325 <span className="shrink-0 text-right font-mono text-xs tabular-nums text-muted">
326 {entry.amountMicros > 0 ? `+${money(entry.amountMicros)}` : charge(entryPrice(entry))}
327 {(entry.discountMicros ?? 0) > 0 && (
328 <span className="block text-[0.6875rem] text-success">{charge(-(entry.discountMicros ?? 0))} discount</span>
329 )}
330 </span>
331 </li>
332 ))}
333 </ul>
334 {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
335 {fetcher.state === "loading" && (
336 <div aria-busy="true" className="pl-8">
337 <SkeletonRows rows={3} rowClassName="h-12" />
338 </div>
339 )}
340 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
341 <Button
342 type="button"
343 onClick={() => load(entries[entries.length - 1].id)}
344 variant="link"
345 size="inline"
346 className="mt-1 ml-11 text-xs text-muted hover:text-fg font-normal"
347 >
348 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
349 </Button>
350 )}
351 </div>
352 )}
353 </li>
354 );
355}