flagon-io/g1t

public

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

Commit

The statement is a month at a time, a line per kind of charge

Billing gains statement (a month of the ledger grouped by day or by project, one line per kind: agent runs, own-provider runs, sandbox time, deployments, payments, credits, refunds, with counts and totals) and statement_entries (one line's entries, 50 at a time). The Billing page shows the month's totals, a month picker, By day / By project, lines that open to their entries with Show more, and a CSV of the month.

syntaqxcommitted Parent916db46Browse files
11 files+677−480/11 viewed
+26−3
325325
326326 ## The statement
327327
328−The **Billing** page lists the workspace's balance and its statement:
329−every payment and every run, newest first, up to the latest 100. Each run names its kind of work
330−and links to the pull request it was for. Every member can see it.
328+The **Billing** page ends with the workspace's statement, a month at a
329+time. Every member can see it.
330+
331+- **Totals.** What the month charged, what was paid and credited, and how
332+ many entries make it up.
333+- **Grouped by day or by project.** Each day (or project) has one line
334+ per kind of charge, with how many entries it holds and what they come
335+ to, rather than a row for every run:
336+
337+ | Line | What it holds |
338+ | --- | --- |
339+ | Agent runs | Runs on g1t's model provider: the model's cost plus the margin. |
340+ | Runs on your own model provider | The run fee for runs billed by your own provider. |
341+ | Sandbox time | Each sandbox's time, memory and disk. |
342+ | Deployments | Builds and apps beyond the allowance. |
343+ | Payments | Card payments and invoices paid. |
344+ | Credits from g1t | Credit g1t added, such as a goodwill credit. |
345+ | Refunds | Money given back to your card. |
346+
347+- **Open a line** to see its entries, 50 at a time, newest first. Each
348+ run links to the pull request it was for.
349+- **Pick a month** to see an earlier one; months with no entries are not
350+ listed.
351+- **CSV** downloads every entry of the month, with the line it falls
352+ under, for your own books. Invoices from Stripe remain the record for
353+ what was charged to your card.
331354
332355 Each pull request's session also ends with what its run cost before the
333356 margin.
+248−0
1+import { ChevronRight, Download } from "lucide-react";
2+import { useState } from "react";
3+import { Link, useFetcher, useNavigate } from "react-router";
4+
5+import { MICROS_PER_DOLLAR, type LedgerEntry, type Statement } from "@g1t/contracts";
6+
7+import { EmptyState, TimeAgo } from "./ui";
8+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
9+import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
10+
11+/** Millionths of a dollar as dollars, to the cent or finer. */
12+function dollars(micros: number, digits = 2): string {
13+ const sign = micros < 0 ? "−" : "";
14+ return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
15+}
16+
17+/** Charges to the cent, but a fraction of a cent shown as such rather than $0.00. */
18+function charge(micros: number): string {
19+ return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
20+}
21+
22+function monthLabel(month: string): string {
23+ const [year, number] = month.split("-").map(Number);
24+ return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
25+ month: "long",
26+ year: "numeric",
27+ timeZone: "UTC",
28+ });
29+}
30+
31+function dayLabel(day: string): string {
32+ return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
33+ weekday: "short",
34+ month: "short",
35+ day: "numeric",
36+ timeZone: "UTC",
37+ });
38+}
39+
40+/**
41+ * A month of the workspace's ledger: totals, then a group per day (or per
42+ * project) with one line per kind of charge. A line opens to its entries,
43+ * 50 at a time.
44+ */
45+export function StatementView({
46+ slug,
47+ statement,
48+ group,
49+}: {
50+ slug: string;
51+ statement: Statement;
52+ group: "day" | "project";
53+}) {
54+ const navigate = useNavigate();
55+ const go = (month: string, by: string) =>
56+ navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
57+ const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
58+ const { totals } = statement;
59+
60+ return (
61+ <section id="statement" className="mt-10 scroll-mt-20">
62+ <div className="flex flex-wrap items-center gap-3">
63+ <h3 className="grow text-sm font-medium text-muted">Statement</h3>
64+ <Select value={statement.month} onValueChange={(month) => go(month, group)}>
65+ <SelectTrigger size="sm" className="w-40" aria-label="Month">
66+ <SelectValue />
67+ </SelectTrigger>
68+ <SelectContent>
69+ {months.map((month) => (
70+ <SelectItem key={month} value={month}>
71+ {monthLabel(month)}
72+ </SelectItem>
73+ ))}
74+ </SelectContent>
75+ </Select>
76+ <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
77+ <TabsList>
78+ <TabsTrigger value="day">By day</TabsTrigger>
79+ <TabsTrigger value="project">By project</TabsTrigger>
80+ </TabsList>
81+ </Tabs>
82+ {totals.entries > 0 && (
83+ <a
84+ href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
85+ 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"
86+ download
87+ >
88+ <Download size={13} /> CSV
89+ </a>
90+ )}
91+ </div>
92+
93+ <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm">
94+ <div className="px-4 py-3">
95+ <dt className="text-xs text-faint">Charged</dt>
96+ <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
97+ </div>
98+ <div className="px-4 py-3">
99+ <dt className="text-xs text-faint">Paid and credited</dt>
100+ <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
101+ </div>
102+ <div className="px-4 py-3">
103+ <dt className="text-xs text-faint">Entries</dt>
104+ <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
105+ </div>
106+ </dl>
107+
108+ <div className="mt-3">
109+ {statement.groups.length === 0 ? (
110+ <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
111+ Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
112+ </EmptyState>
113+ ) : (
114+ <div className="overflow-hidden rounded-xl border border-line">
115+ {statement.groups.map((g) => (
116+ <div key={g.key} className="border-b border-line last:border-b-0">
117+ <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
118+ <span className="grow truncate font-medium text-muted">
119+ {group === "day" ? dayLabel(g.key) : g.label}
120+ </span>
121+ <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
122+ </div>
123+ <ul className="divide-y divide-line">
124+ {g.lines.map((line) => (
125+ <StatementLineRow
126+ key={line.kind}
127+ slug={slug}
128+ month={statement.month}
129+ kind={line.kind}
130+ count={line.count}
131+ chargedMicros={line.chargedMicros}
132+ day={group === "day" ? g.key : null}
133+ project={group === "project" ? g.key : null}
134+ />
135+ ))}
136+ </ul>
137+ </div>
138+ ))}
139+ </div>
140+ )}
141+ </div>
142+ </section>
143+ );
144+}
145+
146+function StatementLineRow({
147+ slug,
148+ month,
149+ kind,
150+ count,
151+ chargedMicros,
152+ day,
153+ project,
154+}: {
155+ slug: string;
156+ month: string;
157+ kind: string;
158+ count: number;
159+ chargedMicros: number;
160+ day: string | null;
161+ project: string | null;
162+}) {
163+ const [open, setOpen] = useState(false);
164+ const [entries, setEntries] = useState<LedgerEntry[]>([]);
165+ const [done, setDone] = useState(false);
166+ const fetcher = useFetcher<LedgerEntry[]>();
167+ const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
168+
169+ // Each page that arrives is added once.
170+ if (fetcher.data && fetcher.data !== seen) {
171+ setSeen(fetcher.data);
172+ setEntries((before) => [...before, ...fetcher.data!]);
173+ if (fetcher.data.length < 50) setDone(true);
174+ }
175+
176+ const load = (before: string | null) => {
177+ const query = new URLSearchParams({ month, kind });
178+ if (day) query.set("day", day);
179+ if (project !== null) query.set("project", project);
180+ if (before) query.set("before", before);
181+ fetcher.load(`/${slug}/-/billing/entries?${query}`);
182+ };
183+
184+ const toggle = () => {
185+ if (!open && entries.length === 0) load(null);
186+ setOpen(!open);
187+ };
188+ const moneyIn = chargedMicros < 0;
189+
190+ return (
191+ <li>
192+ <button
193+ type="button"
194+ onClick={toggle}
195+ aria-expanded={open}
196+ className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
197+ >
198+ <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
199+ <span className="grow truncate font-medium">{kind}</span>
200+ <span className="shrink-0 text-xs text-faint tabular-nums">
201+ {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
202+ </span>
203+ <span
204+ className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`}
205+ >
206+ {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
207+ </span>
208+ </button>
209+ {open && (
210+ <div className="border-t border-line bg-bg/40 pb-2">
211+ <ul className="divide-y divide-line/60">
212+ {entries.map((entry) => (
213+ <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
214+ <div className="min-w-0 grow">
215+ {entry.repo && entry.number ? (
216+ <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
217+ {entry.description}
218+ </Link>
219+ ) : (
220+ <p className="truncate">{entry.description}</p>
221+ )}
222+ <p className="mt-0.5 text-xs text-faint">
223+ <TimeAgo at={entry.createdAt} />
224+ {entry.model && ` · ${entry.model}`}
225+ {entry.createdBy && ` · ${entry.createdBy}`}
226+ </p>
227+ </div>
228+ <span className="shrink-0 font-mono text-xs tabular-nums text-muted">
229+ {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(-entry.amountMicros)}
230+ </span>
231+ </li>
232+ ))}
233+ </ul>
234+ {fetcher.state === "loading" && <p className="py-2 pl-11 text-xs text-faint">Loading…</p>}
235+ {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
236+ <button
237+ type="button"
238+ onClick={() => load(entries[entries.length - 1].id)}
239+ className="mt-1 ml-11 text-xs text-muted hover:text-fg"
240+ >
241+ Show {Math.min(50, count - entries.length)} more of {count - entries.length}
242+ </button>
243+ )}
244+ </div>
245+ )}
246+ </li>
247+ );
248+}
+2−0
2525 route("-/tokens", "routes/workspace/tokens.tsx"),
2626 route("-/usage", "routes/workspace/usage.tsx"),
2727 route("-/billing", "routes/workspace/billing.tsx"),
28+ route("-/billing/entries", "routes/workspace/statement-entries.ts"),
29+ route("-/billing/statement.csv", "routes/workspace/statement-csv.ts"),
2830 route("-/integrations", "routes/workspace/integrations.tsx"),
2931 route("-/webhooks", "routes/workspace/webhooks.tsx"),
3032 route("-/secrets", "routes/workspace/secrets.tsx"),
+9−45
1313
1414 import type { Route } from "./+types/billing";
1515 import { page } from "../../lib/meta";
16+import { StatementView } from "../../components/statement";
1617 import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
17−import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
18+import { Button, ErrorText } from "../../components/ui";
1819 import { billing, deployments } from "../../lib/services.server";
1920 import {
2021 assertSameOrigin,
5051 await billing.confirm(slug, viewer, session);
5152 throw redirect(`/${slug}/-/billing?added=1`);
5253 }
53− const [account, ledger, features, deployUsage, limit, invoices] = await Promise.all([
54+ const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
55+ const [account, statement, features, deployUsage, limit, invoices] = await Promise.all([
5456 billing.account(slug, viewer),
55− billing.ledger(slug, viewer),
57+ billing.statement(slug, viewer, url.searchParams.get("month"), group),
5658 billing.features(slug, viewer),
5759 deployments.usage(slug, viewer),
5860 billing.limit(slug, viewer),
6264 slug,
6365 role,
6466 account: unwrap(account),
65− ledger: unwrap(ledger),
67+ statement: unwrap(statement),
68+ group,
6669 features: unwrap(features),
6770 deployUsage: deployUsage.ok ? deployUsage.value : null,
6871 limit: limit.ok ? limit.value : null,
128131 }
129132
130133 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
131− const { slug, role, account, ledger, features, deployUsage, limit, invoices, added, subscribed } = loaderData;
134+ const { slug, role, account, statement, group, features, deployUsage, limit, invoices, added, subscribed } = loaderData;
132135 const { status } = account;
133136 const paying = useNavigation().state === "submitting";
134137 const empty = account.balanceMicros <= 0;
314317 )}
315318 </div>
316319
317− <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
318− <div className="mt-3">
319− {ledger.length === 0 ? (
320− <EmptyState title="Nothing yet">
321− Each agent run and each payment appears here.
322− </EmptyState>
323− ) : (
324− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
325− {ledger.map((entry) => (
326− <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
327− <div className="min-w-0 grow">
328− {entry.repo && entry.number ? (
329− <Link
330− to={`/${entry.repo}/pull/${entry.number}`}
331− className="block truncate font-medium hover:underline"
332− >
333− {entry.description}
334− </Link>
335− ) : (
336− <p className="truncate font-medium">{entry.description}</p>
337− )}
338− <p className="mt-0.5 text-xs text-faint">
339− <TimeAgo at={entry.createdAt} />
340− {entry.model && ` · ${entry.model}`}
341− {entry.createdBy && ` · ${entry.createdBy}`}
342− </p>
343− </div>
344− <span
345− className={`shrink-0 font-mono text-sm tabular-nums ${
346− entry.amountMicros > 0 ? "text-accent" : "text-muted"
347− }`}
348− >
349− {entry.amountMicros > 0 && "+"}
350− {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
351− </span>
352− </li>
353− ))}
354− </ul>
355− )}
356− </div>
320+ <StatementView slug={slug} statement={statement} group={group} />
357321 </div>
358322
359323 <aside className="space-y-5 text-sm">
+61−0
1+import { MICROS_PER_DOLLAR, type LedgerEntry } from "@g1t/contracts";
2+import { data } from "react-router";
3+
4+import type { Route } from "./+types/statement-csv";
5+import { billing } from "../../lib/services.server";
6+import { getViewer } from "../../lib/session.server";
7+
8+/** Pages read per line at most: 50 entries each. */
9+const MAX_PAGES = 200;
10+
11+/** A month's statement as CSV: every entry, with the line it falls under. */
12+export 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[][] = [["date", "kind", "description", "project", "pull request", "model", "by", "amount (USD)"]];
19+ for (const kind of kinds) {
20+ let before: string | null = null;
21+ for (let page = 0; page < MAX_PAGES; page++) {
22+ const entries = await billing.statementEntries(params.owner, viewer, {
23+ month: statement.value.month,
24+ kind,
25+ before,
26+ });
27+ if (!entries.ok || entries.value.length === 0) break;
28+ for (const entry of entries.value) rows.push(row(kind, entry));
29+ before = entries.value[entries.value.length - 1].id;
30+ if (entries.value.length < 50) break;
31+ }
32+ }
33+ const body = [rows[0], ...rows.slice(1).sort((a, b) => a[0].localeCompare(b[0]))].map((r) => r.map(cell).join(",")).join("\r\n");
34+ return new Response(`${body}\r\n`, {
35+ headers: {
36+ "content-type": "text/csv; charset=utf-8",
37+ "content-disposition": `attachment; filename="g1t-${params.owner.toLowerCase()}-${statement.value.month}.csv"`,
38+ "cache-control": "no-store",
39+ },
40+ });
41+}
42+
43+function row(kind: string, entry: LedgerEntry): string[] {
44+ return [
45+ entry.createdAt,
46+ kind,
47+ entry.description,
48+ entry.repo ?? "",
49+ entry.number ? String(entry.number) : "",
50+ entry.model ?? "",
51+ entry.createdBy ?? "",
52+ // Charges positive, as on the statement.
53+ (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6),
54+ ];
55+}
56+
57+/** A CSV cell, quoted when it must be, and never read as a formula. */
58+function cell(value: string): string {
59+ const safe = /^[=+\-@\t\r]/.test(value) && !/^-?\d/.test(value) ? `'${value}` : value;
60+ return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
61+}
+19−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/statement-entries";
4+import { billing } from "../../lib/services.server";
5+import { getViewer } from "../../lib/session.server";
6+
7+/** One statement line's entries, 50 at a time, for the billing page to open. */
8+export async function loader({ params, context, request }: Route.LoaderArgs) {
9+ const query = new URL(request.url).searchParams;
10+ const entries = await billing.statementEntries(params.owner, getViewer(context), {
11+ month: query.get("month") ?? "",
12+ kind: query.get("kind") ?? "",
13+ day: query.get("day"),
14+ project: query.get("project"),
15+ before: query.get("before"),
16+ });
17+ if (!entries.ok) throw data({ error: entries.error.message }, { status: 404 });
18+ return Response.json(entries.value, { headers: { "cache-control": "no-store" } });
19+}
+73−0
786786 pub workspace: String,
787787 }
788788
789+/// `statement`: a month of a workspace's ledger, grouped by day (or by
790+/// project) with a line per kind of charge. Members only. Returns
791+/// `Outcome<Statement>`.
792+#[derive(Debug, Serialize, Deserialize)]
793+pub struct StatementArgs {
794+ pub workspace: String,
795+ pub viewer: Viewer,
796+ /// YYYY-MM; this month when absent.
797+ #[serde(default)]
798+ pub month: Option<String>,
799+ /// `day` (the default) or `project`.
800+ #[serde(default)]
801+ pub group: Option<String>,
802+}
803+
804+#[derive(Clone, Debug, Serialize, Deserialize)]
805+#[serde(rename_all = "camelCase")]
806+pub struct Statement {
807+ pub month: String,
808+ /// Months with any entries, newest first.
809+ pub months: Vec<String>,
810+ pub groups: Vec<StatementGroup>,
811+ pub totals: StatementTotals,
812+}
813+
814+#[derive(Clone, Debug, Serialize, Deserialize)]
815+#[serde(rename_all = "camelCase")]
816+pub struct StatementGroup {
817+ /// The day (YYYY-MM-DD) or the project (`owner/name`, or empty).
818+ pub key: String,
819+ pub label: String,
820+ pub lines: Vec<StatementLine>,
821+ /// What the group's charges come to.
822+ pub charged_micros: i64,
823+}
824+
825+#[derive(Clone, Debug, Serialize, Deserialize)]
826+#[serde(rename_all = "camelCase")]
827+pub struct StatementLine {
828+ /// Agent runs, Sandbox time, Deployments, Payments, Credits from g1t,
829+ /// Refunds, Runs on your own model provider.
830+ pub kind: String,
831+ pub count: u32,
832+ /// Charges positive; money in (payments, credits) negative.
833+ pub charged_micros: i64,
834+ pub cost_micros: i64,
835+}
836+
837+#[derive(Clone, Debug, Serialize, Deserialize)]
838+#[serde(rename_all = "camelCase")]
839+pub struct StatementTotals {
840+ pub charged_micros: i64,
841+ pub paid_micros: i64,
842+ pub cost_micros: i64,
843+ pub entries: u32,
844+}
845+
846+/// `statement_entries`: one statement line's entries, newest first, 50 at
847+/// a time (`before` = the last id seen). Returns `Outcome<Vec<LedgerEntry>>`.
848+#[derive(Debug, Serialize, Deserialize)]
849+pub struct StatementEntriesArgs {
850+ pub workspace: String,
851+ pub viewer: Viewer,
852+ pub month: String,
853+ pub kind: String,
854+ #[serde(default)]
855+ pub day: Option<String>,
856+ #[serde(default)]
857+ pub project: Option<String>,
858+ #[serde(default)]
859+ pub before: Option<String>,
860+}
861+
789862 // --- Sales (sudo.g1t.sh) ------------------------------------------------------
790863 //
791864 // What staff need to know to reach out: who is growing, who is close to
+21−0
152152 createdAt: string;
153153 };
154154
155+/** A month of the ledger, grouped by day or project, a line per kind of charge. */
156+export type Statement = {
157+ month: string;
158+ months: string[];
159+ groups: {
160+ key: string;
161+ label: string;
162+ lines: { kind: string; count: number; chargedMicros: number; costMicros: number }[];
163+ chargedMicros: number;
164+ }[];
165+ totals: { chargedMicros: number; paidMicros: number; costMicros: number; entries: number };
166+};
167+
155168 export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number };
156169
157170 export type SignalKind = "at_limit" | "near_ceiling" | "declined" | "growing" | "established" | "first_payment" | "high_spend";
387400 account(workspace: string, viewer: Viewer): Promise<Result<BillingAccount>>;
388401 /** Newest first. Members of the workspace only. */
389402 ledger(workspace: string, viewer: Viewer): Promise<Result<LedgerEntry[]>>;
403+ /** A month of the ledger, grouped by `day` (default) or `project`. Members only. */
404+ statement(workspace: string, viewer: Viewer, month?: string | null, group?: "day" | "project"): Promise<Result<Statement>>;
405+ /** One statement line's entries, 50 at a time; `before` is the last id seen. */
406+ statementEntries(
407+ workspace: string,
408+ viewer: Viewer,
409+ filter: { month: string; kind: string; day?: string | null; project?: string | null; before?: string | null },
410+ ): Promise<Result<LedgerEntry[]>>;
390411 /** What the workspace's agents cost since `since`, broken down. Members only. */
391412 usage(workspace: string, viewer: Viewer, since: string): Promise<Result<Usage>>;
392413 /**
+11−0
224224 status: () => call("status", {}),
225225 account: (workspace, viewer) => call("account", { workspace, viewer }),
226226 ledger: (workspace, viewer) => call("ledger", { workspace, viewer }),
227+ statement: (workspace, viewer, month = null, group = "day") => call("statement", { workspace, viewer, month, group }),
228+ statementEntries: (workspace, viewer, filter) =>
229+ call("statement_entries", {
230+ workspace,
231+ viewer,
232+ month: filter.month,
233+ kind: filter.kind,
234+ day: filter.day ?? null,
235+ project: filter.project ?? null,
236+ before: filter.before ?? null,
237+ }),
227238 usage: (workspace, viewer, since) => call("usage", { workspace, viewer, since }),
228239 checkout: (actor, workspace, amountCents, returnUrl) =>
229240 call("checkout", { actor, workspace, amountCents, returnUrl }),
+3−0
1919 mod accounts;
2020 mod invoices;
2121 mod sales;
22+mod statement;
2223 mod webhooks;
2324 mod features;
2425 mod keeper;
10331034 "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?),
10341035 "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?),
10351036 "invoices" => reply(&billing.invoices(args(body)?).await?),
1037+ "statement" => reply(&billing.statement(args(body)?).await?),
1038+ "statement_entries" => reply(&billing.statement_entries(args(body)?).await?),
10361039 "admin_workspace_invoices" => {
10371040 let a: AdminWorkspaceInvoicesArgs = args(body)?;
10381041 reply(&billing.workspace_invoices(&a.workspace.to_lowercase()).await?)
+204−0
1+//! A workspace's statement: a month of its ledger, grouped so it can be
2+//! read at a glance (by day or by project, a line per kind of charge),
3+//! with each line's entries a page at a time.
4+
5+use g1t_contracts::billing::{
6+ LedgerEntry, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
7+};
8+use g1t_contracts::time::rfc3339;
9+use g1t_contracts::Outcome;
10+use g1t_kit::now_ms;
11+use serde::Deserialize;
12+use worker::Result;
13+use worker::wasm_bindgen::JsValue;
14+
15+use crate::{Billing, LedgerRow, members_only};
16+
17+/// Entries in one page of a line.
18+const PAGE: u32 = 50;
19+
20+/// The kind of charge a ledger row is, as the statement names it. One SQL
21+/// expression, so grouping and filtering agree.
22+pub(crate) const KIND_SQL: &str = "CASE
23+ WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t'
24+ WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds'
25+ WHEN kind = 'top_up' THEN 'Payments'
26+ WHEN task = 'sandbox' THEN 'Sandbox time'
27+ WHEN task = 'deployments' THEN 'Deployments'
28+ WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
29+ ELSE 'Agent runs' END";
30+
31+/// The order lines appear in within a group.
32+pub(crate) fn kind_order(kind: &str) -> u8 {
33+ match kind {
34+ "Agent runs" => 0,
35+ "Runs on your own model provider" => 1,
36+ "Sandbox time" => 2,
37+ "Deployments" => 3,
38+ "Payments" => 4,
39+ "Credits from g1t" => 5,
40+ "Refunds" => 6,
41+ _ => 7,
42+ }
43+}
44+
45+/// `2026-10` and the first instant of the next month, for a range.
46+pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
47+ let year: i32 = month.get(..4)?.parse().ok()?;
48+ let number: u32 = month.get(5..7)?.parse().ok()?;
49+ if month.len() != 7 || !(1..=12).contains(&number) {
50+ return None;
51+ }
52+ let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
53+ Some((format!("{month}-01"), format!("{next}-01")))
54+}
55+
56+#[derive(Deserialize)]
57+struct Row {
58+ group_key: Option<String>,
59+ kind: String,
60+ count: u32,
61+ amount: Option<i64>,
62+ cost: Option<i64>,
63+}
64+
65+#[derive(Deserialize)]
66+struct Month {
67+ month: String,
68+}
69+
70+impl Billing {
71+ pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
72+ let workspace = a.workspace.to_lowercase();
73+ if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
74+ return Ok(members_only());
75+ }
76+ let current = rfc3339(now_ms())[..7].to_owned();
77+ let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
78+ let (from, until) = month_range(&month).expect("a valid month");
79+ let by_project = a.group.as_deref() == Some("project");
80+ let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
81+ let rows = self
82+ .db
83+ .prepare(format!(
84+ "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
85+ SUM(amount_micros) AS amount, SUM(cost_micros) AS cost
86+ FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
87+ GROUP BY 1, 2"
88+ ))
89+ .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
90+ .all()
91+ .await?
92+ .results::<Row>()?;
93+
94+ let mut groups: Vec<StatementGroup> = vec![];
95+ for row in rows {
96+ let key = row.group_key.unwrap_or_default();
97+ let amount = row.amount.unwrap_or(0);
98+ let line = StatementLine {
99+ kind: row.kind.clone(),
100+ count: row.count,
101+ // Charges positive, money in negative, as a statement reads.
102+ charged_micros: -amount,
103+ cost_micros: row.cost.unwrap_or(0),
104+ };
105+ match groups.iter_mut().find(|g| g.key == key) {
106+ Some(group) => group.lines.push(line),
107+ None => groups.push(StatementGroup {
108+ label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
109+ key,
110+ lines: vec![line],
111+ charged_micros: 0,
112+ }),
113+ }
114+ }
115+ for group in &mut groups {
116+ group.lines.sort_by_key(|line| kind_order(&line.kind));
117+ group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
118+ }
119+ if by_project {
120+ groups.sort_by(|a, b| b.charged_micros.cmp(&a.charged_micros));
121+ } else {
122+ groups.sort_by(|a, b| b.key.cmp(&a.key));
123+ }
124+ let lines = groups.iter().flat_map(|g| g.lines.iter());
125+ let totals = StatementTotals {
126+ charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
127+ paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
128+ cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
129+ entries: lines.map(|l| l.count).sum(),
130+ };
131+ let months = self
132+ .db
133+ .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
134+ .bind(&[workspace.as_str().into()])?
135+ .all()
136+ .await?
137+ .results::<Month>()?
138+ .into_iter()
139+ .map(|m| m.month)
140+ .collect();
141+ Ok(Outcome::Ok(Statement { month, months, groups, totals }))
142+ }
143+
144+ /// One line's entries, newest first, a page at a time.
145+ pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
146+ let workspace = a.workspace.to_lowercase();
147+ if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
148+ return Ok(members_only());
149+ }
150+ let Some((from, until)) = month_range(&a.month) else {
151+ return Ok(Outcome::Ok(vec![]));
152+ };
153+ let mut filters = vec![format!("({KIND_SQL}) = ?4")];
154+ let mut values: Vec<JsValue> = vec![
155+ workspace.as_str().into(),
156+ from.as_str().into(),
157+ until.as_str().into(),
158+ a.kind.as_str().into(),
159+ ];
160+ if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
161+ values.push(day.into());
162+ filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
163+ }
164+ if let Some(project) = &a.project {
165+ values.push(project.as_str().into());
166+ filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
167+ }
168+ if let Some(before) = &a.before {
169+ values.push(before.as_str().into());
170+ filters.push(format!("id < ?{}", values.len()));
171+ }
172+ let rows = self
173+ .db
174+ .prepare(format!(
175+ "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
176+ ORDER BY id DESC LIMIT {PAGE}",
177+ filters.join(" AND ")
178+ ))
179+ .bind(&values)?
180+ .all()
181+ .await?
182+ .results::<LedgerRow>()?;
183+ Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
184+ }
185+}
186+
187+#[cfg(test)]
188+mod tests {
189+ use super::*;
190+
191+ #[test]
192+ fn a_month_runs_to_the_first_of_the_next() {
193+ assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
194+ assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
195+ assert_eq!(month_range("2026-13"), None);
196+ assert_eq!(month_range("oops"), None);
197+ }
198+
199+ #[test]
200+ fn usage_lines_come_before_money_in() {
201+ assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
202+ assert!(kind_order("Deployments") < kind_order("Payments"));
203+ }
204+}