Skip to content

fix(payment): reuse the same idempotency key when retrying a capture - #16293

Open
blockgroot wants to merge 3 commits into
medusajs:developfrom
blockgroot:fix/payment-capture-retry-idempotency
Open

fix(payment): reuse the same idempotency key when retrying a capture#16293
blockgroot wants to merge 3 commits into
medusajs:developfrom
blockgroot:fix/payment-capture-retry-idempotency

Conversation

@blockgroot

Copy link
Copy Markdown

Summary

What — What changes are introduced in this PR?

capturePayment no longer deletes a Capture row when the payment provider call fails.
Instead the capture is marked failed, and a subsequent capture attempt on the same
payment reuses that capture's id (and therefore the same provider idempotency key)
instead of minting a fresh one.

Why — Why are these changes relevant or necessary?

capturePayment_ forwards a freshly-created Capture row's id to the payment provider
as its idempotency key (payment-module.ts, capturePaymentFromProvider_), so that a
retried capture is deduplicated by the provider instead of moving money twice. But
because a failed attempt's row was deleted, a retry always got a brand-new id and a new
idempotency key. On an ambiguous provider-side failure — a timeout, or our server dying
between the provider processing the request and us receiving the response — the
provider has no way to recognize a retry as the same operation, and the customer's
payment can be captured a second time.

Fixes #16292

How — How have these changes been implemented?

  • On a provider-call failure, capturePayment's catch block now updates the capture's
    metadata to record __capture_status: "failed" instead of deleting the row.
  • capturePayment_'s over-capture guard now excludes only captures explicitly marked
    failed from the captured-amount total, and reuses one (updating its amount and
    resetting its status to pending) instead of creating a new row when one exists.
  • A capture that hasn't been marked failed still counts toward the captured total, so
    a genuinely concurrent capture on the same payment can't mistake an in-flight capture
    (one whose owning call hasn't resolved yet) for a stale, reusable one — this is
    important because a first attempt at this fix (excluding any not-yet-confirmed
    capture) broke the existing concurrency regression tests added in fix(payment,promotion): serialize concurrent money guards to prevent over-capture, over-refund and budget overspend #16097 by letting a
    concurrent call race past the over-capture guard. The final design only treats a
    capture as reusable once its own call has recorded it as failed.
  • capturePaymentFromProvider_ clears the status once the provider call actually
    succeeds (or is skipped because the payment was already auto-captured), so a
    successful capture is never later mistaken for a reusable failed attempt.

No schema migration — this reuses the existing metadata JSON column already present on
Capture. No public API or HTTP contract change.

Testing — How have these changes been tested, or how can the reviewer test the
feature?

New tests in packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts:

  • should reuse the same idempotency key when retrying a capture after a provider-side failure — mocks the provider to fail once then succeed, asserts the first attempt
    throws and leaves the payment uncaptured, then asserts a retry sends the same
    idempotency_key as the failed attempt and results in exactly one confirmed capture.
    This fails on develop (asserts the keys are equal; they currently differ) and passes
    with this change.
  • should capture the full amount normally when no prior attempt failed — confirms the
    ordinary single-capture path is unaffected.

Full @medusajs/payment integration suite: 51/51 passed (includes the existing
concurrency regression tests from #16097 — a first draft of this fix broke those two
tests by excluding not-yet-confirmed captures from the over-capture guard entirely; the
final design fixes that by only excluding captures explicitly marked failed).

Test Suites: 2 passed, 2 total
Tests:       51 passed, 51 total

yarn lint:medusa: 0 errors (204 pre-existing warnings, unchanged from develop).
yarn workspace @medusajs/payment build: passes.
Full repo yarn build: 79/79 tasks.
Full repo yarn test --continue: 67/69 (the 2 failures, @medusajs/icons and
@medusajs/ui, are pre-existing and unrelated to this change — a duplicate React
version resolved for the icons workspace on a clean install).


Examples

// Simplified illustration of the scenario this fixes:
await service.capturePayment({ amount: 100, payment_id: "pay_123" })
// -> provider call times out; Capture is marked `failed`, not deleted

await service.capturePayment({ amount: 100, payment_id: "pay_123" })
// -> before this fix: a new Capture row + new idempotency key, provider
//    can't tell this is a retry of the same request
// -> after this fix: reuses the failed capture's id, provider sees the
//    same idempotency key and dedupes correctly if it already processed
//    the first request

Checklist

  • I have added a changeset for this PR
  • The changes are covered by relevant tests
  • I have verified the code works as intended locally
  • I have linked the related issue(s) if applicable

Additional Context

The refundPayment_/refundPayment path has the identical shape (create a Refund row,
call the provider, delete the row on failure) and is very likely exposed to the same
class of bug. I've deliberately left it out of this PR to keep the diff scoped to
capture, and I'm happy to open a follow-up issue/PR for it once this one lands, if that's
useful.

capturePayment_ always created a brand-new Capture row before calling
the provider, and passed that row's id as the provider idempotency
key. On a provider-call failure the capture was deleted, so a retry
minted a new id and a new key. If the failure was ambiguous (e.g. a
timeout after the provider actually processed the request), the
provider has no way to recognize a retry as the same operation and
can capture the funds twice.

Mark a failed capture instead of deleting it, and reuse it on the next
attempt so the idempotency key stays stable across a retry. A capture
only becomes reusable once its own call records it as failed; until
then it still counts toward the captured total, so a concurrent
capture on the same payment can't mistake an in-flight capture for a
stale one (this composes with the row lock added in medusajs#16097 for that
race, rather than changing it).
@blockgroot
blockgroot requested a review from a team as a code owner August 3, 2026 10:00
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 83ea23e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 79 packages
Name Type
@medusajs/payment Patch
@medusajs/medusa Patch
@medusajs/test-utils Patch
@medusajs/loyalty-plugin Patch
@medusajs/medusa-oas-cli Patch
integration-tests-http Patch
@medusajs/analytics Patch
@medusajs/api-key Patch
@medusajs/auth Patch
@medusajs/caching Patch
@medusajs/cart Patch
@medusajs/currency Patch
@medusajs/customer Patch
@medusajs/file Patch
@medusajs/fulfillment Patch
@medusajs/index Patch
@medusajs/inventory Patch
@medusajs/link-modules Patch
@medusajs/locking Patch
@medusajs/notification Patch
@medusajs/order Patch
@medusajs/pricing Patch
@medusajs/product Patch
@medusajs/promotion Patch
@medusajs/rbac Patch
@medusajs/region Patch
@medusajs/sales-channel Patch
@medusajs/settings Patch
@medusajs/stock-location Patch
@medusajs/store Patch
@medusajs/tax Patch
@medusajs/translation Patch
@medusajs/user Patch
@medusajs/workflow-engine-inmemory Patch
@medusajs/workflow-engine-redis Patch
@medusajs/draft-order Patch
@medusajs/oas-github-ci Patch
@medusajs/cache-inmemory Patch
@medusajs/cache-redis Patch
@medusajs/event-bus-local Patch
@medusajs/event-bus-redis Patch
@medusajs/analytics-local Patch
@medusajs/analytics-posthog Patch
@medusajs/auth-emailpass Patch
@medusajs/auth-github Patch
@medusajs/auth-google Patch
@medusajs/caching-redis Patch
@medusajs/file-local Patch
@medusajs/file-s3 Patch
@medusajs/fulfillment-manual Patch
@medusajs/locking-postgres Patch
@medusajs/locking-redis Patch
@medusajs/notification-local Patch
@medusajs/notification-sendgrid Patch
@medusajs/payment-stripe Patch
@medusajs/core-flows Patch
@medusajs/framework Patch
@medusajs/js-sdk Patch
@medusajs/modules-sdk Patch
@medusajs/orchestration Patch
@medusajs/query Patch
@medusajs/types Patch
@medusajs/utils Patch
@medusajs/workflows-sdk Patch
@medusajs/http-types-generator Patch
@medusajs/cli Patch
@medusajs/deps Patch
@medusajs/eslint-plugin Patch
@medusajs/telemetry Patch
@medusajs/admin-bundler Patch
@medusajs/admin-sdk Patch
@medusajs/admin-shared Patch
@medusajs/admin-vite-plugin Patch
@medusajs/dashboard Patch
@medusajs/icons Patch
@medusajs/toolbox Patch
@medusajs/ui-preset Patch
create-medusa-app Patch
@medusajs/ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@medusa-os-bot

medusa-os-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

Thanks for the contribution! A few items need to be addressed before this can move forward:

Solid fix for capture-retry idempotency: PR template complete, changeset present with correct patch bump, integration tests added and passing. One required change: an added code comment inside capturePayment_ contains (see #16097), which is a PR reference embedded in source code — per project conventions these must be removed.

Triggered by: new PR opened

Reword the over-capture guard comment to explain the row lock inline
instead of pointing at a PR number, per review feedback.
@medusa-os-bot

medusa-os-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

Thanks for the contribution! Initial automated review looks good.

Solid fix for capture-retry idempotency. PR template complete, changeset present with correct patch bump, integration tests added and passing. Prior required change (PR reference in a code comment) has been addressed. No security, performance, or correctness issues found.

Triggered by: new commit pushed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Retrying a payment capture after a provider-side failure sends a different idempotency key

1 participant