From d606779b145c23a421e042f325e41c5fe0247c72 Mon Sep 17 00:00:00 2001 From: elizabeth-ilina Date: Fri, 22 Aug 2025 10:51:41 -0400 Subject: [PATCH] feat(payments-next):Page and util support for alternate payment methods Because: * This commit: * Closes # --- .../checkout/[cartId]/success/page.tsx | 50 ++++++- .../[cartId]/(mainLayout)/success/page.tsx | 50 ++++++- .../[cartId]/(startLayout)/start/page.tsx | 50 ++++++- apps/payments/next/app/[locale]/en.ftl | 2 + .../[locale]/subscriptions/manage/page.tsx | 62 +++++++- libs/payments/cart/src/lib/cart.service.ts | 43 ++++-- libs/payments/cart/src/lib/cart.types.ts | 1 + .../src/lib/util/throwIntentFailedError.ts | 2 + libs/payments/customer/src/index.ts | 1 - .../src/lib/paymentMethod.manager.spec.ts | 138 +++++++++++++++++- .../customer/src/lib/paymentMethod.manager.ts | 71 ++++++++- libs/payments/customer/src/lib/types.ts | 23 +++ .../lib/util/determinePaymentMethod.spec.ts | 32 ---- .../lib/util/determinePaymentMethodType.ts | 46 ------ libs/payments/events/src/lib/emitter.types.ts | 5 +- .../subscriptionManagement.service.spec.ts | 21 --- .../metrics/src/lib/glean/glean.types.ts | 4 + .../client/components/CheckoutForm/index.tsx | 8 +- .../PaymentMethodManagement/index.tsx | 16 +- .../components/PaymentSection/index.tsx | 1 + .../lib/client/components/StripeWrapper.tsx | 1 + libs/payments/ui/src/lib/utils/getCardIcon.ts | 12 ++ .../src/lib/stripeEvents.manager.spec.ts | 9 ++ .../src/lib/stripeWebhooks.service.spec.ts | 9 ++ .../lib/subscriptionHandler.service.spec.ts | 21 ++- .../src/lib/subscriptionHandler.service.ts | 19 +-- .../src/lib/util/determineCancellation.ts | 18 ++- .../src/images/payment-methods/apple-pay.svg | 16 ++ .../src/images/payment-methods/google-pay.svg | 11 ++ libs/shared/l10n/src/lib/branding.ftl | 2 + 30 files changed, 587 insertions(+), 157 deletions(-) delete mode 100644 libs/payments/customer/src/lib/util/determinePaymentMethod.spec.ts delete mode 100644 libs/payments/customer/src/lib/util/determinePaymentMethodType.ts create mode 100644 libs/shared/assets/src/images/payment-methods/apple-pay.svg create mode 100644 libs/shared/assets/src/images/payment-methods/google-pay.svg diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx index f363d74caf..fcb40ebd27 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx @@ -148,7 +148,55 @@ export default async function CheckoutSuccess({ cart.latestInvoicePreview?.currency, locale )} - {cart.paymentInfo.type === 'external_paypal' ? ( + {cart.paymentInfo.walletType === 'apple_pay' ? ( +
+ {l10n.getString('apple-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + +
+ ) : cart.paymentInfo.walletType === 'google_pay' ? ( +
+ {l10n.getString('google-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + +
+ ) : cart.paymentInfo.type === 'external_paypal' ? ( {l10n.getString('paypal-logo-alt-text', + {l10n.getString('apple-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + + + ) : cart.paymentInfo.walletType === 'google_pay' ? ( +
+ {l10n.getString('google-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + +
+ ) : cart.paymentInfo.type === 'external_paypal' ? ( {l10n.getString('paypal-logo-alt-text', - {cart.paymentInfo.type === 'external_paypal' ? ( + {cart.paymentInfo.walletType === 'apple_pay' ? ( +
+ {l10n.getString('apple-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + +
+ ) : cart.paymentInfo.walletType === 'google_pay' ? ( +
+ {l10n.getString('google-pay-logo-alt-text', + + {cart.paymentInfo.brand && ( + {getCardIcon(cart.paymentInfo.brand, + )} + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { last4: cart.paymentInfo.last4 ?? '' }, + `Card ending in ${cart.paymentInfo.last4}` + )} + +
+ ) : cart.paymentInfo.type === 'external_paypal' ? ( {l10n.getString('paypal-logo-alt-text', - {type === 'card' && brand && ( + {type === 'card' && walletType && ( +
+
+
+ { + {brand && ( + {getCardIcon(brand, + )} + {last4 && ( +
+ {l10n.getString( + 'subscription-management-card-ending-in', + { last4 }, + `Card ending in ${last4}` + )} +
+ )} +
+ {expirationDate && ( +
+ {l10n.getString( + 'subscription-management-card-expires-date', + { expirationDate }, + `Expires ${expirationDate}` + )} +
+ )} +
+ + {l10n.getString( + 'subscription-management-button-change-payment-method', + 'Change' + )} + +
+ )} + + {type === 'card' && brand && !walletType && (
> & { diff --git a/libs/payments/cart/src/lib/util/throwIntentFailedError.ts b/libs/payments/cart/src/lib/util/throwIntentFailedError.ts index efbba883e2..997478dfc7 100644 --- a/libs/payments/cart/src/lib/util/throwIntentFailedError.ts +++ b/libs/payments/cart/src/lib/util/throwIntentFailedError.ts @@ -23,6 +23,8 @@ export function throwIntentFailedError( intentType: 'SetupIntent' | 'PaymentIntent' ) { switch (errorCode) { + case 'payment_intent_payment_attempt_failed': + case 'payment_method_provider_decline': case 'card_declined': { switch (declineCode) { case 'approve_with_id': diff --git a/libs/payments/customer/src/index.ts b/libs/payments/customer/src/index.ts index 755acd03f0..66995489ff 100644 --- a/libs/payments/customer/src/index.ts +++ b/libs/payments/customer/src/index.ts @@ -19,5 +19,4 @@ export * from './lib/factories/tax-address.factory'; export * from './lib/customer.error'; export * from './lib/util/stripeInvoiceToFirstInvoicePreviewDTO'; export * from './lib/util/getSubplatInterval'; -export * from './lib/util/determinePaymentMethodType'; export * from './lib/util/retrieveSubscriptionItem'; diff --git a/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts b/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts index 9ba847b37a..24883a48bb 100644 --- a/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts +++ b/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts @@ -5,13 +5,15 @@ import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; import { PaymentMethodManager } from './paymentMethod.manager'; -import { determinePaymentMethodType } from './util/determinePaymentMethodType'; import { MockPaypalClientConfigProvider, PaypalBillingAgreementManager, PayPalClient, PaypalCustomerManager, } from '@fxa/payments/paypal'; +import { + SubPlatPaymentMethodType, +} from '@fxa/payments/customer'; import { StripeClient, MockStripeConfigProvider, @@ -23,9 +25,6 @@ import { import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; -jest.mock('./util/determinePaymentMethodType'); -const mockDeterminePaymentMethodType = jest.mocked(determinePaymentMethodType); - describe('PaymentMethodManager', () => { let paymentMethodManager: PaymentMethodManager; let paypalBillingAgreementManager: PaypalBillingAgreementManager; @@ -100,8 +99,8 @@ describe('PaymentMethodManager', () => { const mockSubscriptions = [StripeSubscriptionFactory()]; const mockUid = faker.string.uuid(); - mockDeterminePaymentMethodType.mockReturnValue({ - type: 'stripe', + jest.spyOn(paymentMethodManager, 'determineType').mockResolvedValue({ + type: SubPlatPaymentMethodType.Stripe, paymentMethodId: 'pm_id', }); jest @@ -119,6 +118,7 @@ describe('PaymentMethodManager', () => { last4: mockPaymentMethod.card?.last4, expMonth: mockPaymentMethod.card?.exp_month, expYear: mockPaymentMethod.card?.exp_year, + walletType: mockPaymentMethod.card?.wallet?.type, }); }); @@ -128,8 +128,8 @@ describe('PaymentMethodManager', () => { const mockSubscriptions = [StripeSubscriptionFactory()]; const mockUid = faker.string.uuid(); - mockDeterminePaymentMethodType.mockReturnValue({ - type: 'external_paypal', + jest.spyOn(paymentMethodManager, 'determineType').mockResolvedValue({ + type: SubPlatPaymentMethodType.PayPal, }); jest .spyOn(paypalBillingAgreementManager, 'retrieveActiveId') @@ -147,4 +147,126 @@ describe('PaymentMethodManager', () => { }); }); }); + + describe('determineType', () => { + it('returns card', async () => { + const mockCustomer = StripeCustomerFactory({ + invoice_settings: { + custom_fields: null, + default_payment_method: 'any', + footer: null, + rendering_options: null, + }, + }); + + const mockPaymentMethod = StripeResponseFactory( + StripePaymentMethodFactory({ + type: 'card', + }) + ); + jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPaymentMethod); + + await expect( + paymentMethodManager.determineType(mockCustomer) + ).resolves.toEqual({ + type: SubPlatPaymentMethodType.Card, + paymentMethodId: expect.any(String), + }); + }); + + it('returns external_paypal', async () => { + const mockSubscription = StripeSubscriptionFactory({ + collection_method: 'send_invoice', + }); + + await expect( + paymentMethodManager.determineType(undefined, [mockSubscription]) + ).resolves.toEqual({ + type: SubPlatPaymentMethodType.PayPal, + }); + }); + + it('returns link', async () => { + const mockCustomer = StripeCustomerFactory({ + invoice_settings: { + custom_fields: null, + default_payment_method: 'any', + footer: null, + rendering_options: null, + }, + }); + + const mockPaymentMethod = StripeResponseFactory( + StripePaymentMethodFactory({ + type: 'link', + }) + ); + jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPaymentMethod); + + await expect( + paymentMethodManager.determineType(mockCustomer) + ).resolves.toEqual({ + type: SubPlatPaymentMethodType.Link, + paymentMethodId: expect.any(String), + }); + }); + + it('returns apple_pay', async () => { + const mockCustomer = StripeCustomerFactory({ + invoice_settings: { + custom_fields: null, + default_payment_method: 'any', + footer: null, + rendering_options: null, + }, + }); + + const mockPaymentMethod = StripeResponseFactory( + { + type: 'card', + card: { wallet: { type: 'apple_pay' } }, + } as any + ); + jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPaymentMethod); + + await expect( + paymentMethodManager.determineType(mockCustomer) + ).resolves.toEqual({ + type: SubPlatPaymentMethodType.ApplePay, + paymentMethodId: expect.any(String), + }); + }); + + it('returns google_pay', async () => { + const mockCustomer = StripeCustomerFactory({ + invoice_settings: { + custom_fields: null, + default_payment_method: 'any', + footer: null, + rendering_options: null, + }, + }); + + const mockPaymentMethod = StripeResponseFactory( + { + type: 'card', + card: { wallet: { type: 'google_pay' } }, + } as any + ); + jest.spyOn(paymentMethodManager, 'retrieve').mockResolvedValue(mockPaymentMethod); + + await expect( + paymentMethodManager.determineType(mockCustomer) + ).resolves.toEqual({ + type: SubPlatPaymentMethodType.GooglePay, + paymentMethodId: expect.any(String), + }); + }); + + it('returns null', async () => { + await expect( + paymentMethodManager.determineType(undefined, undefined) + ).resolves.toBeNull(); + }); + }); }); diff --git a/libs/payments/customer/src/lib/paymentMethod.manager.ts b/libs/payments/customer/src/lib/paymentMethod.manager.ts index 4671ff715b..6c4adf1ad2 100644 --- a/libs/payments/customer/src/lib/paymentMethod.manager.ts +++ b/libs/payments/customer/src/lib/paymentMethod.manager.ts @@ -10,8 +10,17 @@ import { StripeCustomer, StripeSubscription, } from '@fxa/payments/stripe'; -import { DefaultPaymentMethod } from './types'; -import { determinePaymentMethodType } from './util/determinePaymentMethodType'; +import { + DefaultPaymentMethod, + SubPlatPaymentMethodType, + StripePaymentMethod, + PayPalPaymentMethod, +} from './types'; + +type PaymentMethodTypeResponse = + | StripePaymentMethod + | PayPalPaymentMethod + | null; @Injectable() export class PaymentMethodManager { @@ -37,11 +46,11 @@ export class PaymentMethodManager { uid: string ) { let defaultPaymentMethod: DefaultPaymentMethod | undefined; - const paymentMethodType = determinePaymentMethodType( + const paymentMethodType = await this.determineType( customer, subscriptions ); - if (paymentMethodType?.type === 'stripe') { + if (paymentMethodType?.type === SubPlatPaymentMethodType.Stripe) { const paymentMethod = await this.retrieve( paymentMethodType.paymentMethodId ); @@ -51,6 +60,7 @@ export class PaymentMethodManager { last4: paymentMethod.card?.last4, expMonth: paymentMethod.card?.exp_month, expYear: paymentMethod.card?.exp_year, + walletType: paymentMethod.card?.wallet?.type, }; } else if (paymentMethodType?.type === 'external_paypal') { const billingAgreementId = @@ -63,4 +73,57 @@ export class PaymentMethodManager { } return defaultPaymentMethod; } + + async determineType ( + customer?: StripeCustomer, + subscriptions?: StripeSubscription[] + ): Promise { + // First check if payment method is PayPal + // Note, this needs to happen first since a customer could also have a + // default payment method. However if PayPal is set as the payment method, + // it should take precedence. + if ( + subscriptions?.length && + subscriptions[0].collection_method === 'send_invoice' + ) { + return { + type: SubPlatPaymentMethodType.PayPal, + }; + } + + if (customer?.invoice_settings.default_payment_method) { + const paymentMethod = await this.retrieve( + customer.invoice_settings.default_payment_method + ); + if (paymentMethod.card?.wallet?.type === 'apple_pay') { + return { + type: SubPlatPaymentMethodType.ApplePay, + paymentMethodId: customer.invoice_settings.default_payment_method, + } + } else if (paymentMethod.card?.wallet?.type === 'google_pay') { + return { + type: SubPlatPaymentMethodType.GooglePay, + paymentMethodId: customer.invoice_settings.default_payment_method, + } + } else if (paymentMethod.type === 'link') { + return { + type: SubPlatPaymentMethodType.Link, + paymentMethodId: customer.invoice_settings.default_payment_method, + } + } else if (paymentMethod.type === 'card') { + return { + type: SubPlatPaymentMethodType.Card, + paymentMethodId: customer.invoice_settings.default_payment_method, + } + } else { + return { + type: SubPlatPaymentMethodType.Stripe, + paymentMethodId: customer.invoice_settings.default_payment_method, + }; + } + } + + return null; + }; + } diff --git a/libs/payments/customer/src/lib/types.ts b/libs/payments/customer/src/lib/types.ts index 7941a1569f..7b8a9a87a7 100644 --- a/libs/payments/customer/src/lib/types.ts +++ b/libs/payments/customer/src/lib/types.ts @@ -28,6 +28,28 @@ export type InvoicePreview = { subsequentTax?: TaxAmount[]; }; +export enum SubPlatPaymentMethodType { + PayPal = 'external_paypal', + Stripe = 'stripe', + Card = 'card', + ApplePay = 'apple_pay', + GooglePay = 'google_pay', + Link = 'link', +} + +export interface StripePaymentMethod { + type: SubPlatPaymentMethodType.Card + | SubPlatPaymentMethodType.ApplePay + | SubPlatPaymentMethodType.GooglePay + | SubPlatPaymentMethodType.Link + | SubPlatPaymentMethodType.Stripe + paymentMethodId: string; +} + +export interface PayPalPaymentMethod { + type: SubPlatPaymentMethodType.PayPal; +} + export interface Interval { interval: NonNullable['interval']; intervalCount: number; @@ -51,6 +73,7 @@ export interface DefaultPaymentMethod { expMonth?: number; expYear?: number; billingAgreementId?: string; + walletType?: string; } export interface PricingForCurrency { diff --git a/libs/payments/customer/src/lib/util/determinePaymentMethod.spec.ts b/libs/payments/customer/src/lib/util/determinePaymentMethod.spec.ts deleted file mode 100644 index ad63e65e88..0000000000 --- a/libs/payments/customer/src/lib/util/determinePaymentMethod.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* 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 { - StripeCustomerFactory, - StripeSubscriptionFactory, -} from '@fxa/payments/stripe'; -import { determinePaymentMethodType } from './determinePaymentMethodType'; - -describe('determinePaymentMethodType', () => { - it('returns stripe', () => { - const mockCustomer = StripeCustomerFactory(); - expect(determinePaymentMethodType(mockCustomer)).toEqual({ - type: 'stripe', - paymentMethodId: expect.any(String), - }); - }); - - it('returns external_paypal', () => { - const mockSubscription = StripeSubscriptionFactory({ - collection_method: 'send_invoice', - }); - expect(determinePaymentMethodType(undefined, [mockSubscription])).toEqual({ - type: 'external_paypal', - }); - }); - - it('returns null', () => { - expect(determinePaymentMethodType(undefined, undefined)); - }); -}); diff --git a/libs/payments/customer/src/lib/util/determinePaymentMethodType.ts b/libs/payments/customer/src/lib/util/determinePaymentMethodType.ts deleted file mode 100644 index 0aa05d6ffd..0000000000 --- a/libs/payments/customer/src/lib/util/determinePaymentMethodType.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* 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 { StripeCustomer, StripeSubscription } from '@fxa/payments/stripe'; - -interface StripePaymentMethod { - type: 'stripe'; - paymentMethodId: string; -} - -interface PayPalPaymentMethod { - type: 'external_paypal'; -} - -type PaymentMethodTypeResponse = - | StripePaymentMethod - | PayPalPaymentMethod - | null; - -export const determinePaymentMethodType = ( - customer?: StripeCustomer, - subscriptions?: StripeSubscription[] -): PaymentMethodTypeResponse => { - // First check if payment method is PayPal - // Note, this needs to happen first since a customer could also have a - // default payment method. However if PayPal is set as the payment method, - // it should take precedence. - if ( - subscriptions?.length && - subscriptions[0].collection_method === 'send_invoice' - ) { - return { - type: 'external_paypal', - }; - } - - if (customer?.invoice_settings.default_payment_method) { - return { - type: 'stripe', - paymentMethodId: customer.invoice_settings.default_payment_method, - }; - } - - return null; -}; diff --git a/libs/payments/events/src/lib/emitter.types.ts b/libs/payments/events/src/lib/emitter.types.ts index 6d54a3d085..59457b8e45 100644 --- a/libs/payments/events/src/lib/emitter.types.ts +++ b/libs/payments/events/src/lib/emitter.types.ts @@ -10,6 +10,9 @@ import { } from '@fxa/payments/metrics'; import { LocationStatus } from '@fxa/payments/eligibility'; import { TaxChangeAllowedStatus } from '@fxa/payments/cart'; +import { + SubPlatPaymentMethodType, +} from '@fxa/payments/customer'; export type CheckoutEvents = CommonMetrics; export type CheckoutPaymentEvents = CommonMetrics & { @@ -21,7 +24,7 @@ export type SubscriptionEndedEvents = { priceId: string; priceInterval?: string; priceIntervalCount?: number; - paymentProvider?: PaymentProvidersType; + paymentProvider?: SubPlatPaymentMethodType; providerEventId: string; cancellationReason: CancellationReason; uid?: string; diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index ef983c464f..f4db27de2e 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -11,7 +11,6 @@ import { MockCurrencyConfigProvider, } from '@fxa/payments/currency'; import { - determinePaymentMethodType, CustomerManager, InvoiceManager, PaymentMethodManager, @@ -100,9 +99,6 @@ import { import { LOGGER_PROVIDER } from '@fxa/shared/log'; -jest.mock('../../../customer/src/lib/util/determinePaymentMethodType'); -const mockDeterminePaymentMethodType = jest.mocked(determinePaymentMethodType); - jest.mock('@fxa/shared/error', () => ({ ...jest.requireActual('@fxa/shared/error'), SanitizeExceptions: jest.fn(({ allowlist = [] } = {}) => { @@ -344,10 +340,6 @@ describe('SubscriptionManagementService', () => { supportUrl: mockIapOfferingResult2.offering.commonContent.supportUrl, }; - mockDeterminePaymentMethodType.mockReturnValue({ - type: 'stripe', - paymentMethodId: 'pm_id', - }); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -403,7 +395,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -447,7 +438,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -531,14 +521,12 @@ describe('SubscriptionManagementService', () => { supportUrl: mockIapOfferingResult2.offering.commonContent.supportUrl, }; - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); jest .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') .mockResolvedValue(undefined); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -583,7 +571,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -637,7 +624,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -650,7 +636,6 @@ describe('SubscriptionManagementService', () => { jest .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') .mockResolvedValue(undefined); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -710,7 +695,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -723,7 +707,6 @@ describe('SubscriptionManagementService', () => { jest .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') .mockResolvedValue(undefined); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -779,7 +762,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -792,7 +774,6 @@ describe('SubscriptionManagementService', () => { jest .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') .mockResolvedValue(undefined); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -849,7 +830,6 @@ describe('SubscriptionManagementService', () => { purchaseDetails: [], }); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); @@ -862,7 +842,6 @@ describe('SubscriptionManagementService', () => { jest .spyOn(paymentMethodManager, 'getDefaultPaymentMethod') .mockResolvedValue(undefined); - mockDeterminePaymentMethodType.mockReturnValue(null); jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts index 1666dc1d7b..ec148c3063 100644 --- a/libs/payments/metrics/src/lib/glean/glean.types.ts +++ b/libs/payments/metrics/src/lib/glean/glean.types.ts @@ -6,6 +6,9 @@ import Stripe from 'stripe'; export const CheckoutTypes = ['with-accounts', 'without-accounts'] as const; export type CheckoutTypesType = (typeof CheckoutTypes)[number]; +import { + SubPlatPaymentMethodType, +} from '@fxa/payments/customer'; export const PaymentProvidersTypePartial = [ 'card', @@ -16,6 +19,7 @@ export const PaymentProvidersTypePartial = [ ] as const; export type PaymentProvidersType = | Stripe.PaymentMethod.Type + | SubPlatPaymentMethodType | 'google_iap' | 'apple_iap' | 'external_paypal'; 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 a746431de8..1003838b44 100644 --- a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx +++ b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx @@ -82,6 +82,7 @@ interface CheckoutFormProps { last4?: string; brand?: string; customerSessionClientSecret?: string; + walletType?: string; }; }; locale: string; @@ -122,6 +123,7 @@ export function CheckoutForm({ const linkAuthOptions = sessionEmail ? { defaultValues: { email: sessionEmail } } : {}; + const [isNotCard, setIsNotCard] = useState(false); const engageGlean = useCallbackOnce(() => { recordEmitterEventAction( @@ -152,6 +154,10 @@ export function CheckoutForm({ const hasSavedPaymentMethod = !!event?.value?.payment_method?.id; const isNewCardSelected = event?.value?.type === 'card' && !hasSavedPaymentMethod; + const selectedType = event?.value?.type || ''; + const isNotCardType = selectedType !== 'card'; + setIsNotCard(isNotCardType); + setShowLinkAuthElement(isNewCardSelected && hasSavedPaymentMethod); setSelectedPaymentMethod(event?.value?.type || ''); @@ -166,7 +172,7 @@ export function CheckoutForm({ const showPayPalButton = selectedPaymentMethod === 'external_paypal'; const isStripe = cart?.paymentInfo?.type !== 'external_paypal'; const showFullNameInput = - !isPaymentElementLoading && !showPayPalButton && !isSavedPaymentMethod; + !isPaymentElementLoading && !showPayPalButton && !isSavedPaymentMethod && selectedPaymentMethod === 'card' && !isNotCard; const nonStripeFieldsComplete = !showFullNameInput || !!fullName; const submitHandler = async ( diff --git a/libs/payments/ui/src/lib/client/components/PaymentMethodManagement/index.tsx b/libs/payments/ui/src/lib/client/components/PaymentMethodManagement/index.tsx index c1c885c300..3c401cd3c3 100644 --- a/libs/payments/ui/src/lib/client/components/PaymentMethodManagement/index.tsx +++ b/libs/payments/ui/src/lib/client/components/PaymentMethodManagement/index.tsx @@ -46,6 +46,7 @@ export function PaymentMethodManagement({ const [hasFullNameError, setHasFullNameError] = useState(false); const [isNonDefaultCardSelected, setIsNonDefaultCardSelected] = useState(false); + const [isNonCardSelected, setIsNonCardSelected] = useState(false); const handleReady = () => { setIsReady(true); @@ -56,6 +57,14 @@ export function PaymentMethodManagement({ ) => { setIsComplete(event.complete); + if (event.value.type !== 'card') { + setIsNonCardSelected(true); + setIsInputNewCardDetails(false); + setHasFullNameError(false); + return; + } + setIsNonCardSelected(false); + if (event.value.type === 'card' && !event.value.payment_method) { setIsInputNewCardDetails(true); @@ -108,6 +117,11 @@ export function PaymentMethodManagement({ return; } + if (isInputNewCardDetails && !fullName) { + setHasFullNameError(true); + return; + } + setIsLoading(true); setError(null); @@ -250,7 +264,7 @@ export function PaymentMethodManagement({ )} - {isInputNewCardDetails && ( + {(isInputNewCardDetails || isNonCardSelected) && (
{ log: jest.fn(), }; + const paymentMethodManagerMock = { + determineType: jest.fn(), + }; + beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ @@ -61,6 +66,10 @@ describe('StripeEventManager', () => { provide: Logger, useValue: mockLogger, }, + { + provide: PaymentMethodManager, + useValue: paymentMethodManagerMock + }, MockStripeConfigProvider, StripeClient, StripeEventManager, diff --git a/libs/payments/webhooks/src/lib/stripeWebhooks.service.spec.ts b/libs/payments/webhooks/src/lib/stripeWebhooks.service.spec.ts index e672e98d54..c48db9611d 100644 --- a/libs/payments/webhooks/src/lib/stripeWebhooks.service.spec.ts +++ b/libs/payments/webhooks/src/lib/stripeWebhooks.service.spec.ts @@ -14,6 +14,7 @@ import { InvoiceManager, PriceManager, SubscriptionManager, + PaymentMethodManager, } from '@fxa/payments/customer'; import { PaymentsEmitterService } from '@fxa/payments/events'; import { @@ -55,6 +56,10 @@ describe('StripeWebhookService', () => { log: jest.fn(), }; + const paymentMethodManagerMock = { + determineType: jest.fn(), + }; + beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ @@ -62,6 +67,10 @@ describe('StripeWebhookService', () => { provide: Logger, useValue: mockLogger, }, + { + provide: PaymentMethodManager, + useValue: paymentMethodManagerMock + }, MockStripeConfigProvider, StripeClient, StripeEventManager, diff --git a/libs/payments/webhooks/src/lib/subscriptionHandler.service.spec.ts b/libs/payments/webhooks/src/lib/subscriptionHandler.service.spec.ts index bf25707540..33ec719e96 100644 --- a/libs/payments/webhooks/src/lib/subscriptionHandler.service.spec.ts +++ b/libs/payments/webhooks/src/lib/subscriptionHandler.service.spec.ts @@ -21,6 +21,8 @@ import { InvoiceManager, PriceManager, SubscriptionManager, + PaymentMethodManager, + SubPlatPaymentMethodType, } from '@fxa/payments/customer'; import { PaymentsEmitterService } from '@fxa/payments/events'; import { @@ -47,7 +49,6 @@ import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { CustomerSubscriptionDeletedResponseFactory } from './factories'; -import { determinePaymentMethodType } from '@fxa/payments/customer'; import { CancellationReason, determineCancellation, @@ -55,7 +56,6 @@ import { import { Logger } from '@nestjs/common'; jest.mock('@fxa/payments/customer'); -const mockDeterminePaymentMethodType = jest.mocked(determinePaymentMethodType); jest.mock('./util/determineCancellation'); const mockDetermineCancellation = jest.mocked(determineCancellation); @@ -65,6 +65,7 @@ describe('SubscriptionEventsService', () => { let customerManager: CustomerManager; let invoiceManager: InvoiceManager; let emitterService: PaymentsEmitterService; + let paymentMethodManager: PaymentMethodManager; const mockEmitter = { emit: jest.fn(), @@ -73,6 +74,9 @@ describe('SubscriptionEventsService', () => { error: jest.fn(), log: jest.fn(), }; + const paymentMethodManagerMock = { + determineType: jest.fn(), + }; const { event: mockEvent, eventObjectData: mockEventObjectData } = CustomerSubscriptionDeletedResponseFactory(); @@ -84,6 +88,10 @@ describe('SubscriptionEventsService', () => { provide: Logger, useValue: mockLogger, }, + { + provide: PaymentMethodManager, + useValue: paymentMethodManagerMock + }, MockStripeConfigProvider, StripeClient, StripeEventManager, @@ -118,6 +126,7 @@ describe('SubscriptionEventsService', () => { customerManager = module.get(CustomerManager); invoiceManager = module.get(InvoiceManager); emitterService = module.get(PaymentsEmitterService); + paymentMethodManager = module.get(PaymentMethodManager); }); afterEach(() => { @@ -138,8 +147,8 @@ describe('SubscriptionEventsService', () => { jest .spyOn(emitterService, 'getEmitter') .mockReturnValue(mockEmitter as any); - mockDeterminePaymentMethodType.mockReturnValue({ - type: 'stripe', + (paymentMethodManager.determineType as jest.Mock).mockResolvedValue({ + type: SubPlatPaymentMethodType.Card, paymentMethodId: 'pm_id', }); }); @@ -161,8 +170,8 @@ describe('SubscriptionEventsService', () => { }); it('should emit the subscriptionEnded event, with paymentProvider external_paypal', async () => { - mockDeterminePaymentMethodType.mockReturnValue({ - type: 'external_paypal', + (paymentMethodManager.determineType as jest.Mock).mockResolvedValueOnce({ + type: SubPlatPaymentMethodType.PayPal, }); await subscriptionEventsService.handleCustomerSubscriptionDeleted( mockEvent, diff --git a/libs/payments/webhooks/src/lib/subscriptionHandler.service.ts b/libs/payments/webhooks/src/lib/subscriptionHandler.service.ts index c1638560a5..1fa4d9ef74 100644 --- a/libs/payments/webhooks/src/lib/subscriptionHandler.service.ts +++ b/libs/payments/webhooks/src/lib/subscriptionHandler.service.ts @@ -5,12 +5,12 @@ import { CustomerDeletedError, CustomerManager, - determinePaymentMethodType, InvoiceManager, SubscriptionManager, + PaymentMethodManager, + SubPlatPaymentMethodType, } from '@fxa/payments/customer'; import { PaymentsEmitterService } from '@fxa/payments/events'; -import { PaymentProvidersType } from '@fxa/payments/metrics'; import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; import { @@ -24,7 +24,8 @@ export class SubscriptionEventsService { private subscriptionManager: SubscriptionManager, private customerManager: CustomerManager, private invoiceManager: InvoiceManager, - private emitterService: PaymentsEmitterService + private emitterService: PaymentsEmitterService, + private paymentMethodManager: PaymentMethodManager, ) {} async handleCustomerSubscriptionDeleted( @@ -36,7 +37,7 @@ export class SubscriptionEventsService { ); const price = subscription.items.data[0].price; - let paymentProvider: PaymentProvidersType | undefined; + let paymentProvider: SubPlatPaymentMethodType | undefined; let determinedCancellation: CancellationReason | undefined; let uid: string | undefined; try { @@ -44,17 +45,13 @@ export class SubscriptionEventsService { subscription.customer ); uid = customer.metadata['userid']; - const paymentMethodType = determinePaymentMethodType(customer, [ + const paymentMethodType = await this.paymentMethodManager.determineType(customer, [ subscription, ]); - if (paymentMethodType?.type === 'stripe') { - paymentProvider = 'card'; - } else if (paymentMethodType?.type === 'external_paypal') { - paymentProvider = 'external_paypal'; - } + paymentProvider = paymentMethodType?.type; const latestInvoice = - paymentProvider === 'external_paypal' && subscription.latest_invoice + paymentProvider === SubPlatPaymentMethodType.PayPal && subscription.latest_invoice ? await this.invoiceManager.retrieve(subscription.latest_invoice) : undefined; diff --git a/libs/payments/webhooks/src/lib/util/determineCancellation.ts b/libs/payments/webhooks/src/lib/util/determineCancellation.ts index dda4b0d98c..9bebdd9c4f 100644 --- a/libs/payments/webhooks/src/lib/util/determineCancellation.ts +++ b/libs/payments/webhooks/src/lib/util/determineCancellation.ts @@ -2,8 +2,10 @@ * 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 { PaymentProvidersType } from '@fxa/payments/metrics'; import { StripeInvoice, StripeSubscription } from '@fxa/payments/stripe'; +import { + SubPlatPaymentMethodType, +} from '@fxa/payments/customer'; export enum CancellationReason { CustomerInitiated = 'customer_initiated', @@ -11,8 +13,16 @@ export enum CancellationReason { Redundant = 'redundant', } +const STRIPE_PAYMENT_PROVIDER_TYPES = new Set([ + SubPlatPaymentMethodType.Card, + SubPlatPaymentMethodType.GooglePay, + SubPlatPaymentMethodType.ApplePay, + SubPlatPaymentMethodType.Link, + SubPlatPaymentMethodType.Stripe, +]); + export const determineCancellation = ( - paymentProvider: PaymentProvidersType, + paymentProvider: SubPlatPaymentMethodType, subscription: StripeSubscription, latestInvoice?: StripeInvoice ): CancellationReason | undefined => { @@ -20,7 +30,7 @@ export const determineCancellation = ( return CancellationReason.Redundant; } - if (paymentProvider === 'external_paypal') { + if (paymentProvider === SubPlatPaymentMethodType.PayPal) { if (!latestInvoice) { return undefined; } else { @@ -28,7 +38,7 @@ export const determineCancellation = ( ? CancellationReason.CustomerInitiated : CancellationReason.Involuntary; } - } else if (paymentProvider === 'card') { + } else if (STRIPE_PAYMENT_PROVIDER_TYPES.has(paymentProvider)) { return subscription.cancellation_details ? subscription.cancellation_details?.reason === 'cancellation_requested' ? CancellationReason.CustomerInitiated diff --git a/libs/shared/assets/src/images/payment-methods/apple-pay.svg b/libs/shared/assets/src/images/payment-methods/apple-pay.svg new file mode 100644 index 0000000000..30525313f0 --- /dev/null +++ b/libs/shared/assets/src/images/payment-methods/apple-pay.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/libs/shared/assets/src/images/payment-methods/google-pay.svg b/libs/shared/assets/src/images/payment-methods/google-pay.svg new file mode 100644 index 0000000000..b019d2a062 --- /dev/null +++ b/libs/shared/assets/src/images/payment-methods/google-pay.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/libs/shared/l10n/src/lib/branding.ftl b/libs/shared/l10n/src/lib/branding.ftl index b5d6097544..773db84429 100644 --- a/libs/shared/l10n/src/lib/branding.ftl +++ b/libs/shared/l10n/src/lib/branding.ftl @@ -51,7 +51,9 @@ -product-pocket = Pocket -brand-apple = Apple +-brand-apple-pay = Apple Pay -brand-google = Google +-brand-google-pay = Google Pay -brand-paypal = PayPal -brand-name-stripe = Stripe -brand-amex = American Express