fix(payment): Idempotent cancellation and proper creationg fail handling (#10135)

RESOLVES SUP-188

**What**
Two changes are happening here
- In the stripe payment provider, idempotent cancellation action, if not id is provided then return the existing data unchanged
- Payment module should not try to cancel a session that have failed to be created in the first place
This commit is contained in:
Adrien de Peretti
2024-11-18 17:45:13 +00:00
committed by GitHub
parent 47ca1d4b54
commit b1b7a4abf1
4 changed files with 102 additions and 16 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/payment": patch
"@medusajs/payment-stripe": patch
---
fix(payment): Idempotent cancellation and proper creationg fail handling
@@ -14,6 +14,10 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
moduleName: Modules.PAYMENT, moduleName: Modules.PAYMENT,
testSuite: ({ MikroOrmWrapper, service }) => { testSuite: ({ MikroOrmWrapper, service }) => {
describe("Payment Module Service", () => { describe("Payment Module Service", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it(`should export the appropriate linkable configuration`, () => { it(`should export the appropriate linkable configuration`, () => {
const linkable = Module(Modules.PAYMENT, { const linkable = Module(Modules.PAYMENT, {
service: PaymentModuleService, service: PaymentModuleService,
@@ -395,7 +399,6 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
customer: {}, customer: {},
billing_address: {}, billing_address: {},
email: "test@test.test.com", email: "test@test.test.com",
resource_id: "cart_test",
}, },
}) })
@@ -422,6 +425,80 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
}) })
) )
}) })
it("should gracefully handle payment session creation fails from external provider", async () => {
jest
.spyOn((service as any).paymentProviderService_, "createSession")
.mockImplementationOnce(() => {
throw new Error("Create session error")
})
const deleteProviderSessionMock = jest.spyOn(
(service as any).paymentProviderService_,
"deleteSession"
)
const deletePaymentSessionMock = jest.spyOn(
(service as any).paymentSessionService_,
"delete"
)
const error = await service
.createPaymentSession("pay-col-id-1", {
provider_id: "pp_system_default",
amount: 200,
currency_code: "usd",
data: {},
context: {
extra: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
},
})
.catch((e) => e)
expect(deleteProviderSessionMock).toHaveBeenCalledTimes(0)
expect(deletePaymentSessionMock).toHaveBeenCalledTimes(1)
expect(error.message).toEqual("Create session error")
})
it("should gracefully handle payment session creation fails from internal failure", async () => {
jest
.spyOn((service as any).paymentSessionService_, "update")
.mockImplementationOnce(() => {
throw new Error("Update session error")
})
const deleteProviderSessionMock = jest.spyOn(
(service as any).paymentProviderService_,
"deleteSession"
)
const deletePaymentSessionMock = jest.spyOn(
(service as any).paymentSessionService_,
"delete"
)
const error = await service
.createPaymentSession("pay-col-id-1", {
provider_id: "pp_system_default",
amount: 200,
currency_code: "usd",
data: {},
context: {
extra: {},
customer: {},
billing_address: {},
email: "test@test.test.com",
},
})
.catch((e) => e)
expect(deleteProviderSessionMock).toHaveBeenCalledTimes(1)
expect(deletePaymentSessionMock).toHaveBeenCalledTimes(1)
expect(error.message).toEqual("Update session error")
})
}) })
describe("update", () => { describe("update", () => {
@@ -436,7 +513,6 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
customer: {}, customer: {},
billing_address: {}, billing_address: {},
email: "test@test.test.com", email: "test@test.test.com",
resource_id: "cart_test",
}, },
}) })
@@ -446,7 +522,6 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
currency_code: "eur", currency_code: "eur",
data: {}, data: {},
context: { context: {
resource_id: "res_id",
extra: {}, extra: {},
customer: {}, customer: {},
billing_address: {}, billing_address: {},
@@ -299,6 +299,7 @@ export default class PaymentModuleService
@MedusaContext() sharedContext?: Context @MedusaContext() sharedContext?: Context
): Promise<PaymentSessionDTO> { ): Promise<PaymentSessionDTO> {
let paymentSession: PaymentSession | undefined let paymentSession: PaymentSession | undefined
let providerPaymentSession: Record<string, unknown> | undefined
try { try {
paymentSession = await this.createPaymentSession_( paymentSession = await this.createPaymentSession_(
@@ -307,30 +308,33 @@ export default class PaymentModuleService
sharedContext sharedContext
) )
const providerSessionSession = providerPaymentSession = await this.paymentProviderService_.createSession(
await this.paymentProviderService_.createSession(input.provider_id, { input.provider_id,
{
context: { ...input.context, session_id: paymentSession.id }, context: { ...input.context, session_id: paymentSession.id },
amount: input.amount, amount: input.amount,
currency_code: input.currency_code, currency_code: input.currency_code,
}) }
)
paymentSession = ( paymentSession = (
await this.paymentSessionService_.update( await this.paymentSessionService_.update(
{ {
id: paymentSession.id, id: paymentSession.id,
data: { ...input.data, ...providerSessionSession }, data: { ...input.data, ...providerPaymentSession },
}, },
sharedContext sharedContext
) )
)[0] )[0]
} catch (error) { } catch (error) {
if (paymentSession) { if (providerPaymentSession) {
// In case the session is created, but fails to be updated in Medusa,
// we catch the error and delete the session and rethrow.
await this.paymentProviderService_.deleteSession({ await this.paymentProviderService_.deleteSession({
provider_id: input.provider_id, provider_id: input.provider_id,
data: input.data, data: input.data,
}) })
}
if (paymentSession) {
await this.paymentSessionService_.delete( await this.paymentSessionService_.delete(
paymentSession.id, paymentSession.id,
sharedContext sharedContext
@@ -340,9 +344,7 @@ export default class PaymentModuleService
throw error throw error
} }
return await this.baseRepository_.serialize(paymentSession, { return await this.baseRepository_.serialize(paymentSession)
populate: true,
})
} }
@InjectTransactionManager() @InjectTransactionManager()
@@ -573,9 +575,7 @@ export default class PaymentModuleService
// NOTE: currently there is no update with the provider but maybe data could be updated // NOTE: currently there is no update with the provider but maybe data could be updated
const result = await this.paymentService_.update(data, sharedContext) const result = await this.paymentService_.update(data, sharedContext)
return await this.baseRepository_.serialize<PaymentDTO>(result[0], { return await this.baseRepository_.serialize<PaymentDTO>(result[0])
populate: true,
})
} }
@InjectManager() @InjectManager()
@@ -186,6 +186,11 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> { ): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
try { try {
const id = paymentSessionData.id as string const id = paymentSessionData.id as string
if (!id) {
return paymentSessionData
}
return (await this.stripe_.paymentIntents.cancel( return (await this.stripe_.paymentIntents.cancel(
id id
)) as unknown as PaymentProviderSessionResponse["data"] )) as unknown as PaymentProviderSessionResponse["data"]