Make the credit refill and top-up writers atomic
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.
checkAndResetCreditsno longer writes an absoluteremaining_credits. It callsrefillCreditsToFloor(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 returnssupersededwith the current balance and timestamp, andcheckAndResetCreditsmerges 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.
incrementRemainingCreditscallsincrement_credits_atomicinstead 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.tsas 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/stripegave 97 files and 477 tests passed, 0 failed, 0 skipped.pnpm exec vitest run lib/contextgave 67 files passed and 8 skipped; 326 tests passed and 8 skipped. No Context files changed.pnpm exec eslinton the 7 changed.tsfiles: clean.pnpm exec prettier --checkon them pluslib/credits/charges/README.md: clean.pnpm exec tsc --noEmitexits 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 pristineorigin/maintree.- 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, thesupersededreceipt cases, anddoes 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 assertZodErrorrather than any throw.
- 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
- Red → green, original change: the select-then-update top-up, the absolute refill write and the missing
refillCreditsToFloormodule 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.tsis unchanged. It is still used bylib/research/postResearch*Handler.ts,lib/content/chargeForGeneration.ts,lib/sites/production/produceAssets.tsand others. It can still overwrite a top-up, refill or charge that commits between its read and write. It is the next slice. incrementRemainingCreditsnow returns{ remainingCredits }instead of the full row. Both Stripe callers ignore the return value, andtscreports no consumer.refillCreditsToFloorhas a new requiredexpectedTimestampparameter. Its only caller ischeckAndResetCredits.- 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.
checkAndResetCreditsnow callsrefillCreditsToFloor(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 issuperseded, so two concurrent reads of one stale row refill a period only once and a later debit isn't resurrected.incrementRemainingCreditsnow callsincrement_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.
incrementRemainingCreditsnow returns{ remainingCredits }instead of the full row; both Stripe callers ignore the return value.
Migration
- Deploy database migration
20261010212300_credit_wallet_atomic_adjustments.sqlbefore this API change; the refill and top-up paths call the new RPCs and would fail without them.