diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index 634675845bb..adecf94048e 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -1074,6 +1074,79 @@ describe('CheckoutService', () => { }); }); + describe('confirms pending setup intent on subscription', () => { + const mockPendingSetupIntentId = 'seti_pending_12345'; + const mockSubscriptionWithPendingSetup = StripeResponseFactory( + StripeSubscriptionFactory({ + pending_setup_intent: mockPendingSetupIntentId, + }) + ); + const mockInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + payment_intent: null, + amount_due: 0, + }) + ); + const mockSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'succeeded', + payment_method: StripePaymentMethodFactory().id, + }) + ); + + beforeEach(async () => { + jest + .spyOn(subscriptionManager, 'create') + .mockResolvedValue(mockSubscriptionWithPendingSetup); + jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice); + jest + .spyOn(setupIntentManager, 'confirm') + .mockResolvedValue(mockSetupIntent); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockSetupIntent); + }); + + beforeEach(async () => { + await checkoutService.payWithStripe( + mockCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockCart.uid + ); + }); + + it('calls setupIntentManager.confirm with the pending setup intent', () => { + expect(setupIntentManager.confirm).toHaveBeenCalledWith( + mockPendingSetupIntentId, + mockConfirmationToken.id + ); + }); + + it('does not call createAndConfirm', () => { + expect(setupIntentManager.createAndConfirm).not.toHaveBeenCalled(); + }); + + it('increments setup intent status counter', () => { + expect(statsd.increment).toHaveBeenCalledWith( + 'checkout_stripe_payment_setupintent_status', + { status: mockSetupIntent.status } + ); + }); + + it('calls updateProcessingCart with the confirmed setup intent id', () => { + expect(cartManager.updateProcessingCart).toHaveBeenCalledWith( + mockCart.id, + mockPrePayStepsResult.version, + { + stripeSubscriptionId: mockSubscriptionWithPendingSetup.id, + stripeIntentId: mockSetupIntent.id, + } + ); + }); + }); + describe('payment intent error', () => { beforeEach(async () => { jest diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index 7f6541d86bb..b5dd6d09487 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -189,7 +189,7 @@ export class CheckoutService { await this.accountCustomerManager.getAccountCustomerByUid(uid); throw new AccountCustomerAlreadyExistsError(uid); - } catch(error) { + } catch (error) { if (!(error instanceof AccountCustomerNotFoundError)) { throw error; } @@ -428,8 +428,7 @@ export class CheckoutService { trial_period_days: freeTrial.trialLengthDays, trial_settings: { end_behavior: { - missing_payment_method: - 'cancel' as const, + missing_payment_method: 'cancel' as const, }, }, } @@ -488,10 +487,7 @@ export class CheckoutService { subscription.status ); } - await this.freeTrialManager.recordFreeTrial( - uid, - freeTrial.internalName - ); + await this.freeTrialManager.recordFreeTrial(uid, freeTrial.internalName); } // Get payment/setup intent for subscription @@ -516,6 +512,15 @@ export class CheckoutService { off_session: false, } ); + } else if (subscription.pending_setup_intent) { + intent = await this.setupIntentManager.confirm( + subscription.pending_setup_intent, + confirmationTokenId + ); + + this.statsd.increment('checkout_stripe_payment_setupintent_status', { + status: intent.status, + }); } else { intent = await this.setupIntentManager.createAndConfirm( customer.id, @@ -743,10 +748,7 @@ export class CheckoutService { subscription.status ); } - await this.freeTrialManager.recordFreeTrial( - uid, - freeTrial.internalName - ); + await this.freeTrialManager.recordFreeTrial(uid, freeTrial.internalName); } const updatedVersion = version + 1; @@ -973,15 +975,14 @@ export class CheckoutService { taxAddress, }); } else { - upcomingInvoice = - await this.invoiceManager.previewUpcomingForUpgrade({ - priceId, - customer, - fromSubscriptionItem, - ...(isTrialing && { - trialEnd: Math.floor(Date.now() / 1000), - }), - }); + upcomingInvoice = await this.invoiceManager.previewUpcomingForUpgrade({ + priceId, + customer, + fromSubscriptionItem, + ...(isTrialing && { + trialEnd: Math.floor(Date.now() / 1000), + }), + }); } return upcomingInvoice.subtotal; } else { @@ -1014,7 +1015,10 @@ export class CheckoutService { const fetchResult = await Promise.all([ this.nimbusManager.fetchExperiments({ - nimbusUserId: this.nimbusManager.generateNimbusId(uid, experimentationId), + nimbusUserId: this.nimbusManager.generateNimbusId( + uid, + experimentationId + ), preview: searchParams?.experimentationPreview === 'true', }), this.productConfigurationManager.getFreeTrial(offeringConfigId), @@ -1035,9 +1039,7 @@ export class CheckoutService { } const [nimbusResult, freeTrialUtil] = fetchResult; - if ( - !nimbusResult?.Features?.['free-trial-feature']?.enabled - ) { + if (!nimbusResult?.Features?.['free-trial-feature']?.enabled) { return null; } @@ -1049,7 +1051,9 @@ export class CheckoutService { const matchingTrial = freeTrials.find( (trial) => trial.trialLengthDays > 0 && - trial.countries.some((country) => country.slice(0, 2) === countryCode) && + trial.countries.some( + (country) => country.slice(0, 2) === countryCode + ) && trial.intervals.includes(interval) ); return matchingTrial ?? null; @@ -1074,7 +1078,7 @@ export class CheckoutService { interval: args.interval, uid: args.uid, experimentationId: args.experimentationId, - searchParams: args.searchParams + searchParams: args.searchParams, }); if (!offer) { return { offer: null, userEligible: false }; @@ -1088,7 +1092,7 @@ export class CheckoutService { return { offer, - userEligible: !isBlockedByCooldown + userEligible: !isBlockedByCooldown, }; } } diff --git a/libs/payments/customer/src/lib/setupIntent.manager.spec.ts b/libs/payments/customer/src/lib/setupIntent.manager.spec.ts index 29495c694ef..e91b491b5ff 100644 --- a/libs/payments/customer/src/lib/setupIntent.manager.spec.ts +++ b/libs/payments/customer/src/lib/setupIntent.manager.spec.ts @@ -63,6 +63,31 @@ describe('SetupIntentManager', () => { }); }); + describe('confirm', () => { + it('should confirm an existing setup intent', async () => { + const mockSetupIntentId = 'seti_12345'; + const mockConfirmationToken = 'confirmToken'; + const mockResponse = StripeResponseFactory(StripeSetupIntentFactory()); + + jest + .spyOn(stripeClient, 'setupIntentConfirm') + .mockResolvedValue(mockResponse); + + const result = await setupIntentManager.confirm( + mockSetupIntentId, + mockConfirmationToken + ); + + expect(stripeClient.setupIntentConfirm).toHaveBeenCalledWith( + mockSetupIntentId, + { + confirmation_token: mockConfirmationToken, + } + ); + expect(result).toEqual(mockResponse); + }); + }); + describe('retrieve', () => { it('should retrieve a payment intent', async () => { const mockResponse = StripeResponseFactory(StripeSetupIntentFactory()); diff --git a/libs/payments/customer/src/lib/setupIntent.manager.ts b/libs/payments/customer/src/lib/setupIntent.manager.ts index 8da7aeb15b6..23618f25f68 100644 --- a/libs/payments/customer/src/lib/setupIntent.manager.ts +++ b/libs/payments/customer/src/lib/setupIntent.manager.ts @@ -23,6 +23,12 @@ export class SetupIntentManager { }); } + async confirm(setupIntentId: string, confirmationTokenId: string) { + return this.stripeClient.setupIntentConfirm(setupIntentId, { + confirmation_token: confirmationTokenId, + }); + } + async retrieve(setupIntentId: string) { return this.stripeClient.setupIntentRetrieve(setupIntentId); } diff --git a/libs/payments/stripe/src/lib/stripe.client.spec.ts b/libs/payments/stripe/src/lib/stripe.client.spec.ts index 3f58e1fe694..b7515d45b47 100644 --- a/libs/payments/stripe/src/lib/stripe.client.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.client.spec.ts @@ -18,6 +18,7 @@ import { StripePaymentMethodFactory } from './factories/payment-method.factory'; import { StripePriceFactory } from './factories/price.factory'; import { StripeProductFactory } from './factories/product.factory'; import { StripePromotionCodeFactory } from './factories/promotion-code.factory'; +import { StripeSetupIntentFactory } from './factories/setup-intent.factory'; import { StripeSubscriptionFactory } from './factories/subscription.factory'; import { StripeUpcomingInvoiceFactory } from './factories/upcoming-invoice.factory'; import { StripeClient } from './stripe.client'; @@ -59,6 +60,14 @@ const mockStripeSubscriptionsRetrieve = mockJestFnGenerator(); const mockStripeSubscriptionsUpdate = mockJestFnGenerator(); +const mockStripeSetupIntentsCancel = + mockJestFnGenerator(); +const mockStripeSetupIntentsConfirm = + mockJestFnGenerator(); +const mockStripeSetupIntentsCreate = + mockJestFnGenerator(); +const mockStripeSetupIntentsRetrieve = + mockJestFnGenerator(); jest.mock('stripe', () => ({ Stripe: function () { @@ -94,6 +103,12 @@ jest.mock('stripe', () => ({ retrieve: mockStripeSubscriptionsRetrieve, update: mockStripeSubscriptionsUpdate, }, + setupIntents: { + cancel: mockStripeSetupIntentsCancel, + confirm: mockStripeSetupIntentsConfirm, + create: mockStripeSetupIntentsCreate, + retrieve: mockStripeSetupIntentsRetrieve, + }, }; }, })); @@ -372,4 +387,63 @@ describe('StripeClient', () => { expect(result).toEqual(mockResponse); }); }); + + describe('setupIntentCancel', () => { + it('cancels a setup intent within Stripe', async () => { + const mockSetupIntent = StripeSetupIntentFactory(); + const mockResponse = StripeResponseFactory(mockSetupIntent); + + mockStripeSetupIntentsCancel.mockResolvedValue(mockResponse); + + const result = await stripeClient.setupIntentCancel(mockSetupIntent.id); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('setupIntentCreate', () => { + it('creates a setup intent within Stripe', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockSetupIntent = StripeSetupIntentFactory(); + const mockResponse = StripeResponseFactory(mockSetupIntent); + + mockStripeSetupIntentsCreate.mockResolvedValue(mockResponse); + + const result = await stripeClient.setupIntentCreate({ + customer: mockCustomer.id, + confirm: true, + }); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('setupIntentRetrieve', () => { + it('retrieves a setup intent within Stripe', async () => { + const mockSetupIntent = StripeSetupIntentFactory(); + const mockResponse = StripeResponseFactory(mockSetupIntent); + + mockStripeSetupIntentsRetrieve.mockResolvedValue(mockResponse); + + const result = await stripeClient.setupIntentRetrieve(mockSetupIntent.id); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('setupIntentConfirm', () => { + it('confirms a setup intent within Stripe', async () => { + const mockSetupIntent = StripeSetupIntentFactory(); + const mockResponse = StripeResponseFactory(mockSetupIntent); + const mockConfirmationToken = 'ctoken_12345'; + + mockStripeSetupIntentsConfirm.mockResolvedValue(mockResponse); + + const result = await stripeClient.setupIntentConfirm(mockSetupIntent.id, { + confirmation_token: mockConfirmationToken, + }); + + expect(result).toEqual(mockResponse); + }); + }); }); diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts index 1c4b9d98d6a..180e478de7e 100644 --- a/libs/payments/stripe/src/lib/stripe.client.ts +++ b/libs/payments/stripe/src/lib/stripe.client.ts @@ -142,8 +142,10 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('subscriptionsList', undefined, args[0]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), - client: (_: any, context: StripeClient) => new AsyncLocalStorageAdapter(false, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), + client: (_: any, context: StripeClient) => + new AsyncLocalStorageAdapter(false, context.log), }) @CaptureTimingWithStatsD() async subscriptionsList(params?: Stripe.SubscriptionListParams) { @@ -197,8 +199,10 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('subscriptionsRetrieve', args[0], args[1]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), - client: (_: any, context: StripeClient) => new AsyncLocalStorageAdapter(false, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), + client: (_: any, context: StripeClient) => + new AsyncLocalStorageAdapter(false, context.log), }) @CaptureTimingWithStatsD() async subscriptionsRetrieve( @@ -318,8 +322,10 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('paymentMethodsRetrieve', args[0], args[1]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), - client: (_: any, context: StripeClient) => new AsyncLocalStorageAdapter(false, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), + client: (_: any, context: StripeClient) => + new AsyncLocalStorageAdapter(false, context.log), }) @CaptureTimingWithStatsD() async paymentMethodRetrieve( @@ -346,7 +352,8 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('pricesRetrieve', args[0], args[1]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), ttlSeconds: 600, client: new MemoryAdapter(), }) @@ -362,7 +369,8 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('productsRetrieve', args[0], args[1]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), ttlSeconds: 600, client: new MemoryAdapter(), }) @@ -378,7 +386,8 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('promotionCodesList', undefined, args[0]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), ttlSeconds: 600, client: new MemoryAdapter(), }) @@ -394,7 +403,8 @@ export class StripeClient { @Cacheable({ cacheKey: (args: any) => cacheKeyForClient('promotionCodesRetrieve', args[0], args[1]), - strategy: (_: any, context: StripeClient) => new CacheFirstStrategy(undefined, undefined, context.log), + strategy: (_: any, context: StripeClient) => + new CacheFirstStrategy(undefined, undefined, context.log), ttlSeconds: 600, client: new MemoryAdapter(), }) @@ -467,6 +477,18 @@ export class StripeClient { return result as StripeResponse; } + @CaptureTimingWithStatsD() + async setupIntentConfirm( + setupIntentId: string, + params?: Stripe.SetupIntentConfirmParams + ) { + const result = await this.stripe.setupIntents.confirm(setupIntentId, { + ...params, + expand: undefined, + }); + return result as StripeResponse; + } + @CaptureTimingWithStatsD() async confirmationTokenRetrieve(confirmationTokenId: string) { const result =