Skip to content

g1t/apps/web/app/routes/workspace/statement-csv.ts

97 lines3,590 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 "price (USD)",
28 "discount (USD)",
29 "amount (USD)",
30 "paid by included usage (USD)",
31 "paid by trial credit (USD)",
32 "paid by open-source pool (USD)",
33 "covered by g1t (USD)",
34 ],
35 ];
36 for (const kind of kinds) {
37 let before: string | null = null;
38 for (let page = 0; page < MAX_PAGES; page++) {
39 const entries = await billing.statementEntries(params.owner, viewer, {
40 month: statement.value.month,
41 kind,
42 before,
43 });
44 if (!entries.ok || entries.value.length === 0) break;
45 for (const entry of entries.value) rows.push(row(kind, entry));
46 before = entries.value[entries.value.length - 1].id;
47 if (entries.value.length < 50) break;
48 }
49 }
50 const body = [rows[0], ...rows.slice(1).sort((a, b) => a[0].localeCompare(b[0]))].map((r) => r.map(cell).join(",")).join("\r\n");
51 return new Response(`${body}\r\n`, {
52 headers: {
53 "content-type": "text/csv; charset=utf-8",
54 "content-disposition": `attachment; filename="g1t-${params.owner.toLowerCase()}-${statement.value.month}.csv"`,
55 "cache-control": "no-store",
56 },
57 });
58}
59
60function row(kind: string, entry: LedgerEntry): string[] {
61 return [
62 entry.createdAt,
63 kind,
64 entry.description,
65 entry.repo ?? "",
66 entry.number ? String(entry.number) : "",
67 entry.model ?? "",
68 entry.createdBy ?? "",
69 // Usage at its price (empty for money in), then the discount off it.
70 entry.kind === "usage" ? (usagePrice(entry) / MICROS_PER_DOLLAR).toFixed(6) : "",
71 ((entry.discountMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
72 // Charges positive, as on the statement.
73 (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6),
74 ((entry.creditMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
75 ((entry.trialMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
76 ((entry.ossMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
77 ((entry.givenMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
78 ];
79}
80
81/** A usage entry at its price: charged, what paid for it first, and the discount. */
82function usagePrice(entry: LedgerEntry): number {
83 return (
84 -entry.amountMicros +
85 (entry.creditMicros ?? 0) +
86 (entry.trialMicros ?? 0) +
87 (entry.ossMicros ?? 0) +
88 (entry.givenMicros ?? 0) +
89 (entry.discountMicros ?? 0)
90 );
91}
92
93/** A CSV cell, quoted when it must be, and never read as a formula. */
94function cell(value: string): string {
95 const safe = /^[=+\-@\t\r]/.test(value) && !/^-?\d/.test(value) ? `'${value}` : value;
96 return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
97}