diff --git a/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.spec.ts b/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.spec.ts index b6ac45cd45..1577f050d4 100644 --- a/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.spec.ts +++ b/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.spec.ts @@ -1,4 +1,5 @@ import { createScriptLoader } from '@bigcommerce/script-loader'; +import { noop } from 'lodash'; import { BillingAddress, @@ -1933,6 +1934,113 @@ describe('StripeOCSPaymentStrategy', () => { }); }); + describe('asyncPaymentValidation', () => { + const mockPaymentMethodWithFlag = (asyncPaymentValidation: boolean) => { + jest.spyOn( + paymentIntegrationService.getState(), + 'getPaymentMethodOrThrow', + ).mockReturnValue({ + ...getStripeOCSMock(), + initializationData: { + ...getStripeOCSMock().initializationData, + asyncPaymentValidation, + }, + }); + }; + + const mockStripeCheckoutWithConfirm = (confirmFn: jest.Mock) => { + jest.spyOn(stripeScriptLoader, 'getStripeCheckout').mockReturnValue( + Promise.resolve({ + ...getStripeCheckoutInstanceMock(), + loadActions: () => + Promise.resolve({ + type: StripeLoadActionsResultType.SUCCESS, + actions: { + ...getStripeCheckoutSessionActionsMock(), + confirm: confirmFn, + }, + }), + }), + ); + }; + + it('resolves without waiting for the second submitPayment response when flag is true', async () => { + mockPaymentMethodWithFlag(true); + mockFirstPaymentRequest(errorResponse); + jest.spyOn(paymentIntegrationService, 'submitPayment').mockReturnValueOnce( + new Promise(noop), + ); + confirmPaymentMock = jest.fn().mockResolvedValue({ + session: { + id: 'paymentIntentId', + status: { + paymentStatus: StripeCheckoutSessionPaymentStatus.UnPaid, + }, + }, + }); + mockStripeCheckoutWithConfirm(confirmPaymentMock); + + await stripeCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock(methodId)), + ).resolves.not.toThrow(); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + }); + + it('resolves and ignores error when second submitPayment fails and order already paid on the stripe side', async () => { + mockPaymentMethodWithFlag(true); + mockFirstPaymentRequest(errorResponse); + mockFirstPaymentRequest(new Error('second submitPayment error')); + confirmPaymentMock = jest.fn().mockResolvedValue({ + session: { + id: 'paymentIntentId', + status: { + paymentStatus: StripeCheckoutSessionPaymentStatus.Paid, + }, + }, + }); + mockStripeCheckoutWithConfirm(confirmPaymentMock); + + await stripeCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock(methodId)), + ).resolves.not.toThrow(); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + expect( + stripeIntegrationService.throwPaymentConfirmationProceedMessage, + ).not.toHaveBeenCalled(); + }); + + it('waits for the second submitPayment response when flag is false', async () => { + mockPaymentMethodWithFlag(false); + mockFirstPaymentRequest(errorResponse); + mockFirstPaymentRequest(new Error('second submitPayment error')); + confirmPaymentMock = jest.fn().mockResolvedValue({ + session: { + id: 'paymentIntentId', + status: { + paymentStatus: StripeCheckoutSessionPaymentStatus.UnPaid, + }, + }, + }); + mockStripeCheckoutWithConfirm(confirmPaymentMock); + + await stripeCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock(methodId)), + ).rejects.toThrow('second submitPayment error'); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + }); + }); + describe('vaulting via selected payment method', () => { const setupStripeCheckoutWithEvent = ( eventValue: diff --git a/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.ts b/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.ts index c35ac13cda..a620af84a0 100644 --- a/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.ts +++ b/packages/stripe-integration/src/stripe-cs/stripe-cs-payment-strategy.ts @@ -1,4 +1,4 @@ -import { cloneDeep, merge } from 'lodash'; +import { cloneDeep, merge, noop } from 'lodash'; import { InvalidArgumentError, @@ -368,7 +368,8 @@ export default class StripeCSPaymentStrategy implements PaymentStrategy { const { initializationData } = this.paymentIntegrationService .getState() .getPaymentMethodOrThrow(methodId, gatewayId); - const { sendSecondPaymentRequestOnStripeError } = initializationData || {}; + const { sendSecondPaymentRequestOnStripeError, asyncPaymentValidation } = + initializationData || {}; if (stripeError || !stripeCheckoutSession) { if (sendSecondPaymentRequestOnStripeError) { @@ -395,6 +396,15 @@ export default class StripeCSPaymentStrategy implements PaymentStrategy { throw new PaymentMethodFailedError(stripeError?.message); } + if (asyncPaymentValidation) { + // INFO: await is skipped and errors are ignored here intentionally, because the payment + // is already confirmed on the Stripe side, so the order status will be updated + // by webhooks even if this request fails. + this.paymentIntegrationService.submitPayment(paymentPayload).catch(noop); + + return; + } + try { return await this.paymentIntegrationService.submitPayment(paymentPayload); } catch (error) { diff --git a/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.spec.ts b/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.spec.ts index 9518c585b8..8b5b826a2e 100644 --- a/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.spec.ts +++ b/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.spec.ts @@ -1,4 +1,5 @@ import { createScriptLoader } from '@bigcommerce/script-loader'; +import { noop } from 'lodash'; import { InvalidArgumentError, @@ -1362,6 +1363,105 @@ describe('StripeOCSPaymentStrategy', () => { ); }); }); + + describe('asyncPaymentValidation', () => { + const mockPaymentMethodWithFlag = (asyncPaymentValidation: boolean) => { + jest.spyOn( + paymentIntegrationService.getState(), + 'getPaymentMethodOrThrow', + ).mockReturnValue({ + ...getStripeOCSMock(), + initializationData: { + ...getStripeOCSMock().initializationData, + asyncPaymentValidation, + }, + }); + }; + + const mockStripeClientWithConfirm = (confirmFn: jest.Mock) => { + stripeUPEJsMock = { + ...getStripeJsMock(), + confirmPayment: confirmFn, + retrievePaymentIntent: jest.fn(), + }; + jest.spyOn(stripeScriptLoader, 'getStripeClient').mockImplementation( + jest.fn(() => Promise.resolve(stripeUPEJsMock)), + ); + }; + + it('resolves without waiting for the second submitPayment response when flag is true', async () => { + mockPaymentMethodWithFlag(true); + mockFirstPaymentRequest(errorResponse); + jest.spyOn(paymentIntegrationService, 'submitPayment').mockReturnValueOnce( + new Promise(noop), + ); + confirmPaymentMock = jest.fn().mockResolvedValue({ + paymentIntent: { + id: 'paymentIntentId', + client_secret: 'paymentIntentClientSecret', + }, + }); + mockStripeClientWithConfirm(confirmPaymentMock); + + await stripeOCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeOCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock()), + ).resolves.not.toThrow(); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + }); + + it('resolves and ignores error when second submitPayment request fails', async () => { + mockPaymentMethodWithFlag(true); + mockFirstPaymentRequest(errorResponse); + mockFirstPaymentRequest(new Error('second submitPayment error')); + confirmPaymentMock = jest.fn().mockResolvedValue({ + paymentIntent: { + id: 'paymentIntentId', + client_secret: 'paymentIntentClientSecret', + }, + }); + mockStripeClientWithConfirm(confirmPaymentMock); + + await stripeOCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeOCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock()), + ).resolves.not.toThrow(); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + expect( + stripeIntegrationService.throwPaymentConfirmationProceedMessage, + ).not.toHaveBeenCalled(); + }); + + it('waits for the second submitPayment response when flag is false', async () => { + mockPaymentMethodWithFlag(false); + mockFirstPaymentRequest(errorResponse); + mockFirstPaymentRequest(new Error('second submitPayment error')); + confirmPaymentMock = jest.fn().mockResolvedValue({ + paymentIntent: { + id: 'paymentIntentId', + client_secret: 'paymentIntentClientSecret', + }, + }); + mockStripeClientWithConfirm(confirmPaymentMock); + + await stripeOCSPaymentStrategy.initialize(stripeOptions); + + await expect( + stripeOCSPaymentStrategy.execute(getStripeOCSOrderRequestBodyMock()), + ).rejects.toThrow(PaymentMethodFailedError); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledTimes(2); + expect( + stripeIntegrationService.throwPaymentConfirmationProceedMessage, + ).toHaveBeenCalled(); + }); + }); }); describe('#vaulted instruments', () => { diff --git a/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.ts b/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.ts index 6e760bcae9..6e661b59fc 100644 --- a/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.ts +++ b/packages/stripe-integration/src/stripe-ocs/stripe-ocs-payment-strategy.ts @@ -1,4 +1,4 @@ -import { merge } from 'lodash'; +import { merge, noop } from 'lodash'; import { InvalidArgumentError, @@ -313,13 +313,13 @@ export default class StripeOCSPaymentStrategy implements PaymentStrategy { paymentIntentClientSecret || token, paymentMethodOptions, ); + const { initializationData } = this.paymentIntegrationService + .getState() + .getPaymentMethodOrThrow(methodId, gatewayId); + const { sendSecondPaymentRequestOnStripeError, asyncPaymentValidation } = + initializationData || {}; if (stripeError || !paymentIntent) { - const { initializationData } = this.paymentIntegrationService - .getState() - .getPaymentMethodOrThrow(methodId, gatewayId); - const { sendSecondPaymentRequestOnStripeError } = initializationData || {}; - if (sendSecondPaymentRequestOnStripeError) { // INFO: even in case when stripe payment confirmation was declined // we need to send submitPayment request to update status of checkout session on BE side. @@ -342,6 +342,15 @@ export default class StripeOCSPaymentStrategy implements PaymentStrategy { this.stripeIntegrationService.throwStripeError(stripeError); } + if (asyncPaymentValidation) { + // INFO: await is skipped and errors are ignored here intentionally, because the payment + // is already confirmed on the Stripe side, so the order status will be updated + // by webhooks even if this request fails. + this.paymentIntegrationService.submitPayment(paymentPayload).catch(noop); + + return; + } + try { return await this.paymentIntegrationService.submitPayment(paymentPayload); } catch (error) { diff --git a/packages/stripe-utils/src/stripe.ts b/packages/stripe-utils/src/stripe.ts index d3801a6e1b..f52d196479 100644 --- a/packages/stripe-utils/src/stripe.ts +++ b/packages/stripe-utils/src/stripe.ts @@ -804,6 +804,7 @@ export interface StripeInitializationData { sendSecondPaymentRequestOnStripeError?: boolean; adaptivePricingEnabled?: boolean; hasSectionOnTopOfPaymentsList?: boolean; + asyncPaymentValidation?: boolean; } export interface StripeElementUpdateOptions {