80 lines2,983 bytesCodeBlame
1import { MICROS_PER_DOLLAR, type LedgerEntry } from "@g1t/contracts";
2import { data } from "react-router";
3
4import type { Route } from "./+types/statement-csv";
5import { billing } from "../../lib/services.server";
6import { getViewer } from "../../lib/session.server";
7
8/** Pages read per line at most: 50 entries each. */
9const MAX_PAGES = 200;
10
11/** A month's statement as CSV: every entry, with the line it falls under. */
12export async function loader({ params, context, request }: Route.LoaderArgs) {
13 const viewer = getViewer(context);
14 const month = new URL(request.url).searchParams.get("month");
15 const statement = await billing.statement(params.owner, viewer, month, "day");
16 if (!statement.ok) throw data(null, { status: 404 });
17 const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))];
18 const rows: string[][] = [
19 [
20 "date",
21 "kind",
22 "description",
23 "project",
24 "pull request",
25 "model",
26 "by",
27 "amount (USD)",
28 "paid by included usage (USD)",
29 "paid by trial credit (USD)",
30 "paid by open-source pool (USD)",
31 "covered by g1t (USD)",
32 ],
33 ];
34 for (const kind of kinds) {
35 let before: string | null = null;
36 for (let page = 0; page < MAX_PAGES; page++) {
37 const entries = await billing.statementEntries(params.owner, viewer, {
38 month: statement.value.month,
39 kind,
40 before,
41 });
42 if (!entries.ok || entries.value.length === 0) break;
43 for (const entry of entries.value) rows.push(row(kind, entry));
44 before = entries.value[entries.value.length - 1].id;
45 if (entries.value.length < 50) break;
46 }
47 }
48 const body = [rows[0], ...rows.slice(1).sort((a, b) => a[0].localeCompare(b[0]))].map((r) => r.map(cell).join(",")).join("\r\n");
49 return new Response(`${body}\r\n`, {
50 headers: {
51 "content-type": "text/csv; charset=utf-8",
52 "content-disposition": `attachment; filename="g1t-${params.owner.toLowerCase()}-${statement.value.month}.csv"`,
53 "cache-control": "no-store",
54 },
55 });
56}
57
58function row(kind: string, entry: LedgerEntry): string[] {
59 return [
60 entry.createdAt,
61 kind,
62 entry.description,
63 entry.repo ?? "",
64 entry.number ? String(entry.number) : "",
65 entry.model ?? "",
66 entry.createdBy ?? "",
67 // Charges positive, as on the statement.
68 (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6),
69 ((entry.creditMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
70 ((entry.trialMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
71 ((entry.ossMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
72 ((entry.givenMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
73 ];
74}
75
76/** A CSV cell, quoted when it must be, and never read as a formula. */
77function cell(value: string): string {
78 const safe = /^[=+\-@\t\r]/.test(value) && !/^-?\d/.test(value) ? `'${value}` : value;
79 return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
80}