fix(payment): Double idempotent capture called with auto capture beha… (#14073)

* fix(payment): Double idempotent capture called with auto capture behaviour

* Create sweet-peaches-dress.md

* naming

* feedback

* naming
This commit is contained in:
Adrien de Peretti
2025-11-18 10:50:50 +01:00
committed by GitHub
parent 6746fecd72
commit 62d103b44f
4 changed files with 187 additions and 26 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/payment": patch
"@medusajs/types": patch
---
fix(payment): Double idempotent capture called with auto capture behavior

View File

@@ -1,6 +1,10 @@
import { BigNumberInput } from "../totals" import { BigNumberInput } from "../totals"
import { PaymentCollectionStatus, PaymentSessionStatus } from "./common" import { PaymentCollectionStatus, PaymentSessionStatus } from "./common"
import { PaymentAccountHolderDTO, PaymentCustomerDTO, PaymentProviderContext, } from "./provider" import {
PaymentAccountHolderDTO,
PaymentCustomerDTO,
PaymentProviderContext,
} from "./provider"
/** /**
* The payment collection to be created. * The payment collection to be created.
@@ -147,6 +151,11 @@ export interface CreateCaptureDTO {
* a user's ID. * a user's ID.
*/ */
captured_by?: string captured_by?: string
/**
* Whether the capture was automatically captured.
*/
is_captured?: boolean
} }
/** /**

View File

@@ -631,6 +631,62 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
}) })
) )
}) })
it("should auto capture payment when provider returns captured status", async () => {
const collection = await service.createPaymentCollections({
amount: 200,
currency_code: "usd",
})
const session = await service.createPaymentSession(collection.id, {
provider_id: "pp_system_default",
amount: 100,
currency_code: "usd",
data: {},
context: {
customer: { id: "cus-id-1", email: "new@test.tsst" },
},
})
// Mock the provider to return CAPTURED status
const authorizePaymentMock = jest
.spyOn(
(service as any).paymentProviderService_,
"authorizePayment"
)
.mockResolvedValueOnce({
data: { payment_id: "external_payment_id" },
status: "captured",
})
const capturePaymentMock = jest.spyOn(
(service as any).paymentProviderService_,
"capturePayment"
)
const payment = await service.authorizePaymentSession(
session.id,
{}
)
expect(authorizePaymentMock).toHaveBeenCalledTimes(1)
expect(capturePaymentMock).not.toHaveBeenCalled()
expect(payment).toEqual(
expect.objectContaining({
id: expect.any(String),
amount: 100,
currency_code: "usd",
provider_id: "pp_system_default",
captured_at: expect.any(Date),
captures: [expect.objectContaining({})],
payment_session: expect.objectContaining({
status: "authorized",
authorized_at: expect.any(Date),
}),
})
)
})
}) })
}) })
@@ -780,6 +836,68 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
"The payment: pay-id-1 has been canceled." "The payment: pay-id-1 has been canceled."
) )
}) })
it("should not call provider capturePayment for auto-captured payments", async () => {
const collection = await service.createPaymentCollections({
amount: 200,
currency_code: "usd",
})
const session = await service.createPaymentSession(collection.id, {
provider_id: "pp_system_default",
amount: 100,
currency_code: "usd",
data: {},
context: {
customer: { id: "cus-id-1", email: "new@test.tsst" },
},
})
// Mock the provider to return CAPTURED status for auto-capture
jest
.spyOn(
(service as any).paymentProviderService_,
"authorizePayment"
)
.mockResolvedValueOnce({
data: { payment_id: "external_payment_id" },
status: "captured",
})
const payment = await service.authorizePaymentSession(
session.id,
{}
)
// Spy on capturePayment provider method
const capturePaymentMock = jest.spyOn(
(service as any).paymentProviderService_,
"capturePayment"
)
// Try to capture the already auto-captured payment
const capturedPayment = await service.capturePayment({
amount: 100,
payment_id: payment.id,
})
// Provider's capturePayment should NOT be called since it was auto-captured
expect(capturePaymentMock).not.toHaveBeenCalled()
// Verify data consistency
expect(capturedPayment).toEqual(
expect.objectContaining({
id: payment.id,
amount: 100,
captured_at: expect.any(Date),
captures: [
expect.objectContaining({
amount: 100,
}),
],
})
)
})
}) })
describe("refund", () => { describe("refund", () => {

View File

@@ -585,10 +585,10 @@ export default class PaymentModuleService
status: PaymentSessionStatus, status: PaymentSessionStatus,
@MedusaContext() sharedContext?: Context @MedusaContext() sharedContext?: Context
): Promise<InferEntityType<typeof Payment>> { ): Promise<InferEntityType<typeof Payment>> {
let autoCapture = false let isCaptured = false
if (status === PaymentSessionStatus.CAPTURED) { if (status === PaymentSessionStatus.CAPTURED) {
status = PaymentSessionStatus.AUTHORIZED status = PaymentSessionStatus.AUTHORIZED
autoCapture = true isCaptured = true
} }
await this.paymentSessionService_.update( await this.paymentSessionService_.update(
@@ -617,9 +617,13 @@ export default class PaymentModuleService
sharedContext sharedContext
) )
if (autoCapture) { if (isCaptured) {
await this.capturePayment( await this.capturePayment(
{ payment_id: payment.id, amount: session.amount as BigNumberInput }, {
payment_id: payment.id,
amount: session.amount as BigNumberInput,
is_captured: isCaptured,
},
sharedContext sharedContext
) )
} }
@@ -646,8 +650,9 @@ export default class PaymentModuleService
data: CreateCaptureDTO, data: CreateCaptureDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<PaymentDTO> { ): Promise<PaymentDTO> {
let { is_captured, ...data_ } = data
const payment = await this.paymentService_.retrieve( const payment = await this.paymentService_.retrieve(
data.payment_id, data_.payment_id,
{ {
select: [ select: [
"id", "id",
@@ -665,8 +670,14 @@ export default class PaymentModuleService
sharedContext sharedContext
) )
let isCaptured = is_captured
if (!isCaptured) {
const isAutoCaptured = !!payment?.captured_at
isCaptured = isAutoCaptured
}
const { isFullyCaptured, capture } = await this.capturePayment_( const { isFullyCaptured, capture } = await this.capturePayment_(
data, data_,
payment, payment,
sharedContext sharedContext
) )
@@ -675,7 +686,7 @@ export default class PaymentModuleService
await this.capturePaymentFromProvider_( await this.capturePaymentFromProvider_(
payment, payment,
capture, capture,
isFullyCaptured, { isFullyCaptured, isCaptured },
sharedContext sharedContext
) )
} catch (error) { } catch (error) {
@@ -763,27 +774,44 @@ export default class PaymentModuleService
protected async capturePaymentFromProvider_( protected async capturePaymentFromProvider_(
payment: InferEntityType<typeof Payment>, payment: InferEntityType<typeof Payment>,
capture: InferEntityType<typeof Capture> | undefined, capture: InferEntityType<typeof Capture> | undefined,
isFullyCaptured: boolean, options: {
isFullyCaptured?: boolean
isCaptured?: boolean
} = {},
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
) { ) {
const paymentData = await this.paymentProviderService_.capturePayment( if (!options.isCaptured) {
payment.provider_id, const paymentData = await this.paymentProviderService_.capturePayment(
{ payment.provider_id,
data: payment.data!, {
context: { data: payment.data!,
idempotency_key: capture?.id, context: {
}, idempotency_key: capture?.id,
} },
) }
)
await this.paymentService_.update( await this.paymentService_.update(
{ {
id: payment.id, id: payment.id,
data: paymentData.data, data: paymentData.data,
captured_at: isFullyCaptured ? new Date() : undefined, captured_at: options.isFullyCaptured ? new Date() : undefined,
}, },
sharedContext sharedContext
) )
} else if (options.isFullyCaptured && !payment.captured_at) {
/**
* In the case of auto capture, we need to update the payment to set the captured_at date
* only if fully captured.
*/
await this.paymentService_.update(
{
id: payment.id,
captured_at: new Date(),
},
sharedContext
)
}
return payment return payment
} }