Skip to content

feat(billing): prepaid AI-credits billing bridge (epic tasks 1–4, 6-lib) - #1471

Merged
2witstudios merged 11 commits into
masterfrom
pu/metered-billing
Jun 1, 2026
Merged

2witstudios merged 11 commits into
masterfrom
pu/metered-billing

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 1, 2026 •

Copy link
Copy Markdown
Owner

Mission

Stripe is used only to collect money; a local two-bucket ledger is the source of truth and an app-side hard gate enforces the prepaid wall.

Epic: tasks/prepaid-credits-billing.md. Full design: the-plan-was-to-zesty-walrus.md.

What's in this PR (epic tasks 1–4 + the task-6 lib layer)

  • Pure decision core (credit-core.ts) — zero-I/O billing math (markupCents, allocateSpend, evaluateGate, computeMonthlyRefill, applyTopup, classifyStripeEvent, computeBackfillActions). Purity enforced by a test.
  • Pricing config (credit-pricing.ts) — env-overridable markup, per-tier allowances (free = $5, cheaper-models-only), reserve floor, credit packs.
  • Schema + migration (schema/credits.ts, 0143_cool_mongu.sql) — credit_balances (two buckets) + append-only credit_ledger with partial unique indexes on aiUsageLogId and stripeRef (exactly-once guarantees).
  • Consume path (credit-consume.ts) — idempotent claim-then-settle decrement wired into trackAIUsage; writeAiUsage now returns the row id as the idempotency key. Never throws into the AI request.
  • Reconcile cron (api/cron/reconcile-credits, credit-backfill.ts) — HMAC-signed, local-only sweep that re-settles pending rows and consumes orphan usage rows exactly once.
  • Gate (credit-gate.ts) — fast pre-request canConsumeAI over evaluateGate, with lazy balance init from tier defaults. (Wiring into AI handlers + the 402 response is task 6's exposure half — not in this PR.)
  • Premium gating (rate-limit-middleware.ts) — premium models open to any paid tier; positive allowlist so unknown tiers default to denied.

Review fixes already incorporated (fb823ec7d)

  1. HIGH — the idempotent claim now restates the partial-index predicate (WHERE aiUsageLogId IS NOT NULL) so Postgres can infer the ON CONFLICT arbiter. Without it, every insert raised 42P10, was caught silently, and no credits were ever consumed.
  2. MED — premium gating switched from excluding 'free' to a positive paid-tier allowlist.
  3. MED — removed a premature ./billing/credit-funding export pointing at a non-existent module.

Out of scope (later epic tasks)

Task 5 (Stripe funding/webhook/checkout), task-6 exposure half (handler wiring + 402 + rate-limit removal), task 7 (dashboard), task 8 (Stripe setup script).

Validation

  • @pagespace/lib unit tests: 143 pass (billing, monitoring, logging).
  • web rate-limit-middleware tests: 8 pass (incl. founder-allowed + unknown-tier-denied).
  • @pagespace/db + @pagespace/lib build green; db:generate reproduces the migration cleanly.
  • Merged latest master; resolved a migration-number collision (0142 ↔ master's 0142_sparkling_maverick) by regenerating ours as 0143. No conflicts.

Known follow-up

No integration test exercises real Postgres yet — the partial-index ON CONFLICT semantics (and concurrent/double consume) can only be fully verified against a live DB. Recommended as the next addition, especially before the funding path (task 5) lands.

🤖 Generated with Claude Code

Convergence log (post-open fixes)

  • Merge + migration collision: merged latest master; our 0142_sleepy_magma collided with master's 0142_sparkling_maverick, so regenerated the credits migration as 0143_cool_mongu via db:generate. No conflicts.
  • CI typecheck (bce0d2086): apps/processor/tsconfig.json typechecked sibling-package test files under module:commonjs; the billing purity tests' import.meta.url broke "Lint & TypeScript Check" + "Static Security Analysis". Excluded test files from processor's typecheck (matches realtime's convention).
  • P1 silent-drop (d968c0191): consumeCredits marked the ledger applied even when no credit_balances row existed, so the charge was neither deducted nor retried by the cron. Now leaves the row pending until a balance exists. Regression test added. (Reported by Codex; thread resolved.)

Operational follow-up (deploy repo)

api/cron/reconcile-credits is a signed endpoint only; like all sibling cron routes, its schedule lives in PageSpace-Deploy (fly/fly.cron.toml / pagespace-cron image), where it is not yet registered. It must be added there for the backfill to run — the "pending ledger rows settle once a balance exists" guarantee depends on it.

Summary by CodeRabbit

  • New Features

    • Introduced prepaid AI credits billing system with subscription tier-based monthly allowances and one-time top-up purchases.
    • Added credit gating to prevent API usage when insufficient credits remain.
    • Implemented automated credit reconciliation via cron job.
  • Bug Fixes

    • Fixed subscription tier logic to properly recognize founder tier as a paid subscription.

Review round 2 (CodeRabbit / Codex — all threads resolved)

  • gate concurrent-init race (b88557cd1): canConsumeAI re-selects the persisted balance after lazy-init and evaluates against it (no false allow under concurrent init). + race test.
  • consume cost guard (b88557cd1): reject non-finite/negative costDollars before claiming. + test.
  • strict parsing: classifyStripeEvent packCents (/^\d+$/, rejects "2500usd") and envInt (rejects trailing junk/decimals). + tests.
  • schema: billing timestamps → timestamptz; non-negative + period-order check() invariants declared (drizzle-kit 0.23.2 doesn't emit CHECK DDL — kept as forward-compat matching the existing core.ts precedent; runtime non-negativity guaranteed by allocateSpend + cost guard). Migration regenerated 0143_yielding_praxagora.
  • packaging: typesVersions entries added for billing/* exports.
  • test/doc: writeAiUsage mock → null; removed machine-local design path.
  • Declined w/ reasoning: keeping creditLedger ON DELETE CASCADE (dominant repo convention; RESTRICT breaks user deletion) — ledger retention/anonymization recorded as a follow-up.

2witstudios and others added 6 commits June 1, 2026 13:39
…c tasks 1-3)

Temporary prepaid metered-billing bridge on our own Stripe account, decoupled
from the stalled Parallel Drive / Polar move. Strict TDD + functional core.

- credit-core.ts: pure, zero-I/O decision layer (markup, monthly-first spend
  allocation, prepaid gate, refill, topup, stripe-event classification,
  backfill planner). 24 tests, purity enforced by test.
- credit-pricing.ts: env-overridable constants (1.5x markup, tier allowances,
  reserve floor, credit packs).
- credits schema: two-bucket credit_balances + append-only credit_ledger with
  unique partial indexes guaranteeing one decrement per aiUsageLogId and one
  credit per stripeRef. Migration 0142 via db:generate.
- writeAiUsage now returns the inserted row id (deterministic idempotency key).
- credit-consume.ts: idempotent, atomic, never-throws shell that debits the
  prepaid balance at cost x1.5; wired into trackAIUsage (failed calls unbilled).

All 93 runnable lib tests pass; files typecheck/lint clean with deps resolved.
(logger-database.test.ts can't load in .pu worktree due to cuid2 resolution —
environmental, not a regression.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- credit-backfill.ts: local-only reconcile shell over the pure
  computeBackfillActions — re-settles 'pending' ledger rows and consumes
  orphan usage rows (no ledger entry), guaranteeing exactly-once billing
  across crashes/deploys. Makes no Stripe calls.
- Refactor credit-consume: extract shared decrementAndSettle; add
  settlePendingLedgerRow(ledgerId) reused by the backfill retry path.
- Signed-cron route /api/cron/reconcile-credits (mirrors purge-ai-usage-logs;
  scheduled externally like the other signed crons).
- Add @pagespace/lib billing/* exports and @pagespace/db ./schema/credits export.

credit-core suite green; consume/backfill suites + shells typecheck clean.
(consume/backfill vitest suites execute in CI — they import the new
@pagespace/db/schema/credits subpath, which resolves to the pre-merge main
build inside the .pu worktree.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k 6 lib)

- credit-gate.ts: canConsumeAI(userId, tier) — fast single-read prepaid check
  over the pure evaluateGate; lazy-inits a balance row from the tier's monthly
  allowance on first request (how new free users get their trial credits).
  No Stripe in the hot path.
- requiresProSubscription now gates premium models for the FREE tier only; any
  paid tier with credits unlocks them (per founder decision).

credit-gate + premium change typecheck clean; credit-core suite still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er gating

Three defects surfaced in review of the prepaid-credits epic:

- credit-consume: the idempotent claim used onConflictDoNothing with only a
  column target, but the unique index on aiUsageLogId is PARTIAL
  (WHERE aiUsageLogId IS NOT NULL). Postgres can't infer a partial index as the
  conflict arbiter without its predicate, so every insert raised 42P10 — caught
  silently, so no credits were ever consumed. Restate the predicate via `where`.
- rate-limit-middleware: requiresProSubscription gated premium models by
  excluding 'free', silently granting access to any unrecognized tier string.
  Switch to a positive allowlist of paid tiers (pro/founder/business) so unknown
  tiers default to denied.
- packages/lib: drop the premature ./billing/credit-funding export (module does
  not exist yet; it resolved to a missing dist file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… only)

Per founder: free tier gets a generous $5/mo of credit value; the free-tier-only
premium gate keeps it on cheaper standard models, so real provider cost stays low.
Env-overridable via CREDIT_ALLOWANCE_FREE_CENTS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/db/drizzle/meta/0142_snapshot.json
#	packages/db/drizzle/meta/_journal.json
@vercel

vercel Bot commented Jun 1, 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 1, 2026 10:16pm

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR delivers a complete prepaid AI-credits billing system with a pure decision layer, two-bucket ledger schema, idempotent consumption flow, and reconciliation cron that integrates prepaid checks into AI request gating and logging.

Changes

Prepaid AI Credits Billing Bridge

Layer / File(s) Summary
Database Schema and Billing Design
plan.md, tasks/prepaid-credits-billing.md, packages/db/drizzle/0143_cool_mongu.sql, packages/db/drizzle/meta/_journal.json, packages/db/src/schema/credits.ts, packages/db/src/schema.ts, packages/db/package.json
Migration adds credit_balances (per-user monthly/top-up remaining and allowance cents) and credit_ledger (append-only audit trail keyed by aiUsageLogId/stripeRef with partial unique indexes). ORM schema and package exports follow. Comprehensive design documentation specifies the prepaid model, pure decision-layer invariants, consume/reconcile flows, and integration points.
Pure Decision Layer and Pricing
packages/lib/src/billing/credit-core.ts, packages/lib/src/billing/credit-pricing.ts, packages/lib/src/billing/__tests__/credit-core.test.ts
Implements deterministic billing functions (cost markup, spend allocation monthly-first, gate evaluation with reserve floor, monthly refill, top-up application, Stripe event routing, backfill action planning) as a zero-I/O module enforced by test. Pricing module exports tier allowances, markup basis points, reserve floor, and credit pack definitions.
Credit Gate and Pre-Request Eligibility
packages/lib/src/billing/credit-gate.ts, packages/lib/src/billing/__tests__/credit-gate.test.ts
canConsumeAI reads denormalized credit balances, lazy-initializes missing balance rows via tier allowance, and returns allow/deny decisions with reason codes before AI requests proceed. Covers billing-disabled bypass and insufficient-credit scenarios.
Idempotent Credit Consumption and Settlement
packages/lib/src/billing/credit-consume.ts, packages/lib/src/billing/__tests__/credit-consume.test.ts
consumeCredits claims ledger entries using onConflictDoNothing keyed by aiUsageLogId (idempotent), then transactionally locks and decrements per-user balances (monthly-first), marking the ledger row applied. settlePendingLedgerRow retries stuck pending transactions. Both catch and log failures without throwing to allow backfill recovery.
Backfill Reconciliation and Cron Route
packages/lib/src/billing/credit-backfill.ts, packages/lib/src/billing/__tests__/credit-backfill.test.ts, apps/web/src/app/api/cron/reconcile-credits/route.ts
backfillCredits queries pending ledger rows (older than grace window) and orphan usage logs (successful, cost-present, unlisted), routes them to settlement or consumption via computed actions, and returns retry/orphan counts. Cron route authenticates requests, calls backfillCredits, logs results to audit trail, and returns timestamped success/error responses.
AI Monitoring and Logging Integration
packages/lib/src/logging/logger-database.ts, packages/lib/src/logging/__tests__/logger-database.test.ts, packages/lib/src/monitoring/ai-monitoring.ts, packages/lib/src/monitoring/__tests__/ai-monitoring.test.ts
writeAiUsage now returns the persisted usage log id for idempotency or null on failure. trackAIUsage defers credit consumption until after successful persistence, calling consumeCredits with the returned usage-log id only when the AI request succeeded.
Subscription Tier Allowlist Gating
apps/web/src/lib/subscription/rate-limit-middleware.ts, apps/web/src/lib/subscription/__tests__/rate-limit-middleware.test.ts
requiresProSubscription now uses a positive allowlist (pro, founder, business) and defaults unknown tiers to requiring Pro subscription, replacing exemption-based logic.
Package Exports and Config
packages/lib/package.json, apps/processor/tsconfig.json
Adds billing subpath exports (./billing/credit-core, ./billing/credit-gate, ./billing/credit-backfill, ./billing/credit-pricing) to public API. Updates processor tsconfig to exclude test files and __tests__ directories.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • 2witstudios/PageSpace#1088: Both PRs modify packages/lib/package.json's exports map to add new subpath entrypoints (this PR adds four billing/* subpaths for the new credit modules).

🐰 A ledger of credits takes its place,
Monthly buckets, top-ups in their space,
Stripe-fed, user-bounded, always just,
Pure decisions hold our billing trust! ✨💳

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.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 title accurately describes the main change: implementing a prepaid AI-credits billing bridge covering the core components (pure decision layer, pricing, schema, consume, reconcile cron, and gating logic).
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/metered-billing

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: e6daebbb44

ℹ️ 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-consume.ts Outdated
processor/tsconfig included ../../packages/**/* under module:commonjs and, unlike
its siblings (realtime excludes *.test.ts; web uses esnext), did not exclude test
files. The billing purity tests use `import.meta.url`, which tsc rejects under
commonjs (TS1343), breaking the "Lint & TypeScript Check" and "Static Security
Analysis" CI jobs (both run the full turbo typecheck). tsc --noEmit never needs
test files — vitest owns test typechecking — so exclude them, matching the
realtime convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
decrementAndSettle skipped the balance update when a user had no credit_balances
row but still marked the ledger row 'applied'. Since the row was then neither
'pending' nor an orphan, the reconcile cron never retried it, so a successful AI
call was permanently un-deducted — a silent revenue leak for existing users
post-migration (trackAIUsage calls consumeCredits directly while the lazy-init
gate canConsumeAI is not yet wired into AI routes).

Bail out before marking 'applied' when no balance row exists, leaving the ledger
'pending' so the cron settles it once a balance is created. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… follow-up

- Update decrementAndSettle docblock to reflect the missing-balance bail-out
  (leaves the ledger 'pending') instead of the stale "persist if a row exists".
- Note in the epic that api/cron/reconcile-credits still needs registering in the
  deploy repo's scheduler (PageSpace-Deploy/fly.cron.toml) for backfill to run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/lib/src/monitoring/ai-monitoring.ts (1)

603-644: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip credit consumption when the computed charge is zero.

calculateCost() falls back to AI_PRICING.default for unknown models, so this path now records successful calls as fully “billed” even when the price table misses a model and cost === 0. That turns pricing drift into silent underbilling and creates useless zero-amount ledger rows.

Suggested guard
-    }).then((aiUsageLogId) => {
-      if (aiUsageLogId && success) {
+    }).then((aiUsageLogId) => {
+      if (aiUsageLogId && success && cost > 0) {
         void consumeCredits({ aiUsageLogId, userId: data.userId, costDollars: cost })
           .catch((error) => {
             loggers.ai.debug('credit consume failed', { error: (error as Error).message });
           });
+      } else if (aiUsageLogId && success && cost === 0) {
+        loggers.ai.debug('skipping credit consume for zero-cost AI usage', {
+          model: data.model,
+          provider: data.provider,
+        });
       }
     }).catch((error) => {
🤖 Prompt for 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.

In `@packages/lib/src/monitoring/ai-monitoring.ts` around lines 603 - 644, The
code currently calls consumeCredits for any successful call even when
calculateCost returns 0; update the writeAiUsage.then(...) callback to only call
consumeCredits when success is true AND cost > 0 (or cost !== 0 if you prefer
strict). Reference the variables/functions writeAiUsage, calculateCost, cost and
consumeCredits: add a guard like if (aiUsageLogId && success && cost > 0) before
invoking consumeCredits so zero-cost calls are not billed or sent to the ledger
(optionally log that consumption was skipped for cost === 0).
🧹 Nitpick comments (3)
apps/web/src/lib/subscription/rate-limit-middleware.ts (1)

101-104: ⚡ Quick win

Reuse the module-level allowlist here.

This duplicates and shadows the exported PAID_TIERS, so the gating rule can drift from the shared constant later. Use the existing Set and has() instead.

♻️ Proposed fix
-  const PAID_TIERS = ['pro', 'founder', 'business'];
-  return !subscriptionTier || !PAID_TIERS.includes(subscriptionTier);
+  return !subscriptionTier || !PAID_TIERS.has(subscriptionTier);
🤖 Prompt for 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.

In `@apps/web/src/lib/subscription/rate-limit-middleware.ts` around lines 101 -
104, The code creates a local array PAID_TIERS that duplicates and shadows the
module-level exported allowlist; remove the local const and use the shared Set's
has() method instead (replace the local declaration and the includes check with
a call to PAID_TIERS.has(subscriptionTier)), e.g. change the return to use
!subscriptionTier || !PAID_TIERS.has(subscriptionTier) so the middleware reuses
the exported PAID_TIERS Set.
packages/lib/src/billing/credit-backfill.ts (1)

72-77: ⚡ Quick win

Consider raising log level from debug to warn for backfill failures.

Backfill action failures indicate stuck ledger rows or orphan usage that couldn't be reconciled—these are billing integrity issues worth surfacing in production logs. debug level typically won't appear unless explicitly enabled.

Proposed change
     } catch (error) {
-      loggers.ai.debug('credit backfill action failed', {
+      loggers.ai.warn('credit backfill action failed', {
         error: (error as Error).message,
         action,
       });
🤖 Prompt for 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.

In `@packages/lib/src/billing/credit-backfill.ts` around lines 72 - 77, The catch
block in credit-backfill.ts currently logs failures with loggers.ai.debug which
may be suppressed; change it to loggers.ai.warn to surface billing integrity
issues in production, preserving the same payload (include (error as
Error).message and action) and keep the existing contextual message ('credit
backfill action failed') so that the error and action remain visible for
functions handling credit backfills.
packages/lib/src/billing/__tests__/credit-gate.test.ts (1)

49-56: ⚡ Quick win

Add a regression test for lazy-init conflict/no-op behavior.

Please add a case where the first read returns no row, insert no-ops (conflict), and the subsequent decision is based on the persisted row (not assumed tier defaults).

🤖 Prompt for 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.

In `@packages/lib/src/billing/__tests__/credit-gate.test.ts` around lines 49 - 56,
Add a new test that simulates the lazy-init race where the initial select
returns no row, the insert call is a conflict/no-op, and then a subsequent
select returns the existing persisted row so the decision uses that row rather
than tier defaults: mock mockDb.select to return selectReturning([]) on the
first call and selectReturning([persistedRow]) on the next call, mock
mockDb.insert to return an insert conflict/no-op response (e.g.,
zero-rows-affected) via a helper similar to insertChain, call
canConsumeAI('u1','pro') and assert that mockDb.insert was invoked, the returned
decision (r.allowed / r.reason and any quota fields) matches what you expect
from persistedRow, and not the tier defaults; reference canConsumeAI,
mockDb.select, mockDb.insert, selectReturning and insertChain (or a new
insertConflict helper) to implement the mocks.
🤖 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/db/src/schema/credits.ts`:
- Around line 15-19: Add DB-level CHECK constraints on the credit fields to
prevent invalid state: ensure monthlyRemainingCents >= 0, monthlyAllowanceCents
>= 0, topupRemainingCents >= 0, and add a constraint that when both
monthlyPeriodStart and monthlyPeriodEnd are non-null then monthlyPeriodStart <=
monthlyPeriodEnd; implement these checks in the schema definition that contains
monthlyRemainingCents, monthlyAllowanceCents, topupRemainingCents,
monthlyPeriodStart and monthlyPeriodEnd (e.g., using table().check(...) or the
dialect's check helper) and then generate and run the migration with bun run
db:generate to apply the changes.
- Line 32: The creditLedger table's userId foreign key currently uses onDelete:
'cascade' which will delete ledger rows when a user row is removed; change this
to a non-cascading behavior (e.g., onDelete: 'NO ACTION' or onDelete: 'RESTRICT'
as supported by your DB/ORM) so ledger/audit rows remain immutable; update the
reference on text('userId').notNull().references(() => users.id, { onDelete:
'cascade' }) in the creditLedger schema to use the non-cascading option and
ensure account deletions are handled via soft-delete/anonymization instead.
- Around line 18-20: Change the timestamp columns to use timestamptz by adding
withTimezone: true to the timestamp definitions for monthlyPeriodStart,
monthlyPeriodEnd and updatedAt in the credits schema (and also update
creditLedger.createdAt where it's defined) so they become timestamp(..., { mode:
'date', withTimezone: true }); after updating those fields regenerate the DB
migrations (run bun run db:generate) to produce the correct PostgreSQL
timestamptz migrations.

In `@packages/lib/package.json`:
- Around line 50-69: Add matching typesVersions entries for each new billing
subpath so TypeScript with moduleResolution: "node" can find .d.ts files;
specifically update the package.json "typesVersions" mapping to include keys for
"./billing/credit-pricing", "./billing/credit-gate",
"./billing/credit-backfill", and "./billing/credit-core" (and any other
billing/* export) pointing to their corresponding "./dist/billing/*.d.ts"
declarations (e.g., map imports like "`@pagespace/lib/billing/credit-backfill`" to
the dist path), ensuring the names match the export keys in the package.json
exports section.

In `@packages/lib/src/billing/credit-consume.ts`:
- Around line 78-82: In consumeCredits(), validate input.costDollars is a finite
number >= 0 before computing amountCents and realCostCents (used in markupCents
and ledger claim creation); if it's NaN/Infinity or negative, throw a clear
error (or early return) to prevent creating malformed claims — add this guard at
the top of the function (before calling markupCents and computing realCostCents)
and reference the variables amountCents and realCostCents in the check so
downstream ledger/settlement logic only sees valid values.

In `@packages/lib/src/billing/credit-core.ts`:
- Around line 149-152: The code allows malformed packCents like "2500usd"
because Number.parseInt will accept prefixes; update the branch that returns {
kind: 'topup', packCents } to first verify obj.metadata?.packCents is a
canonical integer string (e.g., /^\d+$/) before parsing, then parse
(Number.parseInt) and keep the existing Number.isFinite and >0 checks; adjust
the condition around obj.mode === 'payment' / obj.metadata?.kind ===
'credit_pack' so only strictly-digit packCents strings are accepted (referencing
obj.metadata.packCents and the topup return).

In `@packages/lib/src/billing/credit-gate.ts`:
- Around line 43-60: When lazy-initializing creditBalances with
db.insert(...).onConflictDoNothing you may be evaluating the gate with the
assumed "monthly" value rather than the actual persisted row if a concurrent
init won the race; after the insert attempt (the block that sets result and
calls evaluateGate) re-read the creditBalances row for userId from the DB and
recompute result by calling evaluateGate with the persisted balance
(monthly/topup) and reserveFloorCents so the decision uses the stored values
instead of the assumed ones.

In `@packages/lib/src/billing/credit-pricing.ts`:
- Around line 12-17: The envInt function currently uses Number.parseInt which
tolerates trailing junk and can accept malformed env values; update envInt to
validate raw strictly (e.g. ensure raw matches /^\d+$/ or /^\s*\d+\s*$/ trimmed)
before parsing, then parse with base 10 and return fallback on any non-match or
negative result—modify the envInt implementation to perform the regex check on
process.env[name] (or trim then check) and only then call Number.parseInt(raw,
10) (or Number(raw)) so malformed strings no longer override billing config.

In `@packages/lib/src/monitoring/__tests__/ai-monitoring.test.ts`:
- Around line 232-236: The test mocks writeAiUsage with undefined but the
function now resolves string | null; update the mock in the test for
trackAIUsage to return null instead of undefined (change
mockWriteAiUsage.mockResolvedValueOnce(undefined) to
mockResolvedValueOnce(null)) so the test matches the writeAiUsage contract and
still asserts that mockConsumeCredits is not called; locate the mock in the
ai-monitoring.test where mockWriteAiUsage and trackAIUsage are used.

In `@tasks/prepaid-credits-billing.md`:
- Line 8: The document references a machine-local path
"/Users/jono/.claude/plans/the-plan-was-to-zesty-walrus.md" which is not
available to other contributors; update the note in
tasks/prepaid-credits-billing.md to point to a repo-relative location (e.g.,
docs/design/prepaid-credits.md) or move the full design into the repo and
reference that path instead so everyone can access it; ensure the phrase "full
design lives in ..." is replaced with the new repo-relative path and commit the
moved/added design file alongside the change.

---

Outside diff comments:
In `@packages/lib/src/monitoring/ai-monitoring.ts`:
- Around line 603-644: The code currently calls consumeCredits for any
successful call even when calculateCost returns 0; update the
writeAiUsage.then(...) callback to only call consumeCredits when success is true
AND cost > 0 (or cost !== 0 if you prefer strict). Reference the
variables/functions writeAiUsage, calculateCost, cost and consumeCredits: add a
guard like if (aiUsageLogId && success && cost > 0) before invoking
consumeCredits so zero-cost calls are not billed or sent to the ledger
(optionally log that consumption was skipped for cost === 0).

---

Nitpick comments:
In `@apps/web/src/lib/subscription/rate-limit-middleware.ts`:
- Around line 101-104: The code creates a local array PAID_TIERS that duplicates
and shadows the module-level exported allowlist; remove the local const and use
the shared Set's has() method instead (replace the local declaration and the
includes check with a call to PAID_TIERS.has(subscriptionTier)), e.g. change the
return to use !subscriptionTier || !PAID_TIERS.has(subscriptionTier) so the
middleware reuses the exported PAID_TIERS Set.

In `@packages/lib/src/billing/__tests__/credit-gate.test.ts`:
- Around line 49-56: Add a new test that simulates the lazy-init race where the
initial select returns no row, the insert call is a conflict/no-op, and then a
subsequent select returns the existing persisted row so the decision uses that
row rather than tier defaults: mock mockDb.select to return selectReturning([])
on the first call and selectReturning([persistedRow]) on the next call, mock
mockDb.insert to return an insert conflict/no-op response (e.g.,
zero-rows-affected) via a helper similar to insertChain, call
canConsumeAI('u1','pro') and assert that mockDb.insert was invoked, the returned
decision (r.allowed / r.reason and any quota fields) matches what you expect
from persistedRow, and not the tier defaults; reference canConsumeAI,
mockDb.select, mockDb.insert, selectReturning and insertChain (or a new
insertConflict helper) to implement the mocks.

In `@packages/lib/src/billing/credit-backfill.ts`:
- Around line 72-77: The catch block in credit-backfill.ts currently logs
failures with loggers.ai.debug which may be suppressed; change it to
loggers.ai.warn to surface billing integrity issues in production, preserving
the same payload (include (error as Error).message and action) and keep the
existing contextual message ('credit backfill action failed') so that the error
and action remain visible for functions handling credit backfills.
🪄 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: 3a26cd12-bb23-4321-8077-1e8303f7d5ba

📥 Commits

Reviewing files that changed from the base of the PR and between e9bc6f1 and 0fed074.

📒 Files selected for processing (26)
  • apps/processor/tsconfig.json
  • apps/web/src/app/api/cron/reconcile-credits/route.ts
  • apps/web/src/lib/subscription/__tests__/rate-limit-middleware.test.ts
  • apps/web/src/lib/subscription/rate-limit-middleware.ts
  • packages/db/drizzle/0143_cool_mongu.sql
  • packages/db/drizzle/meta/0143_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/package.json
  • packages/db/src/schema.ts
  • packages/db/src/schema/credits.ts
  • packages/lib/package.json
  • packages/lib/src/billing/__tests__/credit-backfill.test.ts
  • packages/lib/src/billing/__tests__/credit-consume.test.ts
  • packages/lib/src/billing/__tests__/credit-core.test.ts
  • packages/lib/src/billing/__tests__/credit-gate.test.ts
  • packages/lib/src/billing/credit-backfill.ts
  • packages/lib/src/billing/credit-consume.ts
  • packages/lib/src/billing/credit-core.ts
  • packages/lib/src/billing/credit-gate.ts
  • packages/lib/src/billing/credit-pricing.ts
  • packages/lib/src/logging/__tests__/logger-database.test.ts
  • packages/lib/src/logging/logger-database.ts
  • packages/lib/src/monitoring/__tests__/ai-monitoring.test.ts
  • packages/lib/src/monitoring/ai-monitoring.ts
  • plan.md
  • tasks/prepaid-credits-billing.md

Comment thread packages/db/src/schema/credits.ts Outdated
Comment thread packages/db/src/schema/credits.ts Outdated
Comment thread packages/db/src/schema/credits.ts
Comment thread packages/lib/package.json
Comment thread packages/lib/src/billing/credit-consume.ts
Comment thread packages/lib/src/billing/credit-core.ts Outdated
Comment thread packages/lib/src/billing/credit-gate.ts Outdated
Comment thread packages/lib/src/billing/credit-pricing.ts
Comment thread packages/lib/src/monitoring/__tests__/ai-monitoring.test.ts
Comment thread tasks/prepaid-credits-billing.md Outdated
2witstudios and others added 2 commits June 1, 2026 17:12
…hema, types)

Addresses CodeRabbit review threads on PR #1471:
- credit-gate: re-evaluate from the PERSISTED balance after lazy-init instead of
  the assumed allowance, so a concurrent init that already drew the row down
  can't yield a false allow. (+ race regression test)
- credit-consume: guard non-finite/negative costDollars before claiming a ledger
  row. (+ test)
- credit-core: strict packCents parsing — reject "2500usd"/0/missing. (+ tests)
- credit-pricing: strict envInt — reject trailing junk/decimals so a typo'd
  billing env var falls back to the safe default.
- schema/credits: store timestamps as timestamptz (matches newer time-sensitive
  tables) and declare non-negative / period-order CHECK invariants. NOTE:
  drizzle-kit 0.23.2 does not emit CHECK DDL (no table in the snapshot has it,
  incl. the existing core.ts favorites check) — kept as forward-compatible intent
  matching that precedent; runtime non-negativity is guaranteed by allocateSpend
  clamping + the new cost guard. Migration regenerated as 0143_yielding_praxagora.
- package.json: add typesVersions entries for the billing/* subpath exports.
- ai-monitoring.test: mock null (writeAiUsage resolves string | null).
- docs: drop machine-local design-plan path from the epic.

Declined (with reasoning in PR threads): switching creditLedger off ON DELETE
CASCADE — cascade is the dominant repo convention (136 uses) and RESTRICT would
break the existing user-deletion flow; ledger retention/anonymization is a
compliance follow-up for the funding task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
Preview — 6aa81e9b Deployed Jun 1, 2026 by vercel[bot]
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