fix(cloud): make reconcile refund idempotent so a repeated settle cannot double-mint (#11512) - #11539
Conversation
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
NubsCarson
left a comment
There was a problem hiding this comment.
[cloud-audit] LGTM — the refund leg of reconcileCredits is now genuinely idempotent, and the dedupe is atomic, not check-then-act.
Invariant verified (repeated settle must not double-refund/mint):
- The refund is keyed
reconcile-refund:<idempotencyKey>where the key is the request-stable id already threaded into reservation metadata bywithChargeIdempotencyKey(app-credits.ts:457) — the same key both routes pass at reserve time, so the re-invoked reconcile after the settler's guard-reset (#11512 shape) reuses the exact key. refundCreditsforwards it toapplyCreditIncrease(credits.ts:810-818), whose single CTE statement gates the org balanceUPDATEonEXISTS (SELECT 1 FROM inserted), with the insert guarded byNOT EXISTS+ON CONFLICT (stripe_payment_intent_id) DO NOTHINGagainst the unique indexcredit_transactions_stripe_payment_intent_idx(db/schemas/credit-transactions.ts). A deduped second refund therefore skips both the ledger row and the balance increase — no partial-dedupe hole, and the unique index is the concurrency backstop for racing duplicates.- Namespace checked:
reconcile-refund:can't collide with the org-credits path'srecon:<txid>:refundkeys (credits.tsreconKey) or real Stripepi_...ids sharing the index. - The companion legs were already idempotent (
reverseCreatorEarningsdedupes on${chargeKey}:inference_markup:reconcile_refundand skips the shadow aggregate writes on dedupe) — this PR closes the last non-idempotent leg, consistent with the existing scheme. - Test is real: drives the actual reservation closure twice on real PGlite with a hard
balance < $10mint invariant, and the PR documents the fail-without-fix run (11.65 vs 9.45). No migration needed — base code already relies on the sameON CONFLICTtarget in prod, so nothing in the "won't apply" class. - Conditional spread means no-key callers keep today's semantics exactly — behavior-preserving for legacy paths.
Non-blocking caveats:
- The mirror CHARGE branch (
reserveAndDeductCreditsinsidereconcileCredits, app-credits.ts ~:832) still passes no key — a guard-reset replay landing there could double-charge. Pre-existing and outside #11512's shape (the fallbacksettleReservation(0)always takes the refund branch), but worth a follow-up issue for symmetry with credits.ts'sreconKey("overage"). - A deduped repeat reconcile still returns
reconciled:true, action:"refund"with the second call's largeradjustedAmount— cosmetic (balance correct), but logs will overstate the refund on the dedup path.
…not double-mint (#11512) reconcileCredits' refund branch was non-idempotent: the reservation settler resets its first-call-wins guard when reconcile throws, so a refund that commits followed by a post-refund throw (DB blip) lets the route's fallback settleReservation(0) re-invoke reconcile and mint a SECOND full refund. Thread the request-stable idempotencyKey from reservation metadata into the refund as stripePaymentIntentId=`reconcile-refund:<key>` so the re-invoke dedupes on the credit_transactions unique index (ON CONFLICT DO NOTHING) instead of double-crediting. Regression test proves the repeated-settle shape double-mints to 11.65 (above the org's 10.00 start) without the fix and stays at 9.45 with it. Closes #11512 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
66d19e9 to
cf42c32
Compare
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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 |
lalalune
left a comment
There was a problem hiding this comment.
Reviewed and verified after rebasing onto current origin/develop (82001b0).
Local evidence on rebased head cf42c32:
git diff --check origin/develop...HEAD✅bunx @biomejs/biome@2.5.2 check packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts packages/cloud/shared/src/lib/services/app-credits.ts✅bun test src/lib/services/__tests__/app-credit-hold-concurrency.test.tsfrompackages/cloud/shared✅ 5 pass / 0 failbun run --cwd packages/cloud/shared typecheck✅- adjacent guard:
bun test src/lib/utils/credit-reservation.test.ts src/lib/services/__tests__/app-credit-hold-concurrency.test.ts✅ 13 pass / 0 fail
The new regression covers the dangerous guard-reset shape directly against PGLite and the real app-credit service: repeated refund reconcile with the same reservation remains at the expected post-refund balance and does not mint above the starting balance. The implementation scopes the synthetic idempotency key to reconcile-refund:<key>, leaving legacy metadata without an idempotency key unchanged.
|
|
…ow in reconcileCredits (pair with the #11539 idempotent refund)
…ow in reconcileCredits (unkeyed settle routes) (#11608) * fix(cloud): compensate creator-earnings reversal on a post-refund throw in reconcileCredits (pair with the #11539 idempotent refund) * fix(cloud): reconcile compensation with idempotent settler retries --------- Co-authored-by: Shaw <shawgotbags@gmail.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
What
reconcileCredits' refund branch was non-idempotent. The reservation settler resets its first-call-wins guard whenreconcilethrows — so if the refund transaction commits and a post-refund write then throws (DB blip), the route's fallbacksettleReservation(0)re-invokesreconcileand mints a second, full refund to the org.Fix: thread the request-stable
idempotencyKey(already in reservation metadata viawithChargeIdempotencyKey) into the refund asstripePaymentIntentId: reconcile-refund:<key>. A repeated reconcile refund now dedupes on thecredit_transactionsunique index (ON CONFLICT DO NOTHINGinapplyCreditIncrease) instead of double-crediting.Closes #11512
Fail-without-fix proof
New regression test: "a repeated reconcile REFUND for the same reservation is idempotent — no double-refund mint (#11512)" — org starts at $10.00, reserves $2.20, first settle refunds to $9.45, then the same reservation is settled again (the #11512 guard-reset shape).
Without the fix (app-credits.ts stashed, test kept):
With the fix:
All 4 pre-existing tests in the file stay green.
Checks
bun run typecheck(packages/cloud/shared, tsgo --noEmit): exit 0, no errorsbunx biome checkon both touched files: cleanNotes
Money path — do not self-merge.
reconcile-refund:prefix namespaces the key so it can't collide with real Stripe payment-intent ids or other synthetic keys on the same unique index.🤖 Generated with Claude Code