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.
11 files+677−480/11 viewed
| 325 | 325 | ||
| 326 | 326 | ## The statement | |
| 327 | 327 | ||
| 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. | |
| 331 | 354 | ||
| 332 | 355 | Each pull request's session also ends with what its run cost before the | |
| 333 | 356 | margin. |
| 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 | + | } |
| 25 | 25 | route("-/tokens", "routes/workspace/tokens.tsx"), | |
| 26 | 26 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 27 | 27 | 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"), | |
| 28 | 30 | route("-/integrations", "routes/workspace/integrations.tsx"), | |
| 29 | 31 | route("-/webhooks", "routes/workspace/webhooks.tsx"), | |
| 30 | 32 | route("-/secrets", "routes/workspace/secrets.tsx"), |
| 13 | 13 | ||
| 14 | 14 | import type { Route } from "./+types/billing"; | |
| 15 | 15 | import { page } from "../../lib/meta"; | |
| 16 | + | import { StatementView } from "../../components/statement"; | |
| 16 | 17 | import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group"; | |
| 17 | − | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 18 | + | import { Button, ErrorText } from "../../components/ui"; | |
| 18 | 19 | import { billing, deployments } from "../../lib/services.server"; | |
| 19 | 20 | import { | |
| 20 | 21 | assertSameOrigin, | |
| 50 | 51 | await billing.confirm(slug, viewer, session); | |
| 51 | 52 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 52 | 53 | } | |
| 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([ | |
| 54 | 56 | billing.account(slug, viewer), | |
| 55 | − | billing.ledger(slug, viewer), | |
| 57 | + | billing.statement(slug, viewer, url.searchParams.get("month"), group), | |
| 56 | 58 | billing.features(slug, viewer), | |
| 57 | 59 | deployments.usage(slug, viewer), | |
| 58 | 60 | billing.limit(slug, viewer), | |
| 62 | 64 | slug, | |
| 63 | 65 | role, | |
| 64 | 66 | account: unwrap(account), | |
| 65 | − | ledger: unwrap(ledger), | |
| 67 | + | statement: unwrap(statement), | |
| 68 | + | group, | |
| 66 | 69 | features: unwrap(features), | |
| 67 | 70 | deployUsage: deployUsage.ok ? deployUsage.value : null, | |
| 68 | 71 | limit: limit.ok ? limit.value : null, | |
| 128 | 131 | } | |
| 129 | 132 | ||
| 130 | 133 | 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; | |
| 132 | 135 | const { status } = account; | |
| 133 | 136 | const paying = useNavigation().state === "submitting"; | |
| 134 | 137 | const empty = account.balanceMicros <= 0; | |
| 314 | 317 | )} | |
| 315 | 318 | </div> | |
| 316 | 319 | ||
| 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} /> | |
| 357 | 321 | </div> | |
| 358 | 322 | ||
| 359 | 323 | <aside className="space-y-5 text-sm"> |
| 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 | + | } |
| 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 | + | } |
| 786 | 786 | pub workspace: String, | |
| 787 | 787 | } | |
| 788 | 788 | ||
| 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 | + | ||
| 789 | 862 | // --- Sales (sudo.g1t.sh) ------------------------------------------------------ | |
| 790 | 863 | // | |
| 791 | 864 | // What staff need to know to reach out: who is growing, who is close to |
| 152 | 152 | createdAt: string; | |
| 153 | 153 | }; | |
| 154 | 154 | ||
| 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 | + | ||
| 155 | 168 | export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number }; | |
| 156 | 169 | ||
| 157 | 170 | export type SignalKind = "at_limit" | "near_ceiling" | "declined" | "growing" | "established" | "first_payment" | "high_spend"; | |
| 387 | 400 | account(workspace: string, viewer: Viewer): Promise<Result<BillingAccount>>; | |
| 388 | 401 | /** Newest first. Members of the workspace only. */ | |
| 389 | 402 | 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[]>>; | |
| 390 | 411 | /** What the workspace's agents cost since `since`, broken down. Members only. */ | |
| 391 | 412 | usage(workspace: string, viewer: Viewer, since: string): Promise<Result<Usage>>; | |
| 392 | 413 | /** |
| 224 | 224 | status: () => call("status", {}), | |
| 225 | 225 | account: (workspace, viewer) => call("account", { workspace, viewer }), | |
| 226 | 226 | 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 | + | }), | |
| 227 | 238 | usage: (workspace, viewer, since) => call("usage", { workspace, viewer, since }), | |
| 228 | 239 | checkout: (actor, workspace, amountCents, returnUrl) => | |
| 229 | 240 | call("checkout", { actor, workspace, amountCents, returnUrl }), |
| 19 | 19 | mod accounts; | |
| 20 | 20 | mod invoices; | |
| 21 | 21 | mod sales; | |
| 22 | + | mod statement; | |
| 22 | 23 | mod webhooks; | |
| 23 | 24 | mod features; | |
| 24 | 25 | mod keeper; | |
| 1033 | 1034 | "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?), | |
| 1034 | 1035 | "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?), | |
| 1035 | 1036 | "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?), | |
| 1036 | 1039 | "admin_workspace_invoices" => { | |
| 1037 | 1040 | let a: AdminWorkspaceInvoicesArgs = args(body)?; | |
| 1038 | 1041 | reply(&billing.workspace_invoices(&a.workspace.to_lowercase()).await?) |
| 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 | + | } |