feat(billing): expose credit usage log in Billing settings by waleedlatif1 · Pull Request #5391 · simstudioai/sim · GitHub
Skip to content

feat(billing): expose credit usage log in Billing settings#5391

Merged
waleedlatif1 merged 4 commits into
stagingfrom
feat/credit-usage-log
Jul 3, 2026
Merged

feat(billing): expose credit usage log in Billing settings#5391
waleedlatif1 merged 4 commits into
stagingfrom
feat/credit-usage-log

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Add a "Credit usage" section under Billing settings, below Invoices — a paginated, period-filterable list of individual credit-consuming events (model, tool, fixed charges), shown to every plan except Enterprise
  • The backend query already existed (getUserUsageLogs) but the /api/users/me/usage-logs route was dead/unused — wired it up with a proper getUsageLogsContract, a useUsageLogs infinite-query hook, and an emcn-styled UI section matching the existing Invoices list
  • Fixed a stale query-param enum on the route that only allowed 3 of the real 9 usage_log sources
  • Extracted formatCreditsLabel in conversion.ts so the credit-usage rows, the billing cost breakdown, and the trace view all share one formatter instead of diverging

Type of Change

  • New feature

Testing

  • Added route.test.ts (5 tests: auth, dollar→credit conversion, invalid period, start-date resolution per period)
  • Verified live: ran the app against a local seeded DB, forged a Better Auth session, drove it with Playwright — confirmed the section is hidden for an Enterprise-plan user, correctly renders and reconciles totals for a Free-plan user with real usage rows, and the period dropdown re-filters correctly
  • check:api-validation, typecheck, and full test suite all pass

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Add a "Credit usage" section under Billing, below Invoices, showing a
paginated, period-filterable list of individual credit-consuming
events (model, tool, and fixed charges) for every plan except
Enterprise. Wires the existing (previously unused) usage-logs backend
to a proper contract, React Query hook, and emcn-styled UI:

- getUsageLogsContract in contracts/user.ts, broadened the source enum
  to match the real usage_log schema
- Rewrote the route to use parseRequest per the API boundary rules
- useUsageLogs infinite-query hook, keyset-paginated
- CreditUsageSection: period dropdown, total-credits summary, row
  list with source badges, "Load more"
- Extracted formatCreditsLabel in conversion.ts as the shared
  formatter for already-converted integer credits
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

@cursor

cursor Bot commented Jul 3, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches a user billing read API (response shape and source filter) and displays usage ledger data; changes are scoped to authenticated self-service with new route tests and no payment writes.

Overview
Adds a Credit usage section to Billing settings (non-Enterprise) so users can see paginated, period-filtered credit events with totals and humanized source labels.

The /api/users/me/usage-logs handler is refactored to validate via getUsageLogsContract and parseRequest, maps dollar costs to credits-only responses (creditCost, totalCredits, bySourceCredits), trims log fields, and expands allowed source values to match the full usage ledger. Period filtering is centralized in resolveStartDate.

Client wiring includes useUsageLogs (infinite query + contract client), URL-synced period via nuqs, and formatCreditsLabel shared with existing dollar-based credit formatting.

Reviewed by Cursor Bugbot for commit 73679b1. Configure here.

Comment thread apps/sim/app/api/users/me/usage-logs/route.ts
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Wires up the previously-dead /api/users/me/usage-logs route with a proper getUsageLogsContract, expands its source enum from 3 to 9 values, and adds a paginated "Credit usage" section to Billing settings (hidden for Enterprise plans). A shared formatCreditsLabel helper is extracted in conversion.ts so the new log rows, the existing breakdown, and the trace view all pluralize credits identically.

  • Route refactor (route.ts): adopts parseRequest/contract validation, a clean resolveStartDate helper, and strips category from the API response; dollar→credit conversion and summary aggregation are correct.
  • Infinite-query hook + key factory (usage-logs.ts, usage-log-keys.ts): key factory is in a standalone non-'use client' module per project convention; period is in the query key, pageParam/signal correctly exempt; keepPreviousData applies because the key varies.
  • UI section (credit-usage-section.tsx, search-params.ts): period filter is nuqs-backed (replace-history, clear-on-default); totalCredits from pages[0] is the correct full-period aggregate since getUserUsageLogs runs its summary query without the cursor constraint.

Confidence Score: 5/5

Safe to merge — the change is additive (new UI section + contract), the backend query logic was pre-existing, and the gating on !isEnterprise is straightforward.

All changed paths are additive; the route refactor is well-tested (5 targeted test cases), the contract expansion correctly mirrors the real DB enum, and the infinite-query wiring follows established project patterns without touching any shared billing state.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/app/api/users/me/usage-logs/route.ts Rewrites the handler using parseRequest + getUsageLogsContract; cleans up dollar→credit conversion and removes the try/catch in favour of withRouteHandler; no issues found.
apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx New paginated credit-usage UI; totalCredits correctly uses pages[0] which holds the full-period aggregate (backend runs a separate summary query); SOURCE_LABELS covers all 9 enum values; minor display inconsistency for sub-$0.005 entries (formatCreditsLabel has no <1 credit guard).
apps/sim/hooks/queries/usage-logs.ts Infinite-query hook with explicit staleTime, keepPreviousData for period transitions, and correct signal forwarding; key factory lives in the non-'use client' utils file per project rules.
apps/sim/hooks/queries/utils/usage-log-keys.ts Standalone key factory (no 'use client') following the folder-keys / table-keys pattern; source slot is present in the list key for future use.
apps/sim/lib/api/contracts/user.ts Adds usageLogSourceSchema (all 9 sources), usageLogPeriodSchema, response schema, and getUsageLogsContract; expands the stale 3-value source enum to cover all real sources.
apps/sim/lib/billing/credits/conversion.ts Extracts formatCreditsLabel as a shared building block; formatCreditCost now delegates to it, eliminating duplication; no regressions.
apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx Adds CreditUsageSection below Invoices, gated on !subscription.isEnterprise; minimal, correct change.
apps/sim/app/api/users/me/usage-logs/route.test.ts New test file covers auth, dollar→credit conversion, invalid period rejection, and period→startDate resolution; all five cases are solid.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as CreditUsageSection
    participant nuqs as nuqs URL state
    participant RQ as React Query
    participant API as GET /api/users/me/usage-logs
    participant DB as getUserUsageLogs

    UI->>nuqs: read period default 30d
    UI->>RQ: useUsageLogs period
    RQ->>API: "GET ?period=30d&limit=25"
    API->>DB: paginated logs query with cursor
    API->>DB: summary aggregate query no cursor
    DB-->>API: logs page plus full-period summary
    API-->>RQ: logs summary.totalCredits pagination
    RQ-->>UI: pages[0] totalCredits shown logs rendered

    UI->>RQ: fetchNextPage cursor
    RQ->>API: "GET ?period=30d&limit=25&cursor=id"
    API->>DB: paginated logs cursor filtered
    API->>DB: summary aggregate no cursor
    DB-->>API: next page plus same full-period summary
    API-->>RQ: additional logs appended

    UI->>nuqs: user selects 7d
    nuqs-->>UI: period equals 7d replace history
    UI->>RQ: useUsageLogs period 7d new queryKey
    RQ->>API: "GET ?period=7d&limit=25"
    API-->>RQ: new page set
    RQ-->>UI: fresh data replaces keepPreviousData placeholder
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as CreditUsageSection
    participant nuqs as nuqs URL state
    participant RQ as React Query
    participant API as GET /api/users/me/usage-logs
    participant DB as getUserUsageLogs

    UI->>nuqs: read period default 30d
    UI->>RQ: useUsageLogs period
    RQ->>API: "GET ?period=30d&limit=25"
    API->>DB: paginated logs query with cursor
    API->>DB: summary aggregate query no cursor
    DB-->>API: logs page plus full-period summary
    API-->>RQ: logs summary.totalCredits pagination
    RQ-->>UI: pages[0] totalCredits shown logs rendered

    UI->>RQ: fetchNextPage cursor
    RQ->>API: "GET ?period=30d&limit=25&cursor=id"
    API->>DB: paginated logs cursor filtered
    API->>DB: summary aggregate no cursor
    DB-->>API: next page plus same full-period summary
    API-->>RQ: additional logs appended

    UI->>nuqs: user selects 7d
    nuqs-->>UI: period equals 7d replace history
    UI->>RQ: useUsageLogs period 7d new queryKey
    RQ->>API: "GET ?period=7d&limit=25"
    API-->>RQ: new page set
    RQ-->>UI: fresh data replaces keepPreviousData placeholder
Loading

Reviews (6): Last reviewed commit: "improvement(billing): move credit usage ..." | Re-trigger Greptile

Comment thread apps/sim/app/api/users/me/usage-logs/route.ts
Comment thread apps/sim/hooks/queries/usage-logs.ts Outdated
…dary

Greptile P2: usageLogKeys lived in the 'use client' usage-logs.ts hook
file — importing it from a server component (e.g. a future prefetch)
would resolve to a client-reference stub and crash at build/SSR, the
same class of bug that hit tables' key factory before. Extracted to
hooks/queries/utils/usage-log-keys.ts, matching table-keys.ts and
folder-keys.ts.

Also renamed a shadowed `source` map param in the route to `sourceKey`
for clarity.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 89fe7e7. Configure here.

The '1d' | '7d' | '30d' | 'all' union was hand-typed in three places
across usage-logs.ts and credit-usage-section.tsx. Extracted
usageLogPeriodSchema in the contract and derived UsageLogPeriod from
it, so the hook, the component, and the key factory all share one
definition instead of risking drift.
/cleanup pass (nuqs rule, react-query-best-practices, emcn review):
- period was a plain useState, but it's exactly the kind of shareable
  list filter sim-url-state.md calls out for nuqs — migrated to
  billingParsers/useQueryStates so the selection deep-links, survives
  reload, and matches every sibling settings section (recently-deleted,
  inbox, teammates). Derives its literal values from
  usageLogPeriodSchema instead of a fourth copy of the same union.
- Removed an unjustified showSelectedCheck={false} on the period
  ChipDropdown — the one other usage in the codebase is a one-shot
  action menu with no persistent selection; this is a real filter and
  should show the check like the default intends.
- Added placeholderData: keepPreviousData to useUsageLogs — period is
  a variable query key, so without it switching periods flashed the
  loading empty-state instead of smoothly transitioning.

Verified live: deep-link with ?period=7d pre-selects and loads
correctly, switching periods updates the URL, and the selection
survives a hard reload.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 73679b1. Configure here.

@waleedlatif1 waleedlatif1 merged commit ca2a52c into staging Jul 3, 2026
18 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/credit-usage-log branch July 3, 2026 20:21
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 73679b1. Configure here.

waleedlatif1 added a commit that referenced this pull request Jul 4, 2026
… 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant