Commit 759dddb
authored
feat(billing): dedicated Credit usage page with date-range filter and CSV export (#5405)
* fix(billing): apportion per-row credit costs so they sum to the page total
Cursor Bugbot (medium): each row rounded its own dollar cost to
credits independently while the header total rounded the summed
dollars once — over enough rows those two roundings can visibly
disagree, the exact "line items don't add up to the total" class of
bug apportionCredits was already built to prevent (used by the trace
view / cost breakdown). Route now apportions each page's row credits
against that page's dollar sum instead of rounding rows independently.
Added a test with three sub-cent rows that would each independently
round to 0 credits (but sum to 1) to prove the reconciliation holds.
* fix(billing): dim stale credit usage rows while a new period loads
Cursor Bugbot (medium): keepPreviousData kept the prior period's rows
and total on screen while a newly selected period fetched, but the
dropdown label updated immediately — so during the transition the
displayed numbers were labeled under a period they didn't belong to.
Now reads isPlaceholderData (the standard TanStack Query signal for
"this data is a stale placeholder, not a fresh fetch for the current
key") and dims the list while it's true, matching the same flag
already used for this exact purpose in integration-skills-section.tsx.
* fix(billing): show "<1 credit" for rows apportioned to 0
Cursor Bugbot (low): with apportioned per-row credits, a row with a
real but sub-credit dollarCost can legitimately apportion to 0 credits
once a sibling row absorbs the shared rounding remainder — rendering a
flat "0 credits" reads as if nothing was charged, inconsistent with
formatCreditCost's "<1 credit" wording used elsewhere in billing.
Added dollarCost to the wire response (needed to distinguish a
genuinely free row from a rounded-to-zero one) and a small
formatRowCredits helper that only changes the label, not the
underlying creditCost number, so the page-total reconciliation from
the prior fix is unaffected.
* fix(audit-logs): fix broken Custom range picker, trim time-range presets
Custom range silently did nothing: the time-range trigger was a
ChipSelect (Radix DropdownMenu, modal by default), and selecting
"Custom range" opened the Calendar popover in the same tick the modal
menu began its close/focus-lock cleanup, trapping the popover
non-interactive. Swapped to ChipCombobox (Radix Popover, non-modal),
mirroring the already-working pattern in the main Logs page exactly.
Also trimmed the preset list from 11 to 8 entries (dropped Past 30
minutes/12 hours/14 days) so the menu fits without scrolling.
* feat(billing): dedicated Credit usage page with date-range filter and CSV export
Follow-up to #5391 per team feedback in Slack: move the credit usage
list out of the inline Billing section into its own page, redesign
rows to show source ("Chat", "Workflow: <name>") instead of a raw
model description + badge, and add real date-range filtering and
export.
- Billing settings now shows a compact glance (30-day total + a "View
usage logs" link) instead of the full inline list.
- New /settings/billing/credit-usage page (sibling of [section],
mirrors the secrets/[credentialId] detail-route pattern) with day
presets (Today/7d/30d/All time) plus a working Custom range picker
— the same ChipCombobox+Popover+Calendar wiring the audit-logs fix
in this branch uses, not the broken ChipSelect pattern.
- Rows show the humanized source label, or "Workflow: <name>" for
workflow-sourced events (new server-side workflow-name lookup,
batched per page). Dropped the redundant badge and raw model
description.
- CSV export of the currently-filtered logs via a new GET
.../usage-logs/export route (mode: 'text' contract, synchronous
single-response CSV — the dataset is a bounded per-user ledger, not
a workspace-wide export, so no async job queue needed). Query-filter
logic (date-range resolution, workflow-name lookup) is shared with
the list route via shared.ts rather than duplicated.
- period/startDate/endDate live in the URL via a co-located
search-params.ts; the list query keeps keepPreviousData +
isPlaceholderData dimming during filter transitions, matching the
behavior already shipped in #5391.
Verified live end-to-end: back link navigation, custom range picker
opens and applies, day presets, CSV export downloads and matches the
on-screen rows exactly (credits reconcile with the total), compact
Billing summary + link.
* refactor(billing): move workflow-name enrichment into getUserUsageLogs, dedup helpers
/simplify pass over the credit-usage-page branch (4 parallel review
angles: reuse, simplification, efficiency, altitude):
- getUserUsageLogs now LEFT JOINs workflow and returns workflowName
directly (matching lib/logs/list-logs.ts's established pattern),
eliminating the route-layer resolveWorkflowNames query that both the
list and export routes previously ran independently.
- Added includeSummary (default true) to getUserUsageLogs so the
export route's cursor loop can skip the cursor-independent
SUM/GROUP BY aggregate it never reads — that aggregate was being
recomputed on every page of a paginated export for no reason.
- Fixed an off-by-one in the export's pagination loop: `<=
MAX_EXPORT_ROWS` let it fetch one more full page past the cap only
to discard it; `< MAX_EXPORT_ROWS` with a shrinking per-page limit
never overshoots.
- Deduplicated the SOURCE_LABELS map (was defined identically in both
the page and the export route) into a shared, DB-free
source-labels.ts both can import.
- Export route now builds CSV rows via lib/table/export-format.ts's
toCsvRow/formatCsvValue instead of a hand-rolled escaper.
- Added formatApportionedCreditCost to conversion.ts so the page's row
rendering shares its zero/sub-credit wording with formatCreditCost
instead of re-deriving the same three-way branch.
- Replaced the generic requireStartDateForCustomPeriod<Schema> contract
helper (nontrivial generic bound for a single four-line refine used
at two call sites) with a plain shared error-options object.
- Removed the credit-usage page's dateRangeAppliedRef guard — a
controlled Radix Popover never re-invokes onOpenChange in response
to the parent's own setState call, so the guard was defending
against a re-entrant close that can't happen.
- Added a modal prop to ChipSelect (forwarded to the underlying
DropdownMenu, which already supported it) so a future call site that
hits the same "modal select traps a same-tick Popover" bug the
audit-logs Custom range fix worked around has a real fix available
instead of having to swap components again.
Re-verified live end-to-end after the refactor: workflow-name
resolution, credit reconciliation, and CSV export all still correct.
* fix(billing): drop Dollar cost from the CSV export, strip inline comments
We only surface credits to the user, not the underlying dollar figure
— "Dollar cost" was the one place the export literally displayed a
dollar amount (the rest of the codebase uses dollarCost purely as an
internal signal to distinguish a sub-credit charge from a genuinely
free event, never rendered as a "$" value).
* fix(billing): export honors partial custom date range, surfaces truncation
Greptile (P1) and Cursor Bugbot independently caught the same bug:
handleExport only forwarded startDate/endDate when BOTH were truthy,
but the list query and both API contracts treat endDate as optional
for a custom period (defaults to now). A user landing on a bookmarked
?period=custom&startDate=... URL would see populated rows and an
enabled Export button, then get a 400 on click since the export
omitted the required startDate too. Fixed by forwarding each date
independently, matching the list query's existing behavior.
Also addressed Greptile's other two findings:
- The export route now sets X-Export-Truncated so a 5,000-row-capped
download is visible to the user (a toast), not just a server log.
Reading that header meant switching the trigger from a plain anchor
navigation to fetch+blob — an anchor can't inspect the response
before the browser commits to the download.
- resolveDateRange now throws explicitly when a custom period is
missing startDate instead of silencing the null check with `as
string`, which would have produced a silent Invalid Date if ever
called without prior contract validation.
* fix(billing): remove the export's arbitrary row cap, fix a cursor pagination bug it exposed
A personal credit ledger doesn't have the same unbounded-growth problem
a workspace table does — capping the export at 5,000 rows just meant
long-tenured or high-usage accounts (exactly the ones most likely to
need a full export to reconcile a billing question) got silently
truncated. Replaced the cap with a 50,000-row circuit breaker that
should never fire in normal use (logged as an error, not a warning,
if it ever does) and bumped the page size from 500 to 1,000 to cut
round trips.
Removing the cap surfaced a real, pre-existing bug in
getUserUsageLogs's cursor pagination: a raw `sql` template embedded a
JS Date object directly as a bound parameter, which the postgres
driver can't serialize (unlike drizzle's typed gte/lte operators,
which already handle Date correctly elsewhere in the same function).
It only ever manifested past the first page, which nothing before
this export route's tight multi-page loop reliably exercised.
Replaced the raw sql template with drizzle's typed lt/eq/or/and
operators, matching the pattern already proven correct in this file.
Verified live: seeded 6,000 rows (past the old cap) and confirmed the
export downloads all of them in one request with credits reconciling
exactly against the total.
* perf(billing): skip the redundant cursor lookup when the caller already has it
The export loop holds the previous page's rows in memory, so its next
cursor's createdAt is already known — getUserUsageLogs was still
re-resolving it via an extra DB round trip every page regardless.
Added an optional cursorCreatedAt to skip that lookup when provided;
the list route's existing callers are unaffected since they don't
pass it. Verified live: zero cursor-lookup queries fired across a
3,500-row / 4-page export that previously issued one per page.
* fix(billing): apportion credits over the whole filtered set, not per page/call
Cursor Bugbot caught this: the list route apportioned each page's
rows against only that page's own dollar total, while the export
apportioned every exported row against the complete set's total.
Since apportionment depends on the full set, the same log could show
a different creditCost between the list and the export, or even
between two pages of the same "Load more" list — and the sum of every
loaded row could visibly drift from the "Total" header shown above
them once more than one page had loaded.
Extracted getUsageCreditsByLogId — a single, shared, whole-filter
apportionment lookup both routes now call instead of each computing
their own subset locally. The list route calls it once per page
request (same cost profile as the summary aggregate it already pays
for every page); the export calls it once before its pagination loop,
not per page, keeping the round-trip count this session's earlier fix
already reduced. Also extracted the condition-building shared by the
main query, the summary aggregate, and this new lookup into one
buildUsageLogConditions helper, removing a third copy of that logic.
Verified live: summed every row across 4 "Load more" pages and
confirmed it now matches the reported total exactly (previously could
drift), and confirmed the list and the export produce byte-identical
credit sequences for the same rows.
* fix(billing): make custom-range startDate/endDate nullable, not '' defaulted
startDate/endDate had no sensible static default (they're only ever
meaningful mid-custom-range), so defaulting them to '' via
.withDefault('') meant switching back to a preset left the URL
carrying startDate=&endDate= instead of dropping the params entirely.
Made them nullable (no .withDefault) instead, matching the identical
fields in the main Logs page's own search-params.ts. Verified live —
switching from a custom range back to a preset now clears both params
from the URL completely.
* feat(audit-logs): add CSV export, matching the Credit usage page pattern
Adds an Export chip to the top-right of the Audit Logs page (via
SettingsPanel's actions slot — the same header mechanism the Credit
usage page uses), downloading every audit log matching the current
search/type/date filters as CSV.
- New GET /api/audit-logs/export route: same session + enterprise
admin/owner gating as the existing list route, reuses the shared
buildFilterConditions/buildOrgScopeCondition/queryAuditLogs helpers
(already using drizzle's typed operators for cursor pagination, not
the raw-sql-with-embedded-Date pattern fixed elsewhere this
session), and the same fetch+blob+X-Export-Truncated pattern the
Credit usage export already established.
- Capped at 10,000 rows (not the 50,000 used for a personal credit
ledger) — an org's audit trail can genuinely grow much larger than
one user's usage history, so this is sized for "a reasonable audit
review window," with truncation surfaced via a toast rather than
silently dropped.
- Bumped the API-validation-contract audit's route-count baseline for
the new route.
Verified live against a real enterprise org: switched to "All time,"
exported ~750 real audit log rows, confirmed formatting (quoted
descriptions, actor email fallback) and correct filter scoping.
* fix(billing): skip wasted credit apportionment on the summary fetch, block export during stale data
Cursor Bugbot caught two real issues:
1. The compact Billing summary glance (limit=1) only ever reads
summary.totalCredits, but the list route unconditionally ran
getUsageCreditsByLogId's whole-filter scan on every call including
this one — pure wasted work for a caller that discards the result.
Added an includeCredits query flag (default true, using the shared
booleanQueryFlagSchema) so useUsageSummary can opt out; the main
paginated view keeps it on since it genuinely needs per-row values.
2. Export stayed enabled while useUsageLogs held stale rows via
keepPreviousData mid-filter-transition — a user could change the
period/range and click Export before the new data loaded, exporting
against the new filter while the table still showed the old one.
Export is now also disabled while isPlaceholderData is true.
* fix(billing): deterministic apportionment order, block audit export during stale data
Cursor Bugbot caught two more real issues on the latest push:
1. Same stale-export bug as the earlier Credit usage fix, this time in
Audit Logs: Export stayed enabled while useAuditLogs held prior
rows via keepPreviousData, so it could export against a
just-changed filter while the table still showed the old one. Now
also disabled while isPlaceholderData is true.
2. getUsageCreditsByLogId had no ORDER BY before apportionCredits's
largest-remainder tie-break, so which row absorbed a tied
remainder credit depended on undefined Postgres row order — the
same event's displayed credit could flip between calls (list vs.
export, or even two successive requests). Added the same
`orderBy(desc(createdAt), desc(id))` the main list query already
uses, making the tie-break reproducible.
Verified live: 3 identically-costed rows produced the same tie-break
winner across 3 repeated requests (previously order-dependent).
* fix(billing): distinguish a failed summary fetch from zero usage
The compact Billing glance only branched on isPending, so once
useUsageSummary settled into an error state, totalCredits stayed
undefined and formatCreditsLabel(0) rendered "0 credits" — visually
identical to genuinely having no usage this period. Now shows the
same neutral "—" placeholder for isError as it already does for
isPending.
* fix(billing): gate the credit-usage page server-side for enterprise accounts
Greptile (P1) caught this: hiding the "View usage logs" link on the
Billing page for enterprise accounts doesn't stop direct navigation —
anyone with the URL (bookmark, shared link, browser history) could
still reach the full page and its CSV export, which enterprise
accounts were never supposed to see at all (billing is managed
out-of-band for them).
Added a server-side check in page.tsx before anything renders:
resolve the session, look up the highest-priority subscription, and
redirect to /settings/billing if it's enterprise — matching how
getHighestPrioritySubscription is already used elsewhere for
server-side plan checks, rather than relying on a client-side-only
conditional the way the Billing page's inline section does.
Also fixes loading.tsx: it was a Server Component (no directive)
passing a raw icon function reference into the client Chip component,
which fails RSC serialization. Added 'use client'.
Verified live in a real browser against both an enterprise account
(redirects to Billing before any credit-usage content renders) and a
non-enterprise account (reaches the page normally).1 parent d508201 commit 759dddb
25 files changed
Lines changed: 1610 additions & 233 deletions
File tree
- apps/sim
- app
- api
- audit-logs/export
- users/me/usage-logs
- export
- workspace/[workspaceId]/settings
- billing/credit-usage
- components/billing
- components/credit-usage-section
- ee/audit-logs/components
- hooks/queries
- utils
- lib
- api/contracts
- billing
- core
- credits
- packages/emcn/src/components/chip-select
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
0 commit comments