Skip to content

feat(billing): push credit balance to navbar at every mutation, not just chat finish - #1508

Merged
2witstudios merged 2 commits into
masterfrom
pu/is-uage-subscribed-to-realtime
Jun 3, 2026
Merged

2witstudios merged 2 commits into
masterfrom
pu/is-uage-subscribed-to-realtime

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 3, 2026 •

Copy link
Copy Markdown
Owner

Problem

Credit usage only updated the navbar widget on AI-chat onFinish, and only on 2 of 5 chat routes. But a user's spendable (= monthly + topup − reserved) changes at many points that never emitted credits:updated:

  • the reservation hold placed at turn start (spendable drops)
  • hold release on error/abort (spendable restored)
  • 8 background AI jobs that bill via trackAIUsage (memory compaction/discovery/integration, workflow executor, Zoom ×2, pulse cron, ask_agent sub-agent tool)
  • the reconcile cron (orphan-consume, pending-settle, expired-hold sweep)

The decisive case: onFinish is not guaranteed. The AI-SDK abortSignal only 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/lib so 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 + resolveTier moved here from apps/web; apps/web re-exports them so GET /api/credits and 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 as notifications.ts). No-op when billing is disabled; never throws; treats non-2xx realtime responses as dropped broadcasts (logged, not silently swallowed).
  • Emits wired at each chokepoint:
    • canConsumeAI → on hold create (navbar drops at turn start, even if onFinish never runs)
    • consumeCredits + settlePendingLedgerRow → after every debit (all 5 routes + 8 background jobs + cron orphan/pending settle)
    • releaseHold → returns the freed hold's userId and 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 cron
  • Thread conversationId/pageId through ConsumeCreditsInput → ai-monitoring so the per-conversation usage monitor still scopes correctly.
  • Remove the now-redundant manual finish emits in ai/chat and ai/global (the centralized debit emit replaces them; avoids double-fire).

Client (useCreditBalance)

Each credits:updated event 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/AiUsageMonitor are untouched.

No DB schema change.

Tests

  • New credit-emit.test.ts (broadcast shape/scope/no-op/never-throws/non-2xx logged) and moved credit-balance.test.ts into lib.
  • Added emit assertions across credit-consume, credit-gate, credit-backfill tests; integration test stubs the broadcast.
  • @pagespace/lib + web typecheck ✅, lint ✅, full lib suite ✅, affected web tests (credits route, both AI routes, socket-utils, all Stripe) ✅.

Review feedback addressed (Codex + CodeRabbit)

  • Backfill no longer serializes best-effort emits (non-blocking bounded fan-out).
  • credit-emit surfaces non-2xx realtime responses instead of treating them as success.
  • Client revalidates on each event to defeat stale/out-of-order broadcast races.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagespace-marketing Ready Ready Preview, Comment Jun 3, 2026 11:19am

@coderabbitai

coderabbitai Bot commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This 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.

Changes

Credit emit and balance library refactoring

Layer / File(s) Summary
Shared billing library foundation
packages/lib/src/billing/credit-balance.ts, packages/lib/src/billing/credit-emit.ts, packages/lib/package.json
Exports getCreditBalance, resolveTier, and CreditBalanceSummary from credit-balance.ts; exports emitCreditsUpdated and EmitCreditsOptions from credit-emit.ts. Adds subpath exports in package.json for both modules.
Live emission integration in billing flow
packages/lib/src/billing/credit-consume.ts, packages/lib/src/billing/credit-gate.ts, packages/lib/src/billing/credit-backfill.ts, packages/lib/src/monitoring/ai-monitoring.ts
consumeCredits emits credits-updated after settlements with optional conversation/page scope; releaseHold and settlePendingLedgerRow emit after balance changes. canConsumeAI emits immediately after hold placement. backfillCredits emits per affected user during hold expiry sweep. trackAIUsage threads conversationId and pageId into billing calls.
Billing layer test coverage
packages/lib/src/billing/__tests__/credit-emit.test.ts, packages/lib/src/billing/__tests__/credit-consume.test.ts, packages/lib/src/billing/__tests__/credit-gate.test.ts, packages/lib/src/billing/__tests__/credit-balance.test.ts, packages/lib/src/billing/__tests__/credit-backfill.test.ts, packages/lib/src/billing/__tests__/credits-flow.integration.test.ts
New test suite for emitCreditsUpdated covering billing-enabled/disabled, tier resolution, payload scope fields, and broadcast failures. Existing test suites updated to mock and assert emitCreditsUpdated calls after balance mutations and to add resolveTier coverage.
Web app module conversion and route cleanup
apps/web/src/lib/subscription/credit-balance.ts, apps/web/src/app/api/ai/chat/route.ts, apps/web/src/app/api/ai/global/[id]/messages/route.ts
credit-balance.ts becomes a re-export shim for shared billing modules. AI streaming routes remove emitCreditsUpdated imports and calls from onFinish handlers, deferring emission to the consumeCredits call invoked by trackAIUsage.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • 2witstudios/PageSpace#1471: Implements the live emitCreditsUpdated behavior inside the prepaid credits consume/track flow; this PR removes route-level calls to rely on that emission instead.
  • 2witstudios/PageSpace#1479: Modifies trackAIUsage credit consumption call alongside this PR to update when and how credits are debited and broadcast.
  • 2witstudios/PageSpace#1505: Modifies the same AI streaming route handlers to extract provider cost context; both PRs change the route-level billing integration.

🐰 Credits flow through pipes, no longer route-bound,
Shared lib broadcasts updates all around,
Billing and gates now speak the same tongue,
Balance syncs live before settlement is done! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly and accurately summarizes the main change: centralizing realtime credit balance broadcasts to occur at every billing mutation rather than just chat finish, which aligns perfectly with the detailed PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/is-uage-subscribed-to-realtime

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/lib/src/billing/credit-backfill.ts Outdated
Comment thread packages/lib/src/billing/credit-gate.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between d8324a6 and c622f8f.

📒 Files selected for processing (16)
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/lib/subscription/credit-balance.ts
  • packages/lib/package.json
  • packages/lib/src/billing/__tests__/credit-backfill.test.ts
  • packages/lib/src/billing/__tests__/credit-balance.test.ts
  • packages/lib/src/billing/__tests__/credit-consume.test.ts
  • packages/lib/src/billing/__tests__/credit-emit.test.ts
  • packages/lib/src/billing/__tests__/credit-gate.test.ts
  • packages/lib/src/billing/__tests__/credits-flow.integration.test.ts
  • packages/lib/src/billing/credit-backfill.ts
  • packages/lib/src/billing/credit-balance.ts
  • packages/lib/src/billing/credit-consume.ts
  • packages/lib/src/billing/credit-emit.ts
  • packages/lib/src/billing/credit-gate.ts
  • packages/lib/src/monitoring/ai-monitoring.ts

Comment thread packages/lib/src/billing/credit-backfill.ts Outdated
Comment thread packages/lib/src/billing/credit-emit.ts Outdated
…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>
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