From 8e85bc075a9474cb3dcf672dfb0d384fb8425814 Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Tue, 5 May 2026 17:49:59 -0700 Subject: [PATCH] feat(payments-next): Move subscription creation after SetupIntent validation Because: * Currently if a SetupIntent fails, the subscription has been created in Stripe already. When the SetupIntent fails, we cancel the created subscription. This causes the creation and cancellation Stripe webhooks to fire, which triggers unexpected emails for the customer. This commit: * In the case of creating a subscription via a SetupIntent, the subscription creation is processed once the SetupIntent has been confirmed. Closes #PAY-3675 --- libs/payments/cart/src/lib/cart.error.ts | 7 - .../cart/src/lib/cart.service.spec.ts | 67 ++- libs/payments/cart/src/lib/cart.service.ts | 86 ++-- libs/payments/cart/src/lib/checkout.error.ts | 10 + .../cart/src/lib/checkout.service.spec.ts | 424 +++++++++++++++- .../payments/cart/src/lib/checkout.service.ts | 477 ++++++++++++++---- libs/payments/ui/src/index.ts | 1 + .../actions/submitNeedsInputAndRedirect.ts | 22 +- .../client/components/CheckoutForm/index.tsx | 17 +- .../components/PaymentInputHandler/index.tsx | 8 +- .../src/lib/nestapp/nextjs-actions.service.ts | 12 +- .../validators/SubmitNeedsInputActionArgs.ts | 43 +- .../utils/getAttributionFromSearchParams.ts | 30 ++ 13 files changed, 1026 insertions(+), 178 deletions(-) create mode 100644 libs/payments/ui/src/lib/utils/getAttributionFromSearchParams.ts 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'] ?? '', + }; +};