Repository navigation
feat(billing): prepaid AI-credits billing bridge (epic tasks 1–4, 6-lib) - #1471
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis 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. ChangesPrepaid AI Credits Billing Bridge
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 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)
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: 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".
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>
There was a problem hiding this comment.
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 winSkip credit consumption when the computed charge is zero.
calculateCost()falls back toAI_PRICING.defaultfor unknown models, so this path now records successful calls as fully “billed” even when the price table misses a model andcost === 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 winReuse 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 existingSetandhas()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 winConsider raising log level from
debugtowarnfor 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.
debuglevel 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 winAdd 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
📒 Files selected for processing (26)
apps/processor/tsconfig.jsonapps/web/src/app/api/cron/reconcile-credits/route.tsapps/web/src/lib/subscription/__tests__/rate-limit-middleware.test.tsapps/web/src/lib/subscription/rate-limit-middleware.tspackages/db/drizzle/0143_cool_mongu.sqlpackages/db/drizzle/meta/0143_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/package.jsonpackages/db/src/schema.tspackages/db/src/schema/credits.tspackages/lib/package.jsonpackages/lib/src/billing/__tests__/credit-backfill.test.tspackages/lib/src/billing/__tests__/credit-consume.test.tspackages/lib/src/billing/__tests__/credit-core.test.tspackages/lib/src/billing/__tests__/credit-gate.test.tspackages/lib/src/billing/credit-backfill.tspackages/lib/src/billing/credit-consume.tspackages/lib/src/billing/credit-core.tspackages/lib/src/billing/credit-gate.tspackages/lib/src/billing/credit-pricing.tspackages/lib/src/logging/__tests__/logger-database.test.tspackages/lib/src/logging/logger-database.tspackages/lib/src/monitoring/__tests__/ai-monitoring.test.tspackages/lib/src/monitoring/ai-monitoring.tsplan.mdtasks/prepaid-credits-billing.md
…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>
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)
credit-core.ts) — zero-I/O billing math (markupCents,allocateSpend,evaluateGate,computeMonthlyRefill,applyTopup,classifyStripeEvent,computeBackfillActions). Purity enforced by a test.credit-pricing.ts) — env-overridable markup, per-tier allowances (free = $5, cheaper-models-only), reserve floor, credit packs.schema/credits.ts,0143_cool_mongu.sql) —credit_balances(two buckets) + append-onlycredit_ledgerwith partial unique indexes onaiUsageLogIdandstripeRef(exactly-once guarantees).credit-consume.ts) — idempotent claim-then-settle decrement wired intotrackAIUsage;writeAiUsagenow returns the row id as the idempotency key. Never throws into the AI request.api/cron/reconcile-credits,credit-backfill.ts) — HMAC-signed, local-only sweep that re-settlespendingrows and consumes orphan usage rows exactly once.credit-gate.ts) — fast pre-requestcanConsumeAIoverevaluateGate, with lazy balance init from tier defaults. (Wiring into AI handlers + the 402 response is task 6's exposure half — not in this PR.)rate-limit-middleware.ts) — premium models open to any paid tier; positive allowlist so unknown tiers default to denied.Review fixes already incorporated (
fb823ec7d)WHERE aiUsageLogId IS NOT NULL) so Postgres can infer theON CONFLICTarbiter. Without it, every insert raised42P10, was caught silently, and no credits were ever consumed.'free'to a positive paid-tier allowlist../billing/credit-fundingexport 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/libunit tests: 143 pass (billing, monitoring, logging).webrate-limit-middlewaretests: 8 pass (incl. founder-allowed + unknown-tier-denied).@pagespace/db+@pagespace/libbuild green;db:generatereproduces the migration cleanly.master; resolved a migration-number collision (0142↔ master's0142_sparkling_maverick) by regenerating ours as0143. No conflicts.Known follow-up
No integration test exercises real Postgres yet — the partial-index
ON CONFLICTsemantics (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)
master; our0142_sleepy_magmacollided with master's0142_sparkling_maverick, so regenerated the credits migration as0143_cool_monguviadb:generate. No conflicts.bce0d2086):apps/processor/tsconfig.jsontypechecked sibling-package test files undermodule:commonjs; the billing purity tests'import.meta.urlbroke "Lint & TypeScript Check" + "Static Security Analysis". Excluded test files from processor's typecheck (matches realtime's convention).d968c0191):consumeCreditsmarked the ledgerappliedeven when nocredit_balancesrow existed, so the charge was neither deducted nor retried by the cron. Now leaves the rowpendinguntil a balance exists. Regression test added. (Reported by Codex; thread resolved.)Operational follow-up (deploy repo)
api/cron/reconcile-creditsis a signed endpoint only; like all sibling cron routes, its schedule lives inPageSpace-Deploy(fly/fly.cron.toml/pagespace-cronimage), where it is not yet registered. It must be added there for the backfill to run — the "pendingledger rows settle once a balance exists" guarantee depends on it.Summary by CodeRabbit
New Features
Bug Fixes
Review round 2 (CodeRabbit / Codex — all threads resolved)
b88557cd1):canConsumeAIre-selects the persisted balance after lazy-init and evaluates against it (no false allow under concurrent init). + race test.b88557cd1): reject non-finite/negativecostDollarsbefore claiming. + test.classifyStripeEventpackCents (/^\d+$/, rejects"2500usd") andenvInt(rejects trailing junk/decimals). + tests.timestamptz; non-negative + period-ordercheck()invariants declared (drizzle-kit 0.23.2 doesn't emit CHECK DDL — kept as forward-compat matching the existingcore.tsprecedent; runtime non-negativity guaranteed byallocateSpend+ cost guard). Migration regenerated0143_yielding_praxagora.typesVersionsentries added forbilling/*exports.writeAiUsagemock →null; removed machine-local design path.creditLedgerON DELETE CASCADE(dominant repo convention; RESTRICT breaks user deletion) — ledger retention/anonymization recorded as a follow-up.