Repository navigation
feat(billing): push credit balance to navbar at every mutation, not just chat finish - #1508
Conversation
…ust chat finish The navbar credit widget only received `credits:updated` at AI-chat onFinish, and only on 2 of 5 chat routes. But `spendable` (= monthly + topup − reserved) changes at many other points that never emitted: the reservation hold at turn start, hold release on error/abort, every background-job debit (memory, workflows, Zoom, pulse cron, ask_agent), and the reconcile cron. Worst case: an interrupted stream never runs onFinish, so settlement (and the emit) only happens on the cron sweep — which emitted nothing, so the balance only corrected on a manual refresh. That's the "refreshing changed the number" bug. Fix: centralize the broadcast in the billing primitives in @pagespace/lib so every balance/holds mutation pushes automatically, regardless of caller. - Move getCreditBalance + resolveTier into packages/lib/billing/credit-balance.ts (apps/web re-exports them; GET /api/credits and the Stripe webhook unchanged). - Add packages/lib/billing/credit-emit.ts: emitCreditsUpdated() recomputes the balance and signs+POSTs to the realtime server (same pattern as notifications.ts); no-op when billing is disabled, never throws. - Emit at every chokepoint: - canConsumeAI: on hold create (navbar drops at turn start, even if onFinish never runs) - consumeCredits + settlePendingLedgerRow: after every debit (all routes + 8 background jobs + cron) - releaseHold: returns the freed hold's userId and emits (error/abort restore) - backfillCredits: emits per affected user on expired-hold sweep (interrupted-call backstop) - Thread conversationId/pageId through ConsumeCreditsInput → ai-monitoring so the per-conversation usage monitor still scopes correctly. - Remove the now-redundant manual finish emits in the chat routes (avoids double-fire). No DB schema or client changes — useCreditBalance/CreditBalance already consume the event. Adds credit-emit + credit-balance unit tests and emit assertions across consume/gate/backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR refactors credit balance reading and credit-update broadcasting from the web app into shared billing library modules. It centralizes live credit-update emission throughout the billing flow after holds, settlements, and backfills, removing route-level emit calls in favor of billing-layer broadcasting. ChangesCredit emit and balance library refactoring
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c622f8f34f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/lib/src/billing/credit-backfill.ts`:
- Around line 52-59: The current loop awaits each emitCreditsUpdated(uid)
sequentially which can block the cron; change it to fire the emits concurrently
without blocking: replace the for/await loop over affected with a non-blocking
concurrent invocation (e.g. map over affected and call emitCreditsUpdated(uid)
for each, do not await the resulting promises) and attach a .catch to each
promise (or wrap with void promise.catch(...)) to avoid unhandled rejections and
to log failures; reference the affected array and the emitCreditsUpdated
function to locate the change.
In `@packages/lib/src/billing/credit-emit.ts`:
- Around line 47-52: The current fetch call in credit-emit.ts (using
realtimeUrl, requestBody, createSignedBroadcastHeaders and AbortSignal.timeout)
treats any HTTP response as success; modify the code to capture the Response
object (const res = await fetch(...)), check res.ok (or res.status between 200
and 299), and if not ok throw an Error or reject with a message that includes
status and optionally await res.text() for body so the catch path runs and
dropped broadcasts are logged; ensure the thrown error includes identifying info
(status, url, and response text) so existing catch handlers can record failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bc40c992-dae7-4119-9819-3a70a3918dd2
📒 Files selected for processing (16)
apps/web/src/app/api/ai/chat/route.tsapps/web/src/app/api/ai/global/[id]/messages/route.tsapps/web/src/lib/subscription/credit-balance.tspackages/lib/package.jsonpackages/lib/src/billing/__tests__/credit-backfill.test.tspackages/lib/src/billing/__tests__/credit-balance.test.tspackages/lib/src/billing/__tests__/credit-consume.test.tspackages/lib/src/billing/__tests__/credit-emit.test.tspackages/lib/src/billing/__tests__/credit-gate.test.tspackages/lib/src/billing/__tests__/credits-flow.integration.test.tspackages/lib/src/billing/credit-backfill.tspackages/lib/src/billing/credit-balance.tspackages/lib/src/billing/credit-consume.tspackages/lib/src/billing/credit-emit.tspackages/lib/src/billing/credit-gate.tspackages/lib/src/monitoring/ai-monitoring.ts
…non-2xx broadcast detection, client revalidate Codex + CodeRabbit review of #1508: - credit-backfill: the expired-hold sweep awaited each emitCreditsUpdated sequentially, so a slow/unreachable realtime server (5s self-timeout per emit) serialized that delay across every reclaimed user and stalled the cron before the pending/orphan reconciliation. Now fire-and-forget via a bounded (10-wide) emitBalancesBestEffort fan-out: never blocks the cron, never opens one socket per user at once. (Codex P2 + CodeRabbit Major) - credit-emit: broadcastCreditsUpdated treated 4xx/5xx as success, so dropped pushes were invisible. Now throws on non-2xx so emitCreditsUpdated's catch logs it. Added a unit test for the non-2xx path. (CodeRabbit Major) - useCreditBalance: the client applied each credits:updated payload as authoritative, so a delayed turn-start "hold placed" push arriving after its own settlement push could roll the navbar back to a stale reserved/spendable value. Now applies the payload optimistically AND revalidates against /api/credits (authoritative); SWR dedupes concurrent revalidations so bursty start+finish events coalesce. (Codex P2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
Credit usage only updated the navbar widget on AI-chat
onFinish, and only on 2 of 5 chat routes. But a user'sspendable(= monthly + topup − reserved) changes at many points that never emittedcredits:updated:trackAIUsage(memory compaction/discovery/integration, workflow executor, Zoom ×2, pulse cron,ask_agentsub-agent tool)The decisive case:
onFinishis not guaranteed. The AI-SDKabortSignalonly fires on an explicit/api/ai/abort; on a hard disconnect (tab close, navigation, network drop, function killed) settlement never runs, so the balance only corrects when the cron sweeps the dangling hold — and the cron emitted nothing. A manual refresh then "changed the number." That's the reported bug.Fix
Centralize the broadcast in the billing primitives in
@pagespace/libso every balance/holds mutation pushes a fresh balance automatically — regardless of which route or background job triggered it. New billing callers inherit live navbar updates for free.packages/lib/src/billing/credit-balance.ts(new) —getCreditBalance+resolveTiermoved here from apps/web; apps/web re-exports them soGET /api/creditsand the Stripe webhook are unchanged.packages/lib/src/billing/credit-emit.ts(new) —emitCreditsUpdated()recomputes the balance and signs+POSTs to the realtime server (same pattern asnotifications.ts). No-op when billing is disabled; never throws; treats non-2xx realtime responses as dropped broadcasts (logged, not silently swallowed).canConsumeAI→ on hold create (navbar drops at turn start, even ifonFinishnever runs)consumeCredits+settlePendingLedgerRow→ after every debit (all 5 routes + 8 background jobs + cron orphan/pending settle)releaseHold→ returns the freed hold'suserIdand emits (error/abort restore)backfillCredits→ emits per affected user on the expired-hold sweep (interrupted-call backstop), via a non-blocking bounded fan-out so a slow realtime server can't stall the cronconversationId/pageIdthroughConsumeCreditsInput→ai-monitoringso the per-conversation usage monitor still scopes correctly.ai/chatandai/global(the centralized debit emit replaces them; avoids double-fire).Client (
useCreditBalance)Each
credits:updatedevent now applies the payload optimistically and revalidates against/api/credits(the authoritative current balance), so an out-of-order or stale snapshot (e.g. a delayed turn-start "hold placed" push arriving after its own settlement push) can't stick — the follow-up refetch corrects it. SWR dedupes concurrent revalidations, so bursty start+finish events coalesce into a single refetch. No payload-shape change;CreditBalance/AiUsageMonitorare untouched.No DB schema change.
Tests
credit-emit.test.ts(broadcast shape/scope/no-op/never-throws/non-2xx logged) and movedcredit-balance.test.tsinto lib.credit-consume,credit-gate,credit-backfilltests; integration test stubs the broadcast.@pagespace/lib+webtypecheck ✅, lint ✅, full lib suite ✅, affected web tests (credits route, both AI routes, socket-utils, all Stripe) ✅.Review feedback addressed (Codex + CodeRabbit)
credit-emitsurfaces non-2xx realtime responses instead of treating them as success.🤖 Generated with Claude Code