From 91d3332f9e98cfbfd12a2ef995e0b6e9848f464f Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Mon, 17 Feb 2025 18:08:19 +0100 Subject: [PATCH] feat: Add support for idempotency key in payments (#11494) --- packages/core/types/src/payment/provider.ts | 5 ++ .../payment/src/services/payment-module.ts | 30 ++++++- .../payment-stripe/src/core/stripe-base.ts | 81 +++++++++++++------ 3 files changed, 87 insertions(+), 29 deletions(-) diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index d72bf939a2..d18ca915fc 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -76,6 +76,11 @@ export type PaymentProviderContext = { * The customer information from Medusa. */ customer?: PaymentCustomerDTO + + /** + * Idempotency key for the request, if the payment provider supports it. It will be ignored otherwise. + */ + idempotency_key?: string } export type PaymentProviderInput = { diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index 46aa7c6be7..f6a7a99c39 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -347,7 +347,10 @@ export default class PaymentModuleService providerPaymentSession = await this.paymentProviderService_.createSession( input.provider_id, { - context: input.context, + context: { + idempotency_key: paymentSession!.id, + ...input.context, + }, data: { ...input.data, session_id: paymentSession!.id }, amount: input.amount, currency_code: input.currency_code, @@ -421,6 +424,7 @@ export default class PaymentModuleService data: data.data, amount: data.amount, currency_code: data.currency_code, + context: data.context, } ) @@ -490,7 +494,7 @@ export default class PaymentModuleService session.provider_id, { data: session.data, - context, + context: { idempotency_key: session.id, ...context }, } ) @@ -515,6 +519,10 @@ export default class PaymentModuleService } catch (error) { await this.paymentProviderService_.cancelPayment(session.provider_id, { data, + context: { + idempotency_key: payment?.id, + ...context, + }, }) throw error @@ -623,6 +631,7 @@ export default class PaymentModuleService try { await this.capturePaymentFromProvider_( payment, + capture, isFullyCaptured, sharedContext ) @@ -703,6 +712,7 @@ export default class PaymentModuleService @InjectManager() private async capturePaymentFromProvider_( payment: InferEntityType, + capture: InferEntityType | undefined, isFullyCaptured: boolean, @MedusaContext() sharedContext: Context = {} ) { @@ -710,6 +720,9 @@ export default class PaymentModuleService payment.provider_id, { data: payment.data!, + context: { + idempotency_key: capture?.id, + }, } ) @@ -818,6 +831,9 @@ export default class PaymentModuleService { data: payment.data!, amount: refund.raw_amount as BigNumberInput, + context: { + idempotency_key: refund.id, + }, } ) @@ -842,6 +858,9 @@ export default class PaymentModuleService await this.paymentProviderService_.cancelPayment(payment.provider_id, { data: payment.data!, + context: { + idempotency_key: payment.id, + }, }) await this.paymentService_.update( @@ -984,7 +1003,12 @@ export default class PaymentModuleService providerAccountHolder = await this.paymentProviderService_.createAccountHolder( input.provider_id, - { context: input.context } + { + context: { + idempotency_key: input.context?.customer?.id, + ...input.context, + }, + } ) // This can be empty when either the method is not supported or an account holder wasn't created diff --git a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts index 3ce4beb710..a0dd0753c1 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -173,9 +173,9 @@ abstract class StripeBase extends AbstractPaymentProvider { let sessionData try { - sessionData = (await this.stripe_.paymentIntents.create( - intentRequest - )) as unknown as Record + sessionData = (await this.stripe_.paymentIntents.create(intentRequest, { + idempotencyKey: context?.idempotency_key, + })) as unknown as Record } catch (e) { throw this.buildError( "An error occurred in InitiatePayment during the creation of the stripe payment intent", @@ -198,6 +198,7 @@ abstract class StripeBase extends AbstractPaymentProvider { async cancelPayment({ data, + context, }: CancelPaymentInput): Promise { try { const id = data?.id as string @@ -206,7 +207,9 @@ abstract class StripeBase extends AbstractPaymentProvider { return { data: data } } - const res = await this.stripe_.paymentIntents.cancel(id) + const res = await this.stripe_.paymentIntents.cancel(id, { + idempotencyKey: context?.idempotency_key, + }) return { data: res as unknown as Record } } catch (error) { if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) { @@ -219,11 +222,14 @@ abstract class StripeBase extends AbstractPaymentProvider { async capturePayment({ data, + context, }: CapturePaymentInput): Promise { const id = data?.id as string try { - const intent = await this.stripe_.paymentIntents.capture(id) + const intent = await this.stripe_.paymentIntents.capture(id, { + idempotencyKey: context?.idempotency_key, + }) return { data: intent as unknown as Record } } catch (error) { if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) { @@ -243,6 +249,7 @@ abstract class StripeBase extends AbstractPaymentProvider { async refundPayment({ amount, data, + context, }: RefundPaymentInput): Promise { const id = data?.id as string if (!id) { @@ -254,10 +261,15 @@ abstract class StripeBase extends AbstractPaymentProvider { try { const currencyCode = data?.currency as string - await this.stripe_.refunds.create({ - amount: getSmallestUnit(amount, currencyCode), - payment_intent: id as string, - }) + await this.stripe_.refunds.create( + { + amount: getSmallestUnit(amount, currencyCode), + payment_intent: id as string, + }, + { + idempotencyKey: context?.idempotency_key, + } + ) } catch (e) { throw this.buildError("An error occurred in refundPayment", e) } @@ -284,6 +296,7 @@ abstract class StripeBase extends AbstractPaymentProvider { data, currency_code, amount, + context, }: UpdatePaymentInput): Promise { const amountNumeric = getSmallestUnit(amount, currency_code) if (isPresent(amount) && data?.amount === amountNumeric) { @@ -292,9 +305,15 @@ abstract class StripeBase extends AbstractPaymentProvider { try { const id = data?.id as string - const sessionData = (await this.stripe_.paymentIntents.update(id, { - amount: amountNumeric, - })) as unknown as Record + const sessionData = (await this.stripe_.paymentIntents.update( + id, + { + amount: amountNumeric, + }, + { + idempotencyKey: context?.idempotency_key, + } + )) as unknown as Record return { data: sessionData } } catch (e) { @@ -305,7 +324,7 @@ abstract class StripeBase extends AbstractPaymentProvider { async createAccountHolder({ context, }: CreateAccountHolderInput): Promise { - const { account_holder, customer } = context + const { account_holder, customer, idempotency_key } = context if (account_holder?.data?.id) { return { id: account_holder.data.id as string } @@ -332,15 +351,20 @@ abstract class StripeBase extends AbstractPaymentProvider { : undefined try { - const stripeCustomer = await this.stripe_.customers.create({ - email: customer.email, - name: - customer.company_name || - `${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() || - undefined, - phone: customer.phone as string | undefined, - ...shipping, - }) + const stripeCustomer = await this.stripe_.customers.create( + { + email: customer.email, + name: + customer.company_name || + `${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() || + undefined, + phone: customer.phone as string | undefined, + ...shipping, + }, + { + idempotencyKey: idempotency_key, + } + ) return { id: stripeCustomer.id, @@ -412,10 +436,15 @@ abstract class StripeBase extends AbstractPaymentProvider { ) } - const resp = await this.stripe_.setupIntents.create({ - customer: accountHolderId, - ...data, - }) + const resp = await this.stripe_.setupIntents.create( + { + customer: accountHolderId, + ...data, + }, + { + idempotencyKey: context?.idempotency_key, + } + ) return { id: resp.id, data: resp as unknown as Record } }