Make the credit refill and top-up writers atomic

SI
5d ago
+466-181

Summary

Switches the two remaining legacy credit wallet writers to atomic database functions, so neither writes back a balance it read earlier:

  • Monthly plan refill. checkAndResetCredits no longer writes an absolute remaining_credits. It calls refillCreditsToFloor (refill_credits_to_floor), which applies a GREATEST floor and advances the timestamp under the wallet row lock. The call also sends the refill timestamp this read observed (expectedTimestamp → p_expected_timestamp). The database applies the refill only while the wallet still carries that timestamp. Otherwise it returns superseded with the current balance and timestamp, and checkAndResetCredits merges those into the row it read. So two concurrent reads of one stale row refill a period once, and a debit taken after the first refill is not resurrected.
  • Stripe top-up. incrementRemainingCredits calls increment_credits_atomic instead of select-then-update.

This is the "bounded fix before reservations" the ticket records as its next step. It deliberately stops short of the reserve/release/settle primitive owned by #2105 milestone 1. There is no paid caller, price, ceiling, hold, auto-top-up or Context dispatcher change. The incrementRemainingCredits export and its Stripe call sites are unchanged.

Review fixes (commit ffc2faa):

  • The double-refill race above.
  • Comments now claim only what these two writers guarantee, and name lib/credits/deductCredits.ts as the remaining gap.
  • Invalid-receipt tests now assert ZodError.

Refs recoupable/app#2123 (child of epic recoupable/app#2116).

Merge sequencing

database (recoupable/database#101, migration 20261010212300_credit_wallet_atomic_adjustments.sql, head 90b9481 with the 3-argument refill_credits_to_floor) → api (this PR).

Merge and release the database migration first. This PR's refill and top-up paths call the new RPCs, with the refill passing p_expected_timestamp, and would fail against a database without them.

This PR does not depend on open database#88. It has no overlap with open api PRs #782, #909, #651, #786 or #717: none of them touch checkAndResetCredits.ts, incrementRemainingCredits.ts or the lib/stripe top-up files.

Verification

All runs were at head ffc2faa.

  • pnpm exec vitest run lib/supabase/credits_usage lib/credits lib/stripe gave 97 files and 477 tests passed, 0 failed, 0 skipped.
  • pnpm exec vitest run lib/context gave 67 files passed and 8 skipped; 326 tests passed and 8 skipped. No Context files changed.
  • pnpm exec eslint on the 7 changed .ts files: clean. pnpm exec prettier --check on them plus lib/credits/charges/README.md: clean.
  • pnpm exec tsc --noEmit exits 2 with 199 errors in 66 files. None are in a changed file or in the callers (lib/stripe/processCreditsTopup*, checkCreditsAvailable, getAccountCreditsHandler). All 66 files are untouched by this PR, and the same pre-existing errors appear on a pristine origin/main tree.
  • Red → green, review fix:
    • With the updated tests and the previous implementation, 17 of 42 tests in the 4 changed test files failed. These included every refill call now required to carry expectedTimestamp, the superseded receipt cases, and does not refill twice when another caller already refilled this period.
    • Against origin/main's incrementRemainingCredits.ts, the 3 invalid-receipt cases now fail, because they assert ZodError rather than any throw.
  • Red → green, original change: the select-then-update top-up, the absolute refill write and the missing refillCreditsToFloor module each failed their tests before the change.
  • Not verified: hosted deployment, live Stripe webhooks, the production database and live providers. Mocked adapter tests do not prove production behavior. The real transaction coverage is in recoupable/database#101: 10 disposable-PostgreSQL tests on 15.10 and 17.7, including a same-period double refill after a debit and six concurrent refills, plus mutation checks.

Out of scope / known gaps

  • Holds/reservations, request/pilot ceilings, and provider-expense versus customer-debit/waived/unknown reporting (#2105 milestone 1 and later #2123 slices).
  • The unaudited read-modify-write in lib/credits/deductCredits.ts is unchanged. It is still used by lib/research/postResearch*Handler.ts, lib/content/chargeForGeneration.ts, lib/sites/production/produceAssets.ts and others. It can still overwrite a top-up, refill or charge that commits between its read and write. It is the next slice.
  • incrementRemainingCredits now returns { remainingCredits } instead of the full row. Both Stripe callers ignore the return value, and tsc reports no consumer.
  • refillCreditsToFloor has a new required expectedTimestamp parameter. Its only caller is checkAndResetCredits.
  • Generated database types are not regenerated. The two RPCs are narrowly typed at the access-layer boundary, like recordCreditChargeOnce.
  • docs/chat: no contract change.

🤖 Generated with Claude Code


Summary by cubic

Switches the monthly credit refill and Stripe top-up to atomic database functions so a balance read earlier is never written back over a concurrent debit, top-up, or grant.

  • checkAndResetCredits now calls refillCreditsToFloor (refill_credits_to_floor), which raises the balance to the plan total with GREATEST and advances the refill timestamp under the wallet row lock — but only if the wallet still carries the timestamp this read observed; otherwise the receipt is superseded, so two concurrent reads of one stale row refill a period only once and a later debit isn't resurrected.
  • incrementRemainingCredits now calls increment_credits_atomic, adding the top-up delta in place, instead of select-then-update.
  • No changes to call sites, exported names, paid callers, prices, ceilings, holds, auto-top-up, or the Context dispatcher; the Stripe handlers are untouched.
  • incrementRemainingCredits now returns { remainingCredits } instead of the full row; both Stripe callers ignore the return value.

Migration

  • Deploy database migration 20261010212300_credit_wallet_atomic_adjustments.sql before this API change; the refill and top-up paths call the new RPCs and would fail without them.