Skip to content

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

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