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:
committed by
GitHub
parent
6746fecd72
commit
62d103b44f
6
.changeset/sweet-peaches-dress.md
Normal file
6
.changeset/sweet-peaches-dress.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/payment": patch
|
||||||
|
"@medusajs/types": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(payment): Double idempotent capture called with auto capture behavior
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user