diff --git a/libs/payments/cart/src/lib/cart.error.ts b/libs/payments/cart/src/lib/cart.error.ts index a74a0407d4b..3ffa0438937 100644 --- a/libs/payments/cart/src/lib/cart.error.ts +++ b/libs/payments/cart/src/lib/cart.error.ts @@ -486,13 +486,6 @@ export class SubmitNeedsInputCustomerIdMissingError extends CartError { } } -export class SubmitNeedsInputSubscriptionIdMissingError extends CartError { - constructor(cartId: string) { - super('Cart must have a stripeSubscriptionId', { cartId }); - this.name = 'SubmitNeedsInputSubscriptionIdMissingError'; - } -} - export class SubmitNeedsInputUidMissingError extends CartError { constructor(cartId: string) { super('Cart must have a uid', { cartId }); diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 7cc735d4c86..0dfac0e4ba4 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -591,6 +591,44 @@ describe('CartService', () => { ); }); + it('cancels a setup intent when no subscription was created', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: null, + stripeCustomerId: mockCustomer.id, + stripeIntentId: 'seti_setup_intent_id', + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + jest + .spyOn(asyncLocalStorage, 'getStore') + .mockReturnValue(CartStoreFactory()); + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(setupIntentManager, 'retrieve') + .mockResolvedValue(mockSetupIntent); + jest + .spyOn(setupIntentManager, 'cancel') + .mockResolvedValue(mockSetupIntent); + jest.spyOn(subscriptionManager, 'cancel'); + jest.spyOn(subscriptionManager, 'retrieve'); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(setupIntentManager.cancel).toHaveBeenCalledWith( + mockSetupIntent.id, + mockSetupIntent.status + ); + expect(subscriptionManager.cancel).not.toHaveBeenCalled(); + expect(subscriptionManager.retrieve).not.toHaveBeenCalled(); + }); + it('does not delete a customer with preexisting subscriptions', async () => { const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockSubscription = StripeResponseFactory( @@ -2852,16 +2890,37 @@ describe('CartService', () => { /SubmitNeedsInputCustomerIdMissingError/ ); }); - it('throws assertion error for missing stripeSubscriptionId', async () => { + it('finalizes setup intent and creates subscription when no stripeSubscriptionId is set yet', async () => { const localMockCart = { ...mockCart, stripeSubscriptionId: null, + stripeIntentId: mockSetupIntent.id, }; + const mockAttribution = SubscriptionAttributionFactory(); jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(localMockCart); + jest + .spyOn(checkoutService, 'finalizeSetupIntentAndCreateSubscription') + .mockResolvedValue(); - await expect(cartService.submitNeedsInput(mockCart.id)).rejects.toThrow( - /SubmitNeedsInputSubscriptionIdMissingError/ - ); + await cartService.submitNeedsInput(localMockCart.id, mockAttribution); + + expect( + checkoutService.finalizeSetupIntentAndCreateSubscription + ).toHaveBeenCalledWith(localMockCart, mockAttribution, undefined); + expect(checkoutService.postPaySteps).not.toHaveBeenCalled(); + }); + + it('throws SubmitNeedsInputFailedError when no subscription exists and attribution is missing', async () => { + const localMockCart = { + ...mockCart, + stripeSubscriptionId: null, + stripeIntentId: mockSetupIntent.id, + }; + jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(localMockCart); + + await expect( + cartService.submitNeedsInput(localMockCart.id) + ).rejects.toThrow(/SubmitNeedsInputFailedError/); }); it('throws assertion error for missing uid', async () => { const localMockCart = { diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index 00d2937e21d..d0cb187fe0e 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -83,7 +83,6 @@ import { GetCartUnitAmountForCurrencyMissingError, GetCartPriceForCurrencyRecurringMissingError, SubmitNeedsInputCustomerIdMissingError, - SubmitNeedsInputSubscriptionIdMissingError, SubmitNeedsInputUidMissingError, CartSubscriptionDeletionFailedError, } from './cart.error'; @@ -252,34 +251,36 @@ export class CartService { } } } + } - if (cart.stripeIntentId) { - const intent = isPaymentIntentId(cart.stripeIntentId) - ? await this.paymentIntentManager.retrieve(cart.stripeIntentId) - : await this.setupIntentManager.retrieve(cart.stripeIntentId); - if (intent?.status === 'succeeded') { - throw new PaidPaymentIntendOnFailedCartError( - cartId, - intent.id, - cart.stripeCustomerId ?? undefined - ); - } + if (cart.stripeIntentId) { + const intent = isPaymentIntentId(cart.stripeIntentId) + ? await this.paymentIntentManager.retrieve(cart.stripeIntentId) + : await this.setupIntentManager.retrieve(cart.stripeIntentId); + if (intent?.status === 'succeeded') { + throw new PaidPaymentIntendOnFailedCartError( + cartId, + intent.id, + cart.stripeCustomerId ?? undefined + ); + } - if (!isPaymentIntentId(cart.stripeIntentId)) { - try { - await this.setupIntentManager.cancel(intent.id, intent.status); - } catch (e) { - // swallow the error to allow cancellation of the subscription - this.log.error(e); - Sentry.captureException(e, { - extra: { - cartId, - }, - }); - } + if (!isPaymentIntentId(cart.stripeIntentId)) { + try { + await this.setupIntentManager.cancel(intent.id, intent.status); + } catch (e) { + // swallow the error to allow cancellation of the subscription + this.log.error(e); + Sentry.captureException(e, { + extra: { + cartId, + }, + }); } } + } + if (subscriptionId) { if (cart.eligibilityStatus === CartEligibilityStatus.CREATE) { try { await this.subscriptionManager.cancel(subscriptionId, { @@ -1185,17 +1186,17 @@ export class CartService { } @SanitizeExceptions() - async submitNeedsInput(cartId: string) { + async submitNeedsInput( + cartId: string, + attribution?: SubscriptionAttributionParams, + requestArgs?: CommonMetrics + ) { return this.wrapWithCartCatch(cartId, async () => { const cart = await this.cartManager.fetchCartById(cartId); assert( cart.stripeCustomerId, new SubmitNeedsInputCustomerIdMissingError(cartId) ); - assert( - cart.stripeSubscriptionId, - new SubmitNeedsInputSubscriptionIdMissingError(cartId) - ); assert(cart.uid, new SubmitNeedsInputUidMissingError(cartId)); if (!cart.stripeIntentId) { @@ -1207,18 +1208,31 @@ export class CartService { : await this.setupIntentManager.retrieve(cart.stripeIntentId); if (intent.status === 'succeeded') { - if (intent.payment_method) { - await this.customerManager.update(cart.stripeCustomerId, { - invoice_settings: { - default_payment_method: intent.payment_method, - }, - }); - } else { + if (!intent.payment_method) { throw new SuccessfulIntentMissingPaymentMethodCartError( cartId, intent.id ); } + + if (!cart.stripeSubscriptionId) { + assert( + attribution, + new SubmitNeedsInputFailedError(cartId) + ); + await this.checkoutService.finalizeSetupIntentAndCreateSubscription( + cart, + attribution, + requestArgs + ); + return; + } + + await this.customerManager.update(cart.stripeCustomerId, { + invoice_settings: { + default_payment_method: intent.payment_method, + }, + }); const subscription = await this.subscriptionManager.retrieve( cart.stripeSubscriptionId ); diff --git a/libs/payments/cart/src/lib/checkout.error.ts b/libs/payments/cart/src/lib/checkout.error.ts index d01de79e7ac..d532d7e8e69 100644 --- a/libs/payments/cart/src/lib/checkout.error.ts +++ b/libs/payments/cart/src/lib/checkout.error.ts @@ -47,6 +47,16 @@ export class InvalidIntentStateError extends CheckoutError { } } +export class SetupIntentNotReturnedError extends CheckoutError { + constructor(cartId: string) { + super( + 'Stripe did not return a SetupIntent or attach one to the error', + { cartId } + ); + this.name = 'SetupIntentNotReturnedError'; + } +} + export class AccountCustomerAlreadyExistsError extends CheckoutError { constructor(uid: string) { super('account customer already exists for uid', { uid }); diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index c9d4e9a329b..f90ec8e5174 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -105,7 +105,10 @@ import { CartNoTaxAddressError, CartUidMismatchError, } from './cart.error'; -import { AccountCustomerAlreadyExistsError } from './checkout.error'; +import { + AccountCustomerAlreadyExistsError, + InvalidIntentStateError, +} from './checkout.error'; import { CheckoutService } from './checkout.service'; import { PrePayStepsResultFactory } from './checkout.factories'; import { AccountManager } from '@fxa/shared/account/account'; @@ -1665,6 +1668,12 @@ describe('CheckoutService', () => { const mockNonTrialingSubscription = StripeResponseFactory( StripeSubscriptionFactory({ status: 'active' }) ); + const mockSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'succeeded', + payment_method: mockPaymentMethod.id, + }) + ); jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({ ...mockPrePayStepsResult, @@ -1676,6 +1685,10 @@ describe('CheckoutService', () => { jest .spyOn(checkoutService, 'getFreeTrialEligibility') .mockResolvedValue(mockFreeTrial); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockSetupIntent); + jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer); jest .spyOn(subscriptionManager, 'create') .mockResolvedValue(mockNonTrialingSubscription); @@ -1814,6 +1827,415 @@ describe('CheckoutService', () => { expect(cartManager.setNeedsInputCart).toHaveBeenCalledWith(mockCart.id); }); }); + + const setupZeroInitialFixtures = ({ + freeTrial, + cartOverrides = {}, + }: { + freeTrial: FreeTrial | null; + cartOverrides?: Record; + }) => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockCart = StripeResponseFactory( + ResultCartFactory({ + uid: faker.string.uuid(), + stripeCustomerId: mockCustomer.id, + couponCode: faker.string.uuid(), + ...cartOverrides, + }) + ); + const mockPromotionCode = StripeResponseFactory( + StripePromotionCodeFactory() + ); + const mockPrice = StripePriceFactory(); + const mockEligibilityResult = SubscriptionEligibilityResultFactory({ + subscriptionEligibilityResult: EligibilityStatus.CREATE, + }); + return { + mockAttributionData: SubscriptionAttributionFactory(), + mockCustomer, + mockCart, + mockPromotionCode, + mockPrice, + mockConfirmationToken: StripeConfirmationTokenFactory(), + mockPricingForCurrency: PricingForCurrencyFactory(), + mockPaymentMethod: StripeResponseFactory(StripePaymentMethodFactory()), + mockRequestArgs: CommonMetricsFactory(), + mockEligibilityResult, + mockPrePayStepsResult: PrePayStepsResultFactory({ + uid: mockCart.uid, + customer: mockCustomer, + promotionCode: mockPromotionCode, + price: mockPrice, + eligibility: mockEligibilityResult, + freeTrial, + }), + }; + }; + + describe('zero-amount coupon (no free trial)', () => { + const { + mockAttributionData, + mockCustomer, + mockCart: mockZeroAmountCart, + mockConfirmationToken, + mockPricingForCurrency, + mockPaymentMethod, + mockRequestArgs, + mockPrePayStepsResult, + } = setupZeroInitialFixtures({ + freeTrial: null, + cartOverrides: { amount: 0 }, + }); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + + describe('happy path', () => { + const mockSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'succeeded', + payment_method: mockPaymentMethod.id, + }) + ); + + beforeEach(async () => { + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockSetupIntent); + jest + .spyOn(subscriptionManager, 'create') + .mockResolvedValue(mockSubscription); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue(); + jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer); + jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue(); + jest.spyOn(asyncLocalStorage, 'getStore'); + jest + .spyOn(paymentMethodManager, 'retrieve') + .mockResolvedValue(mockPaymentMethod); + jest.spyOn(statsd, 'increment'); + + await checkoutService.payWithStripe( + mockZeroAmountCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockZeroAmountCart.uid + ); + }); + + it('creates and confirms setup intent before subscription', () => { + const setupIntentOrder = ( + setupIntentManager.createAndConfirm as jest.Mock + ).mock.invocationCallOrder[0]; + const subscriptionOrder = (subscriptionManager.create as jest.Mock) + .mock.invocationCallOrder[0]; + expect(setupIntentManager.createAndConfirm).toHaveBeenCalledWith( + mockCustomer.id, + mockConfirmationToken.id + ); + expect(setupIntentOrder).toBeLessThan(subscriptionOrder); + }); + + it('persists the intent id on the cart before subscription is created', () => { + expect(cartManager.updateProcessingCart).toHaveBeenCalledWith( + mockZeroAmountCart.id, + mockPrePayStepsResult.version, + { stripeIntentId: mockSetupIntent.id } + ); + }); + + it('sets the validated payment method as default and creates subscription', () => { + expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, { + invoice_settings: { + default_payment_method: mockSetupIntent.payment_method, + }, + }); + expect(subscriptionManager.create).toHaveBeenCalled(); + }); + + it('updates cart with subscription id after creation', () => { + expect(cartManager.updateProcessingCart).toHaveBeenCalledWith( + mockZeroAmountCart.id, + mockPrePayStepsResult.version + 1, + { stripeSubscriptionId: mockSubscription.id } + ); + }); + + it('calls postPaySteps', () => { + expect(checkoutService.postPaySteps).toHaveBeenCalled(); + }); + }); + + it('does not create the subscription when setup intent requires_payment_method', async () => { + const mockFailedSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'requires_payment_method', + last_setup_error: { code: 'card_declined', type: 'card_error' }, + }) + ); + + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockFailedSetupIntent); + jest.spyOn(subscriptionManager, 'create'); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(statsd, 'increment'); + + await expect( + checkoutService.payWithStripe( + mockZeroAmountCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockZeroAmountCart.uid + ) + ).rejects.toThrow(); + + expect(subscriptionManager.create).not.toHaveBeenCalled(); + }); + + it('sets cart to NEEDS_INPUT and does not create subscription when setup intent requires_action', async () => { + const mockRequiresActionSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'requires_action', + payment_method: mockPaymentMethod.id, + }) + ); + + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockRequiresActionSetupIntent); + jest.spyOn(subscriptionManager, 'create'); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue(); + jest.spyOn(statsd, 'increment'); + + await checkoutService.payWithStripe( + mockZeroAmountCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockZeroAmountCart.uid + ); + + expect(cartManager.setNeedsInputCart).toHaveBeenCalledWith( + mockZeroAmountCart.id + ); + expect(subscriptionManager.create).not.toHaveBeenCalled(); + }); + }); + + describe('free trial setup-intent-first ordering', () => { + const mockFreeTrial: FreeTrial = { + internalName: 'test-free-trial', + intervals: ['monthly'], + trialLengthDays: 14, + countries: ['US - United States'], + cooldownPeriodMonths: 6, + }; + const { + mockAttributionData, + mockCart: mockTrialCart, + mockConfirmationToken, + mockPricingForCurrency, + mockPaymentMethod, + mockRequestArgs, + mockPrePayStepsResult, + } = setupZeroInitialFixtures({ freeTrial: mockFreeTrial }); + + it('does not call subscriptionManager.create when setup intent fails', async () => { + const mockFailedSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'requires_payment_method', + last_setup_error: { code: 'card_declined', type: 'card_error' }, + }) + ); + + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(checkoutService, 'getFreeTrialEligibility') + .mockResolvedValue(mockFreeTrial); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockFailedSetupIntent); + jest.spyOn(subscriptionManager, 'create'); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(statsd, 'increment'); + + await expect( + checkoutService.payWithStripe( + mockTrialCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockTrialCart.uid + ) + ).rejects.toThrow(); + + expect(subscriptionManager.create).not.toHaveBeenCalled(); + }); + + it('does not call subscriptionManager.create when setup intent requires_action', async () => { + const mockRequiresActionSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'requires_action', + payment_method: mockPaymentMethod.id, + }) + ); + + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(checkoutService, 'getFreeTrialEligibility') + .mockResolvedValue(mockFreeTrial); + jest + .spyOn(setupIntentManager, 'createAndConfirm') + .mockResolvedValue(mockRequiresActionSetupIntent); + jest.spyOn(subscriptionManager, 'create'); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue(); + jest.spyOn(statsd, 'increment'); + + await checkoutService.payWithStripe( + mockTrialCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockTrialCart.uid + ); + + expect(cartManager.setNeedsInputCart).toHaveBeenCalledWith( + mockTrialCart.id + ); + expect(subscriptionManager.create).not.toHaveBeenCalled(); + }); + }); + }); + + describe('finalizeSetupIntentAndCreateSubscription', () => { + const mockAttributionData = SubscriptionAttributionFactory(); + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockPaymentMethod = StripeResponseFactory( + StripePaymentMethodFactory() + ); + const mockSetupIntent = StripeResponseFactory( + StripeSetupIntentFactory({ + status: 'succeeded', + payment_method: mockPaymentMethod.id, + }) + ); + const mockCart = StripeResponseFactory( + ResultCartFactory({ + uid: faker.string.uuid(), + stripeCustomerId: mockCustomer.id, + stripeIntentId: mockSetupIntent.id, + amount: 0, + }) + ); + const mockPromotionCode = StripeResponseFactory( + StripePromotionCodeFactory() + ); + const mockPrice = StripePriceFactory(); + const mockPricingForCurrency = PricingForCurrencyFactory(); + const mockSubscription = StripeResponseFactory(StripeSubscriptionFactory()); + const mockRequestArgs = CommonMetricsFactory(); + const mockEligibilityResult = SubscriptionEligibilityResultFactory({ + subscriptionEligibilityResult: EligibilityStatus.CREATE, + }); + const mockPrePayStepsResult = PrePayStepsResultFactory({ + uid: mockCart.uid, + customer: mockCustomer, + promotionCode: mockPromotionCode, + price: mockPrice, + eligibility: mockEligibilityResult, + freeTrial: null, + }); + + beforeEach(() => { + jest + .spyOn(checkoutService, 'prePaySteps') + .mockResolvedValue(mockPrePayStepsResult); + jest + .spyOn(priceManager, 'retrievePricingForCurrency') + .mockResolvedValue(mockPricingForCurrency); + jest + .spyOn(setupIntentManager, 'retrieve') + .mockResolvedValue(mockSetupIntent); + jest + .spyOn(subscriptionManager, 'create') + .mockResolvedValue(mockSubscription); + jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer); + jest.spyOn(cartManager, 'updateProcessingCart').mockResolvedValue(); + jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue(); + jest.spyOn(asyncLocalStorage, 'getStore'); + jest + .spyOn(paymentMethodManager, 'retrieve') + .mockResolvedValue(mockPaymentMethod); + jest.spyOn(statsd, 'increment'); + }); + + it('retrieves setup intent and creates subscription', async () => { + await checkoutService.finalizeSetupIntentAndCreateSubscription( + mockCart, + mockAttributionData, + mockRequestArgs + ); + + expect(setupIntentManager.retrieve).toHaveBeenCalledWith( + mockCart.stripeIntentId + ); + expect(subscriptionManager.create).toHaveBeenCalled(); + expect(checkoutService.postPaySteps).toHaveBeenCalled(); + }); + + it('throws InvalidIntentStateError when setup intent is not succeeded', async () => { + const mockUnconfirmedIntent = StripeResponseFactory( + StripeSetupIntentFactory({ status: 'requires_action' }) + ); + jest + .spyOn(setupIntentManager, 'retrieve') + .mockResolvedValue(mockUnconfirmedIntent); + jest.spyOn(subscriptionManager, 'create'); + + await expect( + checkoutService.finalizeSetupIntentAndCreateSubscription( + mockCart, + mockAttributionData, + mockRequestArgs + ) + ).rejects.toBeInstanceOf(InvalidIntentStateError); + + expect(subscriptionManager.create).not.toHaveBeenCalled(); + }); }); describe('payWithPaypal', () => { diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index ea9e63d904a..59169f31f3e 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -39,6 +39,7 @@ import { AccountCustomerManager, StripeSubscription, StripeCustomer, + StripePrice, StripePromotionCode, type StripePaymentIntent, type StripeSetupIntent, @@ -76,6 +77,7 @@ import { LatestInvoiceNotFoundOnSubscriptionError, NewAccountPrepaidCardFreeTrialNotAllowedError, PaymentMethodUpdateFailedError, + SetupIntentNotReturnedError, UpgradeForSubscriptionNotFoundError, DetermineCheckoutAmountCustomerRequiredError, DetermineCheckoutAmountSubscriptionRequiredError, @@ -368,17 +370,18 @@ export class CheckoutService { requestArgs: CommonMetrics, sessionUid?: string ) { + const prePay = await this.prePaySteps(cart, sessionUid); const { uid, accountCreatedAt, customer, enableAutomaticTax, promotionCode, - version, price, eligibility, freeTrial, - } = await this.prePaySteps(cart, sessionUid); + version, + } = prePay; this.statsd.increment('stripe_subscription', { payment_provider: 'stripe', @@ -408,67 +411,46 @@ export class CheckoutService { } } - const subscription = - eligibility.subscriptionEligibilityResult !== EligibilityStatus.UPGRADE - ? await this.subscriptionManager.create( - { - customer: customer.id, - automatic_tax: { - enabled: enableAutomaticTax, - }, - promotion_code: promotionCode?.id, - items: [ - { - price: price.id, - }, - ], - ...(freeTrial - ? { - trial_period_days: freeTrial.trialLengthDays, - trial_settings: { - end_behavior: { - missing_payment_method: - 'cancel' as const, - }, - }, - } - : { payment_behavior: 'default_incomplete' as const }), - currency: cart.currency ?? undefined, - metadata: { - // Note: These fields are due to missing Fivetran support on Stripe multi-currency plans - [STRIPE_SUBSCRIPTION_METADATA.Amount]: unitAmountForCurrency, - [STRIPE_SUBSCRIPTION_METADATA.Currency]: cart.currency, - [STRIPE_SUBSCRIPTION_METADATA.UtmCampaign]: - attribution.utm_campaign, - [STRIPE_SUBSCRIPTION_METADATA.UtmContent]: - attribution.utm_content, - [STRIPE_SUBSCRIPTION_METADATA.UtmMedium]: - attribution.utm_medium, - [STRIPE_SUBSCRIPTION_METADATA.UtmSource]: - attribution.utm_source, - [STRIPE_SUBSCRIPTION_METADATA.UtmTerm]: attribution.utm_term, - [STRIPE_SUBSCRIPTION_METADATA.SessionFlowId]: - attribution.session_flow_id, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypoint]: - attribution.session_entrypoint, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointExperiment]: - attribution.session_entrypoint_experiment, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointVariation]: - attribution.session_entrypoint_variation, - }, - }, - { - idempotencyKey: cart.id, - } - ) - : await this.upgradeSubscription( - customer.id, - price.id, - eligibility.fromPrice.id, - cart, - eligibility.redundantOverlaps || [], - attribution - ); + const isUpgrade = + eligibility.subscriptionEligibilityResult === EligibilityStatus.UPGRADE; + + // Validate SetupIntents before creating the subscription to prevent webhook calls from failed intents + const isZeroInitialPayment = + !isUpgrade && (!!freeTrial || cart.amount === 0); + + if (isZeroInitialPayment) { + await this.payWithStripeZeroInitial({ + cart, + prePay, + confirmationTokenId, + attribution, + requestArgs, + unitAmountForCurrency, + }); + return; + } + + const subscription = !isUpgrade + ? await this.createSubscription({ + cart, + customer, + enableAutomaticTax, + promotionCode, + price, + attribution, + unitAmountForCurrency, + freeTrial, + paymentBehavior: 'default_incomplete', + trialEndBehavior: { missing_payment_method: 'cancel' }, + }) + : await this.upgradeSubscription( + customer.id, + price.id, + eligibility.fromPrice.id, + cart, + eligibility.redundantOverlaps || [], + attribution + ); // Write the subscription ID to the async local storage // so that it can be used in situations where it hasn't been @@ -607,6 +589,318 @@ export class CheckoutService { } } + private buildSubscriptionMetadata( + cart: ResultCart, + attribution: SubscriptionAttributionParams, + unitAmountForCurrency: number + ) { + return { + // Note: These fields are due to missing Fivetran support on Stripe multi-currency plans + [STRIPE_SUBSCRIPTION_METADATA.Amount]: unitAmountForCurrency, + [STRIPE_SUBSCRIPTION_METADATA.Currency]: cart.currency, + [STRIPE_SUBSCRIPTION_METADATA.UtmCampaign]: attribution.utm_campaign, + [STRIPE_SUBSCRIPTION_METADATA.UtmContent]: attribution.utm_content, + [STRIPE_SUBSCRIPTION_METADATA.UtmMedium]: attribution.utm_medium, + [STRIPE_SUBSCRIPTION_METADATA.UtmSource]: attribution.utm_source, + [STRIPE_SUBSCRIPTION_METADATA.UtmTerm]: attribution.utm_term, + [STRIPE_SUBSCRIPTION_METADATA.SessionFlowId]: attribution.session_flow_id, + [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypoint]: + attribution.session_entrypoint, + [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointExperiment]: + attribution.session_entrypoint_experiment, + [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointVariation]: + attribution.session_entrypoint_variation, + }; + } + + private async createSubscription(args: { + cart: ResultCart; + customer: StripeCustomer; + enableAutomaticTax: boolean; + promotionCode?: StripePromotionCode; + price: StripePrice; + attribution: SubscriptionAttributionParams; + unitAmountForCurrency: number; + freeTrial: FreeTrial | null; + paymentBehavior?: 'default_incomplete'; + collectionMethod?: 'send_invoice'; + daysUntilDue?: number; + trialEndBehavior?: { missing_payment_method: 'cancel' }; + }) { + const { + cart, + customer, + enableAutomaticTax, + promotionCode, + price, + attribution, + unitAmountForCurrency, + freeTrial, + paymentBehavior, + collectionMethod, + daysUntilDue, + trialEndBehavior, + } = args; + + return this.subscriptionManager.create( + { + customer: customer.id, + automatic_tax: { enabled: enableAutomaticTax }, + promotion_code: promotionCode?.id, + items: [{ price: price.id }], + ...(freeTrial + ? { + trial_period_days: freeTrial.trialLengthDays, + ...(trialEndBehavior + ? { trial_settings: { end_behavior: trialEndBehavior } } + : {}), + } + : paymentBehavior + ? { payment_behavior: paymentBehavior } + : {}), + ...(collectionMethod ? { collection_method: collectionMethod } : {}), + ...(daysUntilDue !== undefined ? { days_until_due: daysUntilDue } : {}), + currency: cart.currency ?? undefined, + metadata: this.buildSubscriptionMetadata( + cart, + attribution, + unitAmountForCurrency + ), + }, + { + idempotencyKey: cart.id, + } + ); + } + + private assertSetupIntentSucceededWithPaymentMethod( + cart: ResultCart, + customer: StripeCustomer, + intent: StripeSetupIntent + ): string { + if (intent.status !== 'succeeded') { + throw new InvalidIntentStateError( + cart.id, + intent.id, + intent.status, + 'SetupIntent' + ); + } + if (!intent.payment_method) { + throw new PaymentMethodUpdateFailedError(cart.id, customer.id); + } + return intent.payment_method; + } + + private async payWithStripeZeroInitial(args: { + cart: ResultCart; + prePay: PrePayStepsResult; + confirmationTokenId: string; + attribution: SubscriptionAttributionParams; + requestArgs: CommonMetrics; + unitAmountForCurrency: number; + }) { + const { + cart, + prePay, + confirmationTokenId, + attribution, + requestArgs, + unitAmountForCurrency, + } = args; + const { customer } = prePay; + let { version } = prePay; + + let intent: StripeSetupIntent | undefined; + try { + intent = await this.setupIntentManager.createAndConfirm( + customer.id, + confirmationTokenId + ); + } catch (error) { + if (error?.setup_intent) { + intent = error.setup_intent; + } else { + throw error; + } + } + assert(intent, new SetupIntentNotReturnedError(cart.id)); + + this.statsd.increment('checkout_stripe_payment_setupintent_status', { + status: intent.status, + }); + + await this.cartManager.updateProcessingCart(cart.id, version, { + stripeIntentId: intent.id, + }); + version += 1; + + if (intent.status === 'requires_action') { + await this.cartManager.setNeedsInputCart(cart.id); + return; + } + + if (intent.status === 'requires_payment_method') { + throwIntentFailedError( + intent.last_setup_error?.code, + intent.last_setup_error?.decline_code, + cart.id, + intent.id, + 'SetupIntent' + ); + } + + const paymentMethodId = this.assertSetupIntentSucceededWithPaymentMethod( + cart, + customer, + intent + ); + + await this.createSubscriptionAfterSetupIntentSucceeded({ + prePay: { ...prePay, version }, + cart, + attribution, + paymentMethodId, + unitAmountForCurrency, + requestArgs, + }); + } + + private async createSubscriptionAfterSetupIntentSucceeded(args: { + prePay: PrePayStepsResult; + cart: ResultCart; + attribution: SubscriptionAttributionParams; + paymentMethodId: string; + unitAmountForCurrency: number; + requestArgs?: CommonMetrics; + }) { + const { + prePay, + cart, + attribution, + paymentMethodId, + unitAmountForCurrency, + requestArgs, + } = args; + const { + uid, + version, + customer, + enableAutomaticTax, + promotionCode, + price, + eligibility, + freeTrial, + } = prePay; + + await this.customerManager.update(customer.id, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + + const subscription = await this.createSubscription({ + cart, + customer, + enableAutomaticTax, + promotionCode, + price, + attribution, + unitAmountForCurrency, + freeTrial, + paymentBehavior: 'default_incomplete', + trialEndBehavior: { missing_payment_method: 'cancel' }, + }); + + const store = this.cartAsyncLocalStorage.getStore(); + if (store) { + store.checkout.subscriptionId = subscription.id; + } + + if (freeTrial) { + if (subscription.status !== 'trialing') { + throw new UnexpectedSubscriptionStatusForTrialError( + cart.id, + subscription.id, + subscription.status + ); + } + await this.freeTrialManager.recordFreeTrial( + uid, + freeTrial.internalName + ); + } + + await this.cartManager.updateProcessingCart(cart.id, version, { + stripeSubscriptionId: subscription.id, + }); + const updatedVersion = version + 1; + + const paymentMethod = + await this.paymentMethodManager.retrieve(paymentMethodId); + const paymentForm = + convertStripePaymentMethodTypeToSubPlat(paymentMethod); + + await this.postPaySteps({ + cart, + version: updatedVersion, + subscription, + uid, + paymentProvider: 'stripe', + paymentForm, + isCancelInterstitialOffer: isCancelInterstitialOffer( + eligibility.subscriptionEligibilityResult, + attribution.session_entrypoint + ), + requestArgs, + }); + } + + async finalizeSetupIntentAndCreateSubscription( + cart: ResultCart, + attribution: SubscriptionAttributionParams, + requestArgs?: CommonMetrics + ) { + const prePay = await this.prePaySteps(cart, cart.uid ?? undefined); + + const { unitAmountForCurrency } = + await this.priceManager.retrievePricingForCurrency( + prePay.price.id, + cart.currency + ); + assertNotNull( + unitAmountForCurrency, + new PayWithStripeNullCurrencyError(cart.id, prePay.price.id) + ); + + assert( + cart.stripeIntentId && !isPaymentIntentId(cart.stripeIntentId), + new InvalidIntentStateError( + cart.id, + cart.stripeIntentId ?? '', + 'missing', + 'SetupIntent' + ) + ); + + const intent = await this.setupIntentManager.retrieve(cart.stripeIntentId); + const paymentMethodId = + this.assertSetupIntentSucceededWithPaymentMethod( + cart, + prePay.customer, + intent + ); + + await this.createSubscriptionAfterSetupIntentSucceeded({ + prePay, + cart, + attribution, + paymentMethodId, + unitAmountForCurrency, + requestArgs, + }); + } + async payWithPaypal( cart: ResultCart, attribution: SubscriptionAttributionParams, @@ -653,51 +947,18 @@ export class CheckoutService { const subscription = eligibility.subscriptionEligibilityResult !== EligibilityStatus.UPGRADE - ? await this.subscriptionManager.create( - { - customer: customer.id, - automatic_tax: { - enabled: enableAutomaticTax, - }, - collection_method: 'send_invoice', - days_until_due: 1, - ...(freeTrial - ? { trial_period_days: freeTrial.trialLengthDays } - : {}), - promotion_code: promotionCode?.id, - items: [ - { - price: price.id, - }, - ], - currency: cart.currency ?? undefined, - metadata: { - // Note: These fields are due to missing Fivetran support on Stripe multi-currency plans - [STRIPE_SUBSCRIPTION_METADATA.Amount]: unitAmountForCurrency, - [STRIPE_SUBSCRIPTION_METADATA.Currency]: cart.currency, - [STRIPE_SUBSCRIPTION_METADATA.UtmCampaign]: - attribution.utm_campaign, - [STRIPE_SUBSCRIPTION_METADATA.UtmContent]: - attribution.utm_content, - [STRIPE_SUBSCRIPTION_METADATA.UtmMedium]: - attribution.utm_medium, - [STRIPE_SUBSCRIPTION_METADATA.UtmSource]: - attribution.utm_source, - [STRIPE_SUBSCRIPTION_METADATA.UtmTerm]: attribution.utm_term, - [STRIPE_SUBSCRIPTION_METADATA.SessionFlowId]: - attribution.session_flow_id, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypoint]: - attribution.session_entrypoint, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointExperiment]: - attribution.session_entrypoint_experiment, - [STRIPE_SUBSCRIPTION_METADATA.SessionEntrypointVariation]: - attribution.session_entrypoint_variation, - }, - }, - { - idempotencyKey: cart.id, - } - ) + ? await this.createSubscription({ + cart, + customer, + enableAutomaticTax, + promotionCode, + price, + attribution, + unitAmountForCurrency, + freeTrial, + collectionMethod: 'send_invoice', + daysUntilDue: 1, + }) : await this.upgradeSubscription( customer.id, price.id, diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts index 15db75cb583..1b38835f340 100644 --- a/libs/payments/ui/src/index.ts +++ b/libs/payments/ui/src/index.ts @@ -46,3 +46,4 @@ export * from './lib/utils/buildRedirectUrl'; export * from './lib/utils/getCardIcon'; export * from './lib/utils/getManagePaymentMethodErrorFtlInfo'; export * from './lib/utils/getNextChargeChurnContent'; +export * from './lib/utils/getAttributionFromSearchParams'; diff --git a/libs/payments/ui/src/lib/actions/submitNeedsInputAndRedirect.ts b/libs/payments/ui/src/lib/actions/submitNeedsInputAndRedirect.ts index 951da793f7c..94139ebf2d0 100644 --- a/libs/payments/ui/src/lib/actions/submitNeedsInputAndRedirect.ts +++ b/libs/payments/ui/src/lib/actions/submitNeedsInputAndRedirect.ts @@ -10,6 +10,8 @@ import { URLSearchParams } from 'url'; import { recordEmitterEventAction } from './recordEmitterEvent'; import { flattenRouteParams } from '../utils/flatParam'; import { sanitizePathname } from '../utils/sanitizePathname'; +import { getAdditionalRequestArgs } from '../utils/getAdditionalRequestArgs'; +import { getAttributionFromSearchParams } from '../utils/getAttributionFromSearchParams'; export const submitNeedsInputAndRedirectAction = async ( cartId: string, @@ -19,12 +21,24 @@ export const submitNeedsInputAndRedirectAction = async ( isFreeTrial?: boolean ) => { let redirectPath: string | undefined; - const urlSearchParams = new URLSearchParams(searchParams ? flattenRouteParams(searchParams) : undefined); + const urlSearchParams = new URLSearchParams( + searchParams ? flattenRouteParams(searchParams) : undefined + ); const searchParamsString = searchParams ? `?${urlSearchParams.toString()}` : ''; + const attribution = getAttributionFromSearchParams(searchParams); + const requestArgs = { + ...(await getAdditionalRequestArgs()), + params: flattenRouteParams(params), + searchParams: searchParams ? flattenRouteParams(searchParams) : {}, + }; try { - await getApp().getActionsService().submitNeedsInput({ cartId }); + await getApp().getActionsService().submitNeedsInput({ + cartId, + attribution, + requestArgs, + }); await recordEmitterEventAction( 'checkoutSuccess', @@ -53,11 +67,11 @@ export const submitNeedsInputAndRedirectAction = async ( // Sanitize pathname to prevent open redirect vulnerabilities const safePath = sanitizePathname(currentPathname); - + // Replace the last segment with the redirect path to maintain the full path structure const pathSegments = safePath.split('/'); pathSegments[pathSegments.length - 1] = redirectPath; const fullRedirectPath = pathSegments.join('/'); redirect(`${fullRedirectPath}${searchParamsString}`); -}; \ No newline at end of file +}; diff --git a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx index e37ebad4624..356ca66f631 100644 --- a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx +++ b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx @@ -45,21 +45,10 @@ import PaypalIcon from '@fxa/shared/assets/images/payment-methods/paypal.svg'; import spinnerWhiteImage from '@fxa/shared/assets/images/spinnerwhite.svg'; import spinnerImage from '@fxa/shared/assets/images/spinner.svg'; import * as Sentry from '@sentry/nextjs'; +import { getAttributionFromSearchParams } from '@fxa/payments/ui' -const getAttributionParams = (searchParams: ReadonlyURLSearchParams) => { - const paramsRecord = Object.fromEntries(searchParams); - return { - utm_campaign: paramsRecord['utm_campaign'] ?? '', - utm_content: paramsRecord['utm_content'] ?? '', - utm_medium: paramsRecord['utm_medium'] ?? '', - utm_source: paramsRecord['utm_source'] ?? '', - utm_term: paramsRecord['utm_term'] ?? '', - session_flow_id: paramsRecord['flow_id'] ?? '', - session_entrypoint: paramsRecord['entrypoint'] ?? '', - session_entrypoint_experiment: paramsRecord['entrypoint_experiment'] ?? '', - session_entrypoint_variation: paramsRecord['entrypoint_variation'] ?? '', - }; -}; +const getAttributionParams = (searchParams: ReadonlyURLSearchParams) => + getAttributionFromSearchParams(Object.fromEntries(searchParams)); interface CheckoutFormProps { cmsCommonContent: { diff --git a/libs/payments/ui/src/lib/client/components/PaymentInputHandler/index.tsx b/libs/payments/ui/src/lib/client/components/PaymentInputHandler/index.tsx index d2d5891fbd9..21797e09ce9 100644 --- a/libs/payments/ui/src/lib/client/components/PaymentInputHandler/index.tsx +++ b/libs/payments/ui/src/lib/client/components/PaymentInputHandler/index.tsx @@ -42,7 +42,13 @@ export function PaymentInputHandler({ await stripe.handleNextAction({ clientSecret: inputRequest.data.clientSecret, }); - await submitNeedsInputAndRedirectAction(cartId, params, pathname, searchParamsRecord, isFreeTrial); + await submitNeedsInputAndRedirectAction( + cartId, + params, + pathname, + searchParamsRecord, + isFreeTrial + ); break; case 'notRequired': const redirectResponse = await validateCartStateAndRedirectAction( diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts index a7e7a4daa54..c5aa49d10de 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -767,8 +767,16 @@ export class NextJSActionsService { @NextIOValidator(SubmitNeedsInputActionArgs, undefined) @WithTypeCachableAsyncLocalStorage() @CaptureTimingWithStatsD() - async submitNeedsInput(args: { cartId: string }) { - await this.cartService.submitNeedsInput(args.cartId); + async submitNeedsInput(args: { + cartId: string; + attribution?: SubscriptionAttributionParams; + requestArgs?: CommonMetrics; + }) { + await this.cartService.submitNeedsInput( + args.cartId, + args.attribution, + args.requestArgs + ); } @SanitizeExceptions() diff --git a/libs/payments/ui/src/lib/nestapp/validators/SubmitNeedsInputActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/SubmitNeedsInputActionArgs.ts index 5d3414c2d31..28336ed42c4 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/SubmitNeedsInputActionArgs.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/SubmitNeedsInputActionArgs.ts @@ -2,9 +2,50 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, ValidateNested } from 'class-validator'; +import { RequestArgs } from './common/RequestArgs'; + +export class SubmitNeedsInputAttributionData { + @IsString() + utm_campaign!: string; + + @IsString() + utm_content!: string; + + @IsString() + utm_medium!: string; + + @IsString() + utm_source!: string; + + @IsString() + utm_term!: string; + + @IsString() + session_flow_id!: string; + + @IsString() + session_entrypoint!: string; + + @IsString() + session_entrypoint_experiment!: string; + + @IsString() + session_entrypoint_variation!: string; +} export class SubmitNeedsInputActionArgs { @IsString() cartId!: string; + + @Type(() => SubmitNeedsInputAttributionData) + @ValidateNested() + @IsOptional() + attribution?: SubmitNeedsInputAttributionData; + + @Type(() => RequestArgs) + @ValidateNested() + @IsOptional() + requestArgs?: RequestArgs; } diff --git a/libs/payments/ui/src/lib/utils/getAttributionFromSearchParams.ts b/libs/payments/ui/src/lib/utils/getAttributionFromSearchParams.ts new file mode 100644 index 00000000000..31e895fe421 --- /dev/null +++ b/libs/payments/ui/src/lib/utils/getAttributionFromSearchParams.ts @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { SubscriptionAttributionParams } from '@fxa/payments/cart'; +import { flattenRouteParams } from './flatParam'; + +/** + * Builds a `SubscriptionAttributionParams` from raw Next.js searchParams. + * + * Flattens any array values (via `flattenRouteParams`) and reads the UTM and + * session keys our checkout flow records as Stripe subscription metadata. + * Missing keys default to empty strings to match the persisted shape. + */ +export const getAttributionFromSearchParams = ( + searchParams?: Record +): SubscriptionAttributionParams => { + const flat = searchParams ? flattenRouteParams(searchParams) : {}; + return { + utm_campaign: flat['utm_campaign'] ?? '', + utm_content: flat['utm_content'] ?? '', + utm_medium: flat['utm_medium'] ?? '', + utm_source: flat['utm_source'] ?? '', + utm_term: flat['utm_term'] ?? '', + session_flow_id: flat['flow_id'] ?? '', + session_entrypoint: flat['entrypoint'] ?? '', + session_entrypoint_experiment: flat['entrypoint_experiment'] ?? '', + session_entrypoint_variation: flat['entrypoint_variation'] ?? '', + }; +};