Add atomic refill and top-up writers for the credit wallet

SI
5d ago
+402-0

Summary

Makes the two remaining legacy credit wallet writers atomic in the database so holds can later be layered on a sound balance:

  • refill_credits_to_floor(account, floor, expected_timestamp) is the monthly plan refill. Under the wallet row lock it first checks that the refill timestamp still equals the one the caller observed when it judged the refill due. If another refill, or a grant (which restarts the clock), has moved it, the function returns superseded with the current balance and changes nothing. Otherwise it applies GREATEST(remaining_credits, floor) and advances the timestamp.
  • increment_credits_atomic is the Stripe top-up. It adds the delta in place.

The ticket's own audit records both as lost-update hazards ("refill writes an absolute balance based on an earlier read; top-up increments ... use read-modify-write") and names their bounded fix as the step before reservations. Both functions:

  • are service-role-only and SECURITY INVOKER;
  • reject zero or multiple wallet rows visible at lookup, like record_credit_charge_once;
  • write no usage event, touch no auto-top-up column and enable no caller.

No ledger, price, ceiling, hold, auto-top-up change or paid caller is added.

Review fix (commit 90b9481): the first version applied GREATEST on every call. Two callers that read the same stale row both refilled, and a debit taken between them was resurrected. The observed-timestamp check closes that.

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

Merge sequencing

database (this PR) β†’ api (recoupable/api#1009, branch claude/ctx-2123-wallet-atomic-writes). That PR switches the Stripe top-up and the monthly refill to these RPCs and sends p_expected_timestamp. Apply this migration before deploying that API change; deploying the API first would break those two paths.

This PR neither includes nor depends on open database#88 (single-wallet constraint):

  • The STRICT single-row FOR UPDATE lookup rejects duplicate wallets that already exist when it runs. It cannot stop a duplicate inserted afterwards; database#88 prevents that.
  • The CI job is a separate workflow file, so it does not conflict with #88's edit of credit-charge-receipts.yml.

Verification

All runs used local disposable clusters (LC_ALL=en_US.UTF-8) at head 90b9481.

  • PostgreSQL 17.7: python3 -m unittest discover -s tests -p 'test_credit_wallet_adjustments.py' gave 10 passed, 0 failed, 0 skipped.
  • PostgreSQL 15.10: the same command gave 10 passed, 0 failed, 0 skipped.
  • PostgreSQL 17.7: python3 -m unittest discover -s tests -p 'test_credit_charge_receipts.py' gave 11 passed. That suite is unchanged.
  • The new tests cover:
    • a second refill for the same period after a 200-credit debit returns superseded with 800 and keeps the debit;
    • six concurrent refills for one period apply exactly once;
    • a refill that observed the current to_json timestamp (with microseconds, as PostgREST serializes it) still applies;
    • a NULL observed timestamp is rejected with 22023.
  • Privileges: GRANT EXECUTE to service_role only (2), REVOKE ALL (2). The workflow YAML parses.
  • Red β†’ green:
    • Against the 2-argument function, 9 of 10 tests fail because the 3-argument signature does not exist.
    • Mutation 1: disabling the observed-timestamp check (IF false) fails the 2 new concurrency tests. The second refill returns ('raised', 1000) instead of ('superseded', 800), which reproduces the review probe.
    • Mutation 2 (earlier): replacing GREATEST(remaining_credits, p_floor) with an absolute p_floor failed 3 tests: lock-held top-up 1000 vs 2300, concurrent charge/legacy debit 900 vs 1350, above-floor 1000 vs 1500.
    • Both mutated files were restored byte-identical.
  • Not verified: hosted deployment, Supabase preview or production migration, the production database, live Stripe, and Context callers. No real wallet was read or changed. GitHub checks on head 90b9481 passed: credit-wallet-adjustments (15, 16), credit-charge-receipts (15, 16), release-cases, onboarding and the automatic Supabase Preview check.

Out of scope / known gaps

  • Holds/reservations, request/pilot ceilings, and provider-expense versus customer-debit reporting (#2105 milestone 1 and later #2123 slices).
  • database#88 itself (not included, not depended on).
  • The legacy deduct_credits path and the API's unaudited lib/credits/deductCredits.ts read-modify-write are unchanged. That writer is still used by research, content and site-production charges. It can still overwrite a top-up, refill or charge that commits between its read and write. It is the next slice.
  • The fixture adds the production timestamp column (without time zone) to the minimal legacy schema. It is not a full migration replay.

πŸ€– Generated with Claude Code