Skip to content

fix(billing): durable AI-usage persistence + stop $0 ledger churn - #1476

Merged
2witstudios merged 2 commits into
masterfrom
pu/audit-metering
Jun 2, 2026
Merged

2witstudios merged 2 commits into
masterfrom
pu/audit-metering

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 1, 2026 •

Copy link
Copy Markdown
Owner

Why

Follow-up to the prepaid AI-credits audit (epic #1471). PR #1475 (pu/audit-leaks) fixed the metering leaks — unmetered call sites and the resolved-model-name $0 bug. This PR fixes two persistence/correctness gaps that #1475 left untouched. Both are pure correctness/safety — no billing-policy change (failed calls are still not billed; markup/allowances unchanged).

What's fixed

1. Durability — usage log + charge could be silently dropped (was HIGH)

trackAIUsage detached the writeAiUsage(...).then(consumeCredits) chain and returned before it settled. Call sites await trackAIUsage from a stream onFinish / post-response handler — but awaiting it only awaited the synchronous body; the DB write was still fire-and-forget. If a serverless function froze/returned first, both the aiUsageLogs row and the credit charge were lost, and because the reconcile cron's orphan sweep keys off aiUsageLogs, there was nothing left to recover from — the charge was gone permanently.

Now the writeAiUsage → consumeCredits chain is awaited, so the usage log is durable before the request returns. It still never throws into the AI request (the surrounding try/catch is preserved).

packages/lib/src/monitoring/ai-monitoring.ts

2. $0 ledger churn — needless balance transaction per free/tool call (was MEDIUM)

A free/local model, or a tool-only analytics log carrying no tokens (trackAIToolUsage → trackAIUsage with no usage → cost 0), produced amountCents === 0 but consumeCredits still opened the balance transaction, took the FOR UPDATE row lock, ran a $0 decrement, and wrote a misleading applied/monthly ledger row. In an agent tool loop that serialized N no-op locks on the user's balance row.

Now a zero-charge call settles the claimed row as skipped without the balance transaction (no lock, no $0 decrement). The claim row still exists, so the orphan sweep stays idempotent and never re-processes it.

packages/lib/src/billing/credit-consume.ts

Testing

  • +1 durability test: asserts consumeCredits has not run before writeAiUsage resolves, then runs once the awaited trackAIUsage resolves — with no setTimeout(0) flush, which a fire-and-forget chain would fail.
  • +1 zero-charge test: costDollars: 0 ⇒ no db.transaction, ledger row set to consumeStatus: 'skipped'.
  • credit-consume + ai-monitoring suites: 76 passed.
  • Typecheck: zero new errors from these changes (the tx implicit-any noise at credit-consume.ts is pre-existing worktree tsc-resolution noise on the unchanged Tx-derived functions, identical on master).
  • Lint: clean on changed source files.

Deferred (need a product/architecture decision, not unilateral)

Documented in the audit but intentionally not changed here:

  • success:false drops real provider spend on errored/aborted streams — charging for failed generations is a billing-policy call (fix(billing): meter all AI provider calls and bill resolved model names #1475 also punted it).
  • Reserve floor 0 + no per-user in-flight concurrency cap — bounds how far one user can front unpaid usage; needs a chosen floor + concurrency limit.
  • Sub-cent rounding to 0 — high-volume cheap calls each round their markup to 0¢; needs a fractional-cent/millicent accumulation design.
  • bucket majority-label on split monthly/topup spends (cosmetic unless reporting reconciles by bucket).

Relationship to #1475

Independent branch off master. Touches different regions of ai-monitoring.ts (the trackAIUsage body, not the AI_PRICING table #1475 edits) and credit-consume.ts (not touched by #1475) — merges cleanly in either order.

🤖 Generated with Claude Code

Update (Codex P2): trackAIToolUsage now returns the trackAIUsage promise rather than discarding it, so the durability guarantee also covers the tool-call analytics path (a caller that awaits trackToolUsage now waits for the write to persist). +1 test.

Two metering-correctness gaps left open by #1475 (audit-leaks), found in the
prepaid AI-credits audit. Both are pure correctness/safety; no billing-policy
change.

1. Durability (was fire-and-forget). `trackAIUsage` detached the
   writeAiUsage -> consumeCredits chain with `.then()` and returned before it
   settled. Callers `await` trackAIUsage from a stream onFinish / post-response
   handler, but a serverless freeze could drop the detached promise — losing
   BOTH the usage log AND the charge. With no aiUsageLogs row, the reconcile
   cron's orphan sweep has nothing to recover from, so the charge is gone for
   good. Now the chain is awaited, so the write is durable before the request
   returns. Still never throws into the AI request.

2. $0 ledger churn. A free/local model — or a tool-only analytics log with no
   tokens (trackAIToolUsage) — produced amountCents 0, yet consumeCredits still
   opened a balance transaction, took the row lock, and ran a $0 decrement,
   writing a misleading "applied/monthly" ledger row per call. In an agent tool
   loop that serialized N no-op locks on the user's balance row. Now a
   zero-charge call settles the claimed row as 'skipped' without the balance
   transaction. The claim row still exists, so the orphan sweep stays idempotent
   and never re-processes it.

Tests: +1 durability test (asserts consume runs before the awaited trackAIUsage
resolves, no setTimeout flush) and +1 zero-charge test (no transaction, row
marked 'skipped'). credit-consume + ai-monitoring suites: 76 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@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 11:59pm

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 13 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4643629f-684b-4d04-b0a9-96edbe60c3b1

📥 Commits

Reviewing files that changed from the base of the PR and between edf1233 and 1b5ed92.

📒 Files selected for processing (4)
  • packages/lib/src/billing/__tests__/credit-consume.test.ts
  • packages/lib/src/billing/credit-consume.ts
  • packages/lib/src/monitoring/__tests__/ai-monitoring.test.ts
  • packages/lib/src/monitoring/ai-monitoring.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/audit-metering

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

ℹ️ 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/monitoring/ai-monitoring.ts
Addresses Codex review (P2) on #1476: trackAIToolUsage called trackAIUsage
without returning/awaiting it, so a caller that `await`s trackToolUsage resolved
immediately — the durability guarantee didn't reach tool-analytics logs, and the
writeAiUsage / zero-charge ledger settlement could still be dropped on a
serverless freeze after onFinish.

trackAIToolUsage now RETURNS the trackAIUsage promise (no longer an async wrapper
that discards it), so awaiting it waits for the log to persist. +1 test asserting
the returned promise stays pending until writeAiUsage settles.

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

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@2witstudios
2witstudios merged commit 6a823cc into master Jun 2, 2026
5 checks passed
@2witstudios
2witstudios deleted the pu/audit-metering branch June 2, 2026 02:19
2witstudios added a commit that referenced this pull request Jun 3, 2026
…econcile, accounting + e2e (#1484)

* fix(billing): meter all AI provider calls and bill resolved model names

Close revenue leaks where AI provider calls bypassed credit metering or
recorded $0 cost. Billing only happens via AIMonitoring.trackUsage →
consumeCredits with a real userId and a model present in AI_PRICING.

Unmetered call sites — add trackUsage (real userId was already in scope):
- ask_agent (agent-communication-tools): the largest leak — a user-triggerable
  tool loop of up to stepCountIs(20) round-trips, never billed; also reached by
  every channel @mention of an agent. Returns full ProviderResult from
  getConfiguredModel and meters response.totalUsage so every round-trip counts.
- Memory discovery/integration/compaction: run per active user on memory cron,
  on the expensive pro/glm-5 tier; discovery fires 3 passes/run.
- Zoom extract-action-items and generate-summary: per webhook.

Mis-metered ($0) call sites — track the resolved providerResult.modelName
instead of the raw stored model (PageSpace tier aliases 'standard'/'pro' and
the unpriced default 'glm-4.5-air' all hashed to AI_PRICING.default = $0):
- /api/v1/chat/completions (was page.aiModel ?? 'unknown')
- page-agents/consult (was agent.aiModel || 'glm-4.5-air'); also switch to
  result.totalUsage since it is a stepCountIs(100) tool loop
- /api/ai/chat (was raw currentModel)

Catalog↔pricing drift:
- Add 'glm-4.5-air' to AI_PRICING (0.35/1.55, matching z-ai/glm-4.5-air); it was
  selectable via the glm provider but unpriced, so it metered at $0.

Correctly-metered paths (global assistant, pulse generate/cron, workflow
executor) already used providerResult.modelName and are unchanged.

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

* test(billing): lock in metering for ask_agent and glm-4.5-air pricing

Regression guards for the leak fixes in this PR:
- ai-monitoring: assert PageSpace-tier backend models (glm-4.5-air, glm-4.7,
  glm-5) all price above $0, and that glm-4.5-air bills at its published rate.
  Catches future catalog↔pricing drift that would meter at $0.
- agent-communication-tools: assert ask_agent bills the requesting user against
  the resolved model name (glm-5) using totalUsage (all tool-loop round-trips),
  proving the previously-unmetered path is now metered.

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

* fix(billing): make AI-usage persistence durable and stop $0 ledger churn

Two metering-correctness gaps left open by #1475 (audit-leaks), found in the
prepaid AI-credits audit. Both are pure correctness/safety; no billing-policy
change.

1. Durability (was fire-and-forget). `trackAIUsage` detached the
   writeAiUsage -> consumeCredits chain with `.then()` and returned before it
   settled. Callers `await` trackAIUsage from a stream onFinish / post-response
   handler, but a serverless freeze could drop the detached promise — losing
   BOTH the usage log AND the charge. With no aiUsageLogs row, the reconcile
   cron's orphan sweep has nothing to recover from, so the charge is gone for
   good. Now the chain is awaited, so the write is durable before the request
   returns. Still never throws into the AI request.

2. $0 ledger churn. A free/local model — or a tool-only analytics log with no
   tokens (trackAIToolUsage) — produced amountCents 0, yet consumeCredits still
   opened a balance transaction, took the row lock, and ran a $0 decrement,
   writing a misleading "applied/monthly" ledger row per call. In an agent tool
   loop that serialized N no-op locks on the user's balance row. Now a
   zero-charge call settles the claimed row as 'skipped' without the balance
   transaction. The claim row still exists, so the orphan sweep stays idempotent
   and never re-processes it.

Tests: +1 durability test (asserts consume runs before the awaited trackAIUsage
resolves, no setTimeout flush) and +1 zero-charge test (no transaction, row
marked 'skipped'). credit-consume + ai-monitoring suites: 76 passed.

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

* fix(billing): extend usage-persistence durability to the tool-call path

Addresses Codex review (P2) on #1476: trackAIToolUsage called trackAIUsage
without returning/awaiting it, so a caller that `await`s trackToolUsage resolved
immediately — the durability guarantee didn't reach tool-analytics logs, and the
writeAiUsage / zero-charge ledger settlement could still be dropped on a
serverless freeze after onFinish.

trackAIToolUsage now RETURNS the trackAIUsage promise (no longer an async wrapper
that discards it), so awaiting it waits for the log to persist. +1 test asserting
the returned promise stays pending until writeAiUsage settles.

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

* fix(billing): schedule reconcile cron, drain backfill, bill errored-but-real AI spend

The "billed exactly once across crashes/deploys" guarantee had three holes:

W4a — reconcile cron unscheduled. /api/cron/reconcile-credits existed and was
HMAC-protected but absent from docker/cron/crontab, so nothing ever ran the
reconciliation. Added a signed GET every 10 minutes using the same cron-curl
pattern as the other ~16 jobs.

W4b — backfill never drained. backfillCredits() did a single LIMIT 200 pending
sweep + single LIMIT 200 orphan sweep; a backlog >200 silently left the rest.
It now loops until a pass returns fewer than BATCH from both sweeps (settled
rows drop out of the next query, so re-querying makes forward progress),
bounded by MAX_PASSES=50 as an unbounded-run backstop. GRACE_MS cutoff and the
isBillingEnabled() guard are unchanged; returns cumulative {retried, orphans}.

R1 — errored-but-real spend was dropped (deliberate billing-policy change).
Tokens consumed before a mid-stream error/abort are real provider cost, but
trackAIUsage only billed when success===true and the orphan sweep filtered
success=true, so an errored generation that produced tokens was logged with a
real cost and billed by neither path. Now:
  - trackAIUsage bills when aiUsageLogId && (success || totalTokens > 0). A
    token-less pre-generation failure still carries 0 tokens and is skipped;
    consumeCredits still settles a zero-charge call as 'skipped' (no $0 churn).
  - the orphan sweep reconciles success:false rows carrying cost > 0, and now
    filters gt(cost, 0) so no/zero-cost rows stay excluded.
The base PR intentionally left failed calls unbilled; the audit owner has
decided errored-but-real spend MUST be billed.

Tests: backfill drains a >200 backlog across passes, stops at the safety cap,
bills a success:false orphan with cost, and asserts the sweep no longer gates
on success; trackAIUsage bills an errored call with tokens but not a token-less
failure. @pagespace/lib typecheck clean; billing + ai-monitoring suites green.

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

* feat(billing): fund prepaid balances from Stripe (invoice.paid refill + credit-pack top-up)

The pure routing/arithmetic in credit-core (classifyStripeEvent,
computeMonthlyRefill, applyTopup) had zero callers and there was no funding
shell, so a paid invoice or credit-pack purchase never became spendable credit.
The webhook only logged invoice.paid and ignored credit-pack checkouts.

Add credit-funding.ts — an imperative shell that:
  - invoice.paid -> resets the monthly bucket to the tier allowance, rolls the
    billing window forward from the invoice period, and records a monthly_grant
    ledger row keyed on the invoice id.
  - checkout.session.completed (mode=payment, kind=credit_pack) -> adds the pack
    to the never-expiring top-up bucket via applyTopup, recording a topup_purchase
    ledger row keyed on the session id.

Exactly-once: each funding ledger insert uses onConflictDoNothing against the
partial unique index credit_ledger_stripe_ref_unique (predicate restated as the
arbiter), and the balance mutation only runs when that insert actually inserted —
so a redelivered Stripe event credits the balance exactly once. Ledger insert and
balance write share one transaction. Funding never throws into the webhook: a
failure is logged and swallowed; Stripe retry / the reconcile cron re-delivers.
No-op when billing is disabled (tenant/onprem) and for tier_change events (tier
persistence stays in handleSubscriptionChange; the next invoice.paid refills at
the new allowance).

Wire applyStripeFunding into the webhook for invoice.paid and
checkout.session.completed, additively — existing logging and subscription-tier
behavior are unchanged.

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

* fix(billing): enforce prepaid gate at AI entry points + log usage when metadata missing

Wire canConsumeAI() into every user-facing AI generation entry point so an
out-of-credits user is blocked (HTTP 402) before the model runs, instead of
the platform silently fronting the overage. Also fix the global-messages route
to always write an aiUsageLogs row (R4) so the orphan-sweep can recover/bill
calls where the provider returned no usage metadata.

Entry points gated (402 out_of_credits when !gate.allowed):
- api/ai/chat
- api/ai/global/[id]/messages
- api/v1/chat/completions
- api/ai/page-agents/consult
- api/pulse/generate (on-demand; cron path intentionally not gated)

R4: api/ai/global/[id]/messages now always calls AIMonitoring.trackUsage in
onFinish (0/undefined tokens are fine — $0 cost, but the log row exists).

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

* fix(billing): make credit-pack top-up funding race-safe; correct funding-failure docs

Self-review of the funding shell surfaced a lost-update on the top-up money path:
two concurrent first-time credit-pack purchases (distinct session ids, so their
ledger inserts don't serialize each other) would both SELECT ... FOR UPDATE on a
not-yet-existent balance row — locking nothing — both read 0, and the second
write would overwrite the first instead of adding to it, silently dropping a paid
top-up.

Fix: inside the funding transaction, ensure the balance row exists first
(INSERT ... ON CONFLICT DO NOTHING), then SELECT ... FOR UPDATE always locks a
real row, making the read-add-write atomic. applyTopup is still the source of the
new value; concurrent purchases now serialize on the row lock and both increments
apply. Add a first-time-buyer regression test alongside the existing add-to-
existing-balance test.

Also corrected the module/function docs: funding swallows its own errors (never
500s) and the webhook's coarse stripeEvents guard blocks same-event reprocessing,
so a failed funding event is not auto-recovered by Stripe retry. The previous
comment overclaimed retry/cron recovery; it now states failures are surfaced via
logs for operator follow-up.

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

* fix(billing): non-zero reserve floor, shortfall-as-debt, sub-cent remainder, gate-driven monthly reset

Closes four correctness gaps in the prepaid credit core:

R2a — Reserve floor default 0 -> 25¢, bounding the single in-flight call
that can overshoot zero (cost is only known post-stream). Env override kept.

R2b — Shortfall is no longer silently discarded. decrementAndSettle records
appliedCents (what actually left the balance) on the usage row and, when the
balance can't cover the charge, writes the uncovered remainder as a terminal
'adjustment' (debt) row in the same txn — visible, queryable by aiUsageLogId,
recoverable. Balances stay >= 0 (DB CHECK); debt lives in the ledger. The
usage-log unique index is scoped to entryType='usage' so the debt row can
share the call's aiUsageLogId.

R3 — Sub-cent costs no longer round to $0. Charges accrue in millicents into a
per-user pendingMillicents carry; each settle debits floor(pending/1000) whole
cents and banks the remainder. New pure core: chargeMillicents / accruePending
/ accrueCharge. No float ever reaches stored state.

W3-free — canConsumeAI now stamps a monthlyPeriod{Start,End} on lazy-init and,
when the window has expired, resets the monthly bucket to the tier allowance and
rolls the window forward — giving free/no-subscription users a monthly reset
without a cron. The reset UPDATE re-checks expiry in its WHERE so a racing
invoice.paid refill naturally wins.

Schema: + credit_balances.pendingMillicents, + credit_ledger.appliedCents,
+ credit_ledger.chargeMillicents; usage-log unique index scoped to 'usage'.
Migration 0144 generated (not hand-written).

Out of scope (tracked separately): per-user in-flight concurrency cap /
reservation, which requires threading a reservation id through routes+monitoring.

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

* fix(billing): mark funding ledger rows settled so the backfill cron can't claw them back

Critical: monthly_grant and topup_purchase rows inherited creditLedger's default
consumeStatus 'pending'. backfillCredits() sweeps EVERY pending ledger row through
settlePendingLedgerRow() -> decrementAndSettle(), which SUBTRACTS abs(amountCents)
from the balance (it exists to settle unsettled *usage* charges). A funding row has
a positive amountCents, so after the 5-minute grace period the cron would reverse
every grant/top-up — clawing back exactly the credit funding just added.

Funding applies its balance change in the same transaction as the ledger insert, so
the row is already settled the moment it is written. Insert funding rows with
consumeStatus 'applied' so the pending sweep skips them. Add assertions to the
monthly-refill and top-up tests pinning consumeStatus 'applied'.

Reported by Codex review (P1).

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

* test(billing): end-to-end credits-flow integration tests

Drive the real billing shells (applyStripeFunding, canConsumeAI,
consumeCredits, backfillCredits) wired together over one shared
in-memory DB, proving the prepaid money path fund→gate→consume→reconcile
works as a single system. Covers happy path, idempotency (aiUsageLogId +
stripeRef), crash recovery (pending settle, orphan sweep, success:false
billing, >BATCH multi-pass drain), monthly reset, sub-cent accrual,
shortfall/debt, and billing-disabled no-op.

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

* test(billing): align gate tests with #1473 (mock createAdminRestrictedResponse + isAdminOnlyProvider)

* fix(billing): await ask_agent metering so the sub-agent charge is durable

Addresses Codex P2: trackAIUsage now persists+debits inside its returned
promise, so the ask_agent call must await it (matching the chat/v1 handlers)
or the sub-agent usage log and credit debit can be dropped on a serverless
return.

* fix(billing): make funding failures retryable via Stripe redelivery

Codex P1 (re-raised): the webhook commits its stripeEvents idempotency marker
before processing, so a swallowed funding failure was lost forever — Stripe's
redelivery short-circuits as "already processed" and the backfill cron reconciles
only usage rows, not funding. A transient DB error during invoice.paid or a
credit-pack checkout could leave a paying customer permanently unfunded.

applyStripeFunding now logs and RE-THROWS genuine failures (non-actionable cases —
billing disabled, ignored events, unknown customer, missing ids — still return
quietly). The webhook wraps funding in fundOrLetStripeRetry: on a funding failure
it deletes the stripeEvents marker and rethrows, so the route returns 500 and
Stripe redelivers, reprocessing the event. Funding is idempotent on
creditLedger.stripeRef, so the balance is still credited exactly once.

Safe to reprocess: the handlers that run before funding on these events are
log-only / no-op (handleInvoicePaid only logs; handleCheckoutCompleted acts only
for mode 'subscription', whereas a credit-pack top-up is mode 'payment').

Tests: rethrow-on-failure assertion replaces the old swallow test; added a
non-actionable-cases test asserting no throw for unknown customer / ignored /
billing-disabled. 55 billing tests pass.

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

* fix(lib): export ./billing/credit-funding so the web build can resolve it

The Stripe webhook route imports @pagespace/lib/billing/credit-funding, but the
new module was missing from package.json exports + typesVersions. tsconfig-path
typecheck resolved it locally, but the Next build resolves via package exports
and failed collecting /api/stripe/webhook. Verified: web build now exits 0.

* fix(billing): make the whole funding-relevant webhook path retryable, not just funding

Codex P1: the marker-cleanup only wrapped the funding call, but for invoice.paid
handleInvoicePaid runs first and does a DB user lookup. A transient failure THERE
threw before the cleanup, leaving the stripeEvents marker in place — the outer
catch returns 500, and Stripe's redelivery short-circuits at the marker conflict
and never reaches applyStripeFunding, so the paid monthly credit is still lost.

Replace fundOrLetStripeRetry(event) with withFundingRetry(eventId, run): it wraps
the WHOLE funding-relevant case body (pre-funding handler + applyStripeFunding) and
deletes the marker on ANY failure before rethrowing, so Stripe redelivers and the
whole path reruns. Safe to reprocess: handleInvoicePaid only logs;
handleCheckoutCompleted's sole throwable is an idempotent customer-link upsert (and
it acts only for mode 'subscription' — a credit-pack top-up is mode 'payment'; its
provisioning POST swallows its own errors); funding is idempotent on stripeRef.

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

* fix(billing): gate self-heals a NULL monthly period so top-up-first users get the free allowance

If a credit-pack purchase creates the first credit_balances row (topup funding
inserts { userId } only — monthly 0, monthlyPeriodEnd NULL) before the user's
first AI request, the gate previously skipped both the reset (period was NULL,
not expired) and lazy-init (a row existed), so the free monthly allowance was
never granted and never reset. The gate now resets on (period IS NULL OR
expired), stamping a window and granting the tier allowance. Race-safe: the
UPDATE re-checks the same predicate. +1 test; integration fake-DB engine gains
an 'or' operator.

* fix(billing): address CodeRabbit review (gate ordering, paid-user reset, migration CHECK, test rigor)

- chat route: run the prepaid gate BEFORE persisting the user message, so a 402
  no longer leaves an orphaned/duplicate prompt in chat history on retry.
- credit-gate: restrict gate-driven monthly reset to FREE/non-subscription users.
  Paid tiers refill authoritatively via invoice.paid; gate-resetting them would
  over-grant when a renewal invoice is late or retried. +tests (paid user blocked).
- migration 0144: add the credit_balances_pending_millicents_range CHECK so DBs
  upgraded through this migration enforce the 0<=pending<1000 carry invariant.
- global R4 test: prove trackUsage is AWAITED via a never-resolving deferred
  (a synchronous mock + 'was called' could not catch a fire-and-forget regression).
- integration test: derive the billing window from Date.now() so the funded
  period is always active (a hardcoded past window let the gate refill mid-test).
- consult test: drop the file-wide no-explicit-any disable; type the mock helpers.

* fix(billing): bound drain loop on no-progress, not just MAX_PASSES

Review hardening for the backfill drain loop:

- No-forward-progress break. `decrementAndSettle` (credit-consume.ts) leaves a
  ledger row 'pending' when the user has no balance row yet, so such rows never
  drop out of the pending sweep. The drain loop would therefore re-fetch and
  re-attempt the same unprocessable batch every pass up to MAX_PASSES (50× the
  work, every 10-min cron run, for a balance-less backlog). Now each pass
  fingerprints the fetched rows (order-independent); two identical consecutive
  passes mean nothing settled, so the loop stops instead of churning. A truly
  stuck full batch now ends in 2 passes, not 50. Partial progress still drains
  normally via the short-pass break.

- Hoisted the cap-exhaustion warning out of the loop body. It now fires exactly
  when the loop ran to MAX_PASSES (a real remaining backlog), not on a narrow
  last-iteration batch-size coincidence, and is now covered by tests.

Tests: +1 (stuck full batch stops early, no MAX_PASSES warning) and the cap test
now asserts the warning fires. @pagespace/lib typecheck clean; full lib suite
178 files / 4361 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e993ec)

* fix(billing): normalize appliedCents to avoid storing -0 on sub-cent settles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 34a6cd2)

* fix(billing): clamp month rollover in gate reset to avoid month-end overflow

CodeRabbit P2: setUTCMonth(+1) turns Jan 31 into Mar 3, making the 'monthly'
reset window longer than a month and delaying the next allowance refill for
users initialized/reset near month end. addOneMonth now clamps to the last
valid day of the target month (Jan 31 -> Feb 28/29). Exported + unit-tested
across mid-month, month-end, leap-year, and year-rollover cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7e69435)

* fix(billing): address credits-remediation review findings (gate ordering, funding tier/customer, expired paid monthly, webhook test)

Five follow-ups from the /aidd:review of pu/credits-remediation:

- [P1] Mock applyStripeFunding in the webhook unit tests. The new funding call
  loaded the real billing module against a mock db that can't satisfy its query
  chain, turning every funding-relevant event into a 500. Mock the module and
  add wiring assertions (funding invoked once per event; 500 is retryable).

- [P1] Resolve a credit-pack buyer from trusted session metadata.userId before
  the customer link. A first-time payment-mode checkout doesn't link the Stripe
  customer to a user, so the customer lookup missed and the top-up was silently
  dropped. resolveTopupUser prefers metadata.userId, falls back to the customer.

- [P2] Run the global-assistant credit gate BEFORE persisting the user message,
  matching the page-chat route. A denied request no longer leaves an orphaned
  prompt that duplicates on top-up + retry.

- [P2] Exclude a paid user's expired monthly bucket from the gate decision. Once
  monthlyPeriodEnd has passed (renewal delayed), leftover monthly allowance no
  longer funds calls — only the never-expiring top-up does (blocked-until-renewal).

- [P2] Base the monthly refill on the tier derived from the PAID invoice line,
  not the stored users.subscriptionTier, so an invoice.paid that races ahead of
  the subscription webhook still grants the correct allowance. applyStripeFunding
  takes an optional { tier }; the webhook derives it via getTierFromPrice.

Tests: +4 lib billing (95 pass), +4 web route (webhook/global gate). lib + web
typecheck clean.

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

* docs(marketing): move pricing/FAQ/terms copy to metered AI credits (#1495)

Rewrite every "N AI calls per day" surface in apps/marketing to the
prepaid metered AI-credits story: each tier includes a monthly $ AI-credit
allowance that meters usage, you can buy more anytime via top-up packs,
unused monthly credits reset each billing period, and model access still
differs by tier (free = standard models; paid = standard + Pro models).

Numbers are sourced from packages/lib billing/credit-pricing.ts via a new
single-source-of-truth module (apps/marketing/src/lib/credits.ts) so public
copy can't drift from what the app actually meters.

Surfaces updated: pricing page (cards + comparison table), FAQ (incl. new
"how credits work" entry + reworded out-of-credits answer), Terms (plan
list + usage-limits section), getting-started + features/ai docs, privacy,
schema.org offers, search index, and the BYOK blog post. Storage/file-size
copy left unchanged.

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

* feat(billing): AI unit-economics observability (margin queries + admin view) (#1494)

* feat(billing): AI unit-economics observability (margin queries + admin view)

Add margin aggregation queries joining aiUsageLogs with creditLedger usage
rows to surface real provider cost vs charged credits, gross margin %, and
uncovered debt per period, model/provider, and user.

- monitoring-queries.ts: computeMarginPct + getUnitEconomicsSummary,
  getMarginByPeriod, getMarginByModel, getTopSpendersByMargin,
  getOutstandingDebtByUser. Magnitudes via ABS(); debt summed from
  'adjustment' rows by ledger createdAt (no join) so retention purges
  can't under-report. Granularity is a bound param, not interpolated.
- GET /api/admin/unit-economics (withAdminAuth): JSON snapshot + CSV export.
- /admin/unit-economics admin view: summary cards, margin by model, top
  spenders, outstanding debt, margin-over-time; linked from /admin nav.
- Unit tests for margin logic, filters, and entryType scoping (14 tests).

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

* fix(billing): sum precise sub-cent fields in unit-economics aggregates

Summing per-row-rounded realCostCents/amountCents rounded high-volume
sub-cent traffic to $0 and reported bogus margin. Aggregate the precise
fields and round once: charged from SUM(chargeMillicents)/1000, real cost
from SUM(aiUsageLogs.cost)*100. appliedCents stays exact (whole-cent debit,
remainder banked in pendingMillicents). Debt keeps summing amountCents since
'adjustment' rows carry only the whole-cent shortfall.

Addresses CodeRabbit P2 on PR #1494.

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

---------

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

* feat(email): announce metered AI credits to users (template + broadcast script) (#1496)

* feat(email): announce metered AI credits to users (template + broadcast script)

Workstream F of the metered-AI-credits cutover: a one-time announcement
email telling users their AI usage is moving from daily call limits to a
monthly pool of prepaid AI credits (with buy-more top-ups).

- CreditsChangeEmail React Email template matching the existing template
  visual style; states the per-tier monthly allowance + top-up packs and
  reassures that documents/tasks/channels/collaboration are unaffected.
- credits-change-content helper derives all per-tier dollar figures straight
  from billing/credit-pricing (TIER_MONTHLY_ALLOWANCE_CENTS + CREDIT_PACKS)
  so the email can never quote a number the gate doesn't actually grant.
- render-email helper wraps @react-email/components render so repo-root
  scripts can produce email HTML without depending on it directly.
- send-credits-change-notifications.ts broadcast script mirrors
  send-tos-notifications.ts: queries all users with a valid email, sends via
  the shared rate-limited sendEmail, and is idempotent/resumable via a local
  JSONL ledger (re-runs skip already-sent recipients; failures retry).
  Supports --dry-run, --verified-only, --limit, --delay-ms, --log.

Verified end-to-end with --dry-run against a seeded DB: per-tier numbers
render correctly (free $5 / pro $15 / founder $50 / business $100), invalid
emails skip, ledger entries skip, and --verified-only/--limit behave. No
real emails sent. lint, typecheck, build, and lib tests green.

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

* fix(email): address Codex review on credits broadcast script

- Refuse a live (non --dry-run) send when the resolved app base URL points at
  localhost, so the broadcast can never email a broken CTA. Resolve the URL
  from NEXT_PUBLIC_APP_URL then WEB_APP_URL, preferring the first non-localhost
  value (handles a setup where only the server-side WEB_APP_URL is production).
- Make the idempotency ledger crash-safe: open + validate writability before
  the first send, fsync each record, and treat a ledger-write failure after a
  successful send as fatal — abort and name the unrecorded recipient so a
  re-run can never silently double-send it.
- Drop the unused emailVerified column from the user select.

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

---------

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

* fix(billing): credit reservation holds + free in-flight cap; retire daily AI rate limits (#1497)

* fix(billing): credit reservation holds + free in-flight cap; retire daily AI rate limits

Workstream A — reservation/hold + free-tier in-flight cap:
- New credit_holds table (id, userId fk cascade, estCents, aiUsageLogId,
  createdAt, expiresAt; indexed on userId + expiresAt) via db:generate.
- credit-core (pure): reservationCents(), holdExpiresAt(), and evaluateGate
  extended to subtract reservedCents + estCostCents from spendable and to deny
  with a new too_many_in_flight reason when inFlightCount >= maxInFlight.
- credit-pricing: CREDIT_HOLD_ESTIMATE_CENTS (default = reserve floor),
  CREDIT_HOLD_TTL_SECONDS (900), MAX_FREE_INFLIGHT (2), all env-tunable.
- credit-gate canConsumeAI: authoritative decision now runs in one transaction
  that locks the balance row, sums & counts non-expired holds, denies the
  free-tier in-flight cap and out-of-credits, else inserts a hold and returns
  { allowed, holdId }. GateResult gains holdId.
- credit-consume: consumeCredits({…, holdId?}) releases the hold inside the
  settle transaction (and on the zero-charge path); new releaseHold() frees a
  reservation for token-less failures that never bill.
- credit-backfill reconcile: sweeps holds past expiresAt so a crashed stream's
  reservation can't permanently shrink spendable (BackfillResult.expiredHolds).
- holdId threaded gate -> route -> billing: AIUsageData/trackUsage ->
  consumeCredits, across all 5 AI routes (+ agent-communication-tools note).
  Shared credit-gate-response helper maps out_of_credits -> 402,
  too_many_in_flight -> 429.

Workstream B — retire daily AI rate limits:
- chat + global-assistant routes: removed the getCurrentUsage ->
  createRateLimitResponse (429) blocks and the incrementUsage/broadcastUsageEvent
  calls in onFinish. Model-tier gating (requiresProSubscription / admin-only
  providers) preserved. usage-service / rate-limit-cache / rate_limit_buckets /
  sweep-expired left intact — confirmed they back auth/login/integration limits.

Tests: extended billing unit + integration suites (hold accounting, in-flight
cap, hold release on settle, expiry sweep, full gate->consume hold lifecycle)
and added route 429 + helper coverage. lib 4426 + web routes green; typecheck,
lint, web build all pass.

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

* fix(billing): address Codex review — settle-time monthly expiry + release holds on early route exits

P2 (credit-gate.ts): use-it-or-lose-it now holds at SETTLE too. decrementAndSettle
excludes an expired monthly window (paid user past monthlyPeriodEnd) so allocateSpend
no longer silently draws the forfeited monthly allowance — it spends top-up only and
drops the stale monthly, matching the gate's exclusion.

P2 (global/[id]/messages + chat + consult + pulse routes): release the credit hold on
pre-generation early returns/throws. A holdHandedOff flag + finally frees the reservation
whenever the request exits after the gate but before the stream/billing takes ownership
(auth/permission/provider/save failures), instead of stranding it against the user's
balance + in-flight cap until the reconcile sweep. v1 unchanged (no explicit early return
after its gate; a throw falls through to the reconcile backstop like any crashed stream).

Also: export @pagespace/lib/billing/credit-consume (exports + typesVersions) so routes can
import releaseHold; add settle-time expired-monthly unit test.

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

---------

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

* fix(db): regenerate credits migration onto master's 0144 (resolve migration-number collision)

Merging master brought in Canvas Publishing's 0144_flawless_ma_gnuci, colliding with
our hand-numbered 0144_big_valkyrie/0145_brave_exodus. Took master's 0144 as canonical
and regenerated a single 0145 capturing the credits schema delta (pendingMillicents,
appliedCents, chargeMillicents, credit_holds, usage-log index rescope) on top of it.
Re-added the pendingMillicents range CHECK by hand — drizzle-kit in this repo doesn't
emit/track CHECK constraints, so the regenerate would otherwise silently drop the
[0,1000) money-path invariant the original migration enforced.

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

* feat(billing): credit balance UI, buy-credits checkout, and out-of-credits UX (#1499)

* feat(billing): credit balance UI, buy-credits checkout, and out-of-credits UX

Workstream C + the in-app (apps/web) copy of Workstream D for the metered
AI-credits cutover. Gives users the surfaces that make the hard cutover humane:
see their balance, get correct 402/429 messages with a CTA, and buy more credits.

- Balance API: GET /api/credits → { monthly, topup, spendable, reserved } with a
  read-only getCreditBalance() that mirrors the gate's window semantics for display
  (free-tier lapsed → full allowance; paid lapsed → 0 monthly; spendable nets holds).
  SWR hook useCreditBalance with live socket updates.
- Live updates: replace the retired daily-quota usage:updated socket event with
  credits:updated (broadcastCreditsEvent + emitCreditsUpdated), emitted after a call
  settles (the two interactive AI routes) and after funding (the Stripe webhook).
- Widget: replace UsageCounter with a CreditBalance header widget (remaining + low
  warning + Buy credits) and a CreditBalanceCard on settings/billing. settings/plan
  + settings/billing now show credits, not aiCalls/day.
- Buy-credits checkout: POST /api/stripe/create-credit-topup mirrors create-subscription
  (mode:'payment', inline price_data from CREDIT_PACKS, metadata.kind='credit_pack'),
  so the existing webhook funds the top-up bucket. BuyCreditsButton in settings + in
  the out-of-credits chat error states.
- Error UX: classifyAIError distinguishes out_of_credits (402) and too_many_in_flight
  (429) with distinct copy; SidebarChatTab + ChatInputArea show a Buy-credits CTA.
- Copy: plans.ts limits move from aiCalls/pro/day to a monthly credit allowance +
  proModels capability, sourced from credit-pricing via a new web credits.ts helper
  (mirrors apps/marketing/src/lib/credits.ts). Retire the orphaned /api/subscriptions/usage.

Tests: balance logic, GET /api/credits, the top-up route, error classification, and
the renamed socket event. Lint + web typecheck + web build green.

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

* fix(billing): hide BuyCreditsButton on iOS / billing-disabled (Codex P2)

The out-of-credits error CTAs (ChatInputArea, SidebarChatTab) rendered
BuyCreditsButton unconditionally, exposing a Stripe checkout on iOS Capacitor
builds where billing UI must be hidden for App Store compliance. Make
BuyCreditsButton self-hide via useBillingVisibility so every call site —
including the error states — is compliant.

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

---------

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

* fix(ai): dedupe users import in v1 completions after merging master

master #1500 (server-side tool execution) and our credit gate both added
`import { users }` to the completions route; the clean text-merge left a
duplicate identifier (TS2300). Removed the redundant import.

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

* fix(billing): address CodeRabbit review on metered-credits PR

- v1 completions: release the credit hold on stream failure (was leaking the
  reservation until TTL/reconcile, leaving users artificially short).
- chat route: create the conversation row AFTER the credit gate so a denied
  first prompt leaves no orphaned conversation.
- monitoring-queries: anchor the unit-economics window on creditLedger.createdAt
  and LEFT JOIN aiUsageLogs (was inner-join + usage-log timestamp, which dropped
  charged credits/margin once a usage log was retention-purged); bucket
  purged-log rows under 'unknown' model/provider. Test updated to match.
- admin CSV export: neutralize spreadsheet formula injection (=,+,-,@) in
  attacker-controlled name/email cells.
- error classifier: tighten to exact codes/phrases so "context window limit
  exceeded" / generic "ai credits" no longer misroute to rate-limit/buy-credits.
- admin unit-economics page: render period buckets from the server string
  (no Date reparsing) to avoid timezone-shifted / mislabeled month buckets.
- credits route: validate subscriptionTier at runtime instead of casting.
- create-credit-topup: reject malformed/non-object JSON with 400, not 500.
- AiUsageMonitor: only compare ids this monitor is scoped to (page-agent mode
  has no conversationId, so it was dropping every credits:updated event).
- send-credits-change-notifications: count ATTEMPTS against --limit so a
  provider outage can't blow past a canary cap.

Not changed: CodeRabbit's "decouple apps/marketing from @pagespace/lib" — master
already couples it (contact route imports @pagespace/lib/security), so the
standalone premise is stale; the import is the no-drift source of truth. Replied
on-thread.

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

* feat(billing): CREDITS_ENFORCEMENT_ENABLED kill-switch (dark-launch the gate)

The metered-credits cutover otherwise hard-enforces (live 402/429) the moment it
deploys, on placeholder allowances. Add an env flag so the gate can be dark-launched:
deploy the code in observe-only mode (meter + record real cost/charged credits for the
unit-economics view) and flip blocking on deliberately once the numbers are validated.

- credit-pricing.ts: envBool + isCreditsEnforcementEnabled() (default FALSE), read at
  call time so it toggles via env+redeploy and is settable per-test.
- credit-gate.ts: the gate still does ALL bookkeeping (lazy-init, monthly reset, balance
  read, hold on the allow path); when enforcement is OFF it only overrides a would-be
  denial (out_of_credits / too_many_in_flight) to allowed:'enforcement_disabled'. A
  credit-having user is unchanged (normal allow + hold). consumeCredits is untouched, so
  metering/observability run regardless.
- credit-core.ts: add 'enforcement_disabled' to GateReason (an allowed reason;
  credit-gate-response only maps deny reasons, so no HTTP change).
- tests: the two suites that exercise real enforcement set CREDITS_ENFORCEMENT_ENABLED=true;
  new dark-launch cases assert denials are suppressed while bookkeeping still runs.

To enforce in production: set CREDITS_ENFORCEMENT_ENABLED=true and redeploy.

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

* fix(db): rebase credits migration onto master's 0145 (resolve 0145 collision)

Merging master brought Agent Code Execution's 0145_flawless_living_mummy (#1487),
colliding with our regenerated 0145. Took master's 0145 as canonical and regenerated
the credits delta as 0146_furry_abomination via db:generate. Verified mechanically:
0146.prevId == master 0145.id, 0145.prevId == master 0144.id, journal idx + when
timestamps strictly increasing — chain points at the correct parent. Re-added the
pendingMillicents range CHECK (drizzle-kit doesn't emit CHECK constraints).

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

* fix(billing): exempt numeric cells from CSV spreadsheet-injection guard

sanitizeSpreadsheetCell ran on every cell, so legit negative exports (marginUSD
"-0.37", marginPct "-12.50") got quote-prefixed and landed as text in Excel/Sheets.
Exempt plain numbers (/^-?\d+(\.\d+)?$/); keep quoting only non-numeric text that
starts with a formula trigger (=,+,-,@), i.e. the name/email columns.

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 — 1b5ed920 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