From 27f9f43aafcf3e0b6ae050aacbc5b144965c32b4 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Mon, 8 Jun 2026 16:58:31 -0400 Subject: [PATCH 1/2] test(payments): Add test coverage for Checkout - PayPal flows This pull request - Adds unit, integration, and component-level test coverage for PayPal payment method error handling on the subscription management page Closes: PAY-3688 --- .../subscriptions/manage/page.test.tsx | 389 ++++++++++++++++++ .../payments/paypal/page.test.tsx | 192 +++++++++ .../billing-and-subscriptions.service.spec.ts | 235 +++++++++++ .../src/lib/paymentMethod.manager.spec.ts | 26 ++ .../hasOpenInvoiceWithPaymentAttempts.spec.ts | 51 +++ .../subscriptionManagement.service.spec.ts | 77 ++++ .../paypalBillingAgreement.manager.spec.ts | 57 ++- packages/functional-tests/lib/sentry.ts | 68 +++ .../pages/payments/checkout.ts | 135 ++++++ .../tests-payments-next/checkout.spec.ts | 90 +++- 10 files changed, 1305 insertions(+), 15 deletions(-) create mode 100644 apps/payments/next/app/[locale]/subscriptions/manage/page.test.tsx create mode 100644 apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.test.tsx create mode 100644 packages/functional-tests/lib/sentry.ts diff --git a/apps/payments/next/app/[locale]/subscriptions/manage/page.test.tsx b/apps/payments/next/app/[locale]/subscriptions/manage/page.test.tsx new file mode 100644 index 00000000000..d8bfba45d8e --- /dev/null +++ b/apps/payments/next/app/[locale]/subscriptions/manage/page.test.tsx @@ -0,0 +1,389 @@ +/* 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 { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Manage from './page'; + +const mockGetSubManPageContentAction = jest.fn(); +const mockGetExperimentsAction = jest.fn(); +const mockAuth = jest.fn(); +const mockGetL10n = jest.fn(); +const mockRedirect = jest.fn(); +const mockHeaders = jest.fn(); + +jest.mock('@fxa/payments/ui/actions', () => ({ + __esModule: true, + getSubManPageContentAction: (...args: unknown[]) => + mockGetSubManPageContentAction(...args), + getExperimentsAction: (...args: unknown[]) => + mockGetExperimentsAction(...args), +})); + +jest.mock('apps/payments/next/auth', () => ({ + __esModule: true, + auth: () => mockAuth(), +})); + +jest.mock('@fxa/payments/ui/server', () => ({ + __esModule: true, + getApp: () => ({ + getL10n: (...args: unknown[]) => mockGetL10n(...args), + }), +})); + +jest.mock('apps/payments/next/config', () => ({ + __esModule: true, + config: { + paymentsNextHostedUrl: 'https://payments.example.com', + paypal: { clientId: 'paypal-client-id' }, + csp: { paypalApi: 'https://paypal.example.com' }, + }, +})); + +jest.mock('next/navigation', () => ({ + __esModule: true, + redirect: (...args: unknown[]) => mockRedirect(...args), +})); + +jest.mock('next/headers', () => ({ + __esModule: true, + headers: () => mockHeaders(), +})); + +// eslint-disable-next-line @next/next/no-img-element +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ + alt, + className, + }: { + alt?: string; + className?: string; + src?: unknown; + // eslint-disable-next-line @next/next/no-img-element + }) => {alt, +})); + +jest.mock('@fxa/payments/customer', () => ({ + __esModule: true, + SubPlatPaymentMethodType: { + PayPal: 'external_paypal', + Card: 'card', + ApplePay: 'apple_pay', + GooglePay: 'google_pay', + Link: 'link', + Stripe: 'stripe', + }, +})); + +jest.mock('@fxa/payments/ui', () => ({ + __esModule: true, + Banner: ({ + children, + variant, + }: { + children: React.ReactNode; + variant: string; + }) => ( +
+ {children} +
+ ), + BannerVariant: { + Error: 'error', + Info: 'info', + Success: 'success', + Warning: 'warning', + }, + formatPlanInterval: jest.fn(() => 'monthly'), + FreeTrialContent: () =>
, + getCardIcon: jest.fn(() => ({ + img: 'mock-card.svg', + altText: 'PayPal', + width: 40, + height: 24, + })), + GleanPageView: () => null, + SubscriptionContent: () =>
, +})); + +jest.mock('@fxa/shared/react', () => ({ + __esModule: true, + LinkExternal: ({ + children, + href, + ...props + }: { + children: React.ReactNode; + href: string; + [key: string]: unknown; + }) => ( + )}> + {children} + + ), +})); + +jest.mock('clsx', () => ({ + __esModule: true, + default: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +jest.mock( + '@fxa/shared/assets/images/alert-yellow.svg', + () => 'alert-yellow.svg', + { virtual: true } +); +jest.mock( + '@fxa/shared/assets/images/arrow-down.svg', + () => 'arrow-down.svg', + { virtual: true } +); +jest.mock( + '@fxa/shared/assets/images/apple-logo.svg', + () => 'apple-logo.svg', + { virtual: true } +); +jest.mock( + '@fxa/shared/assets/images/google-logo.svg', + () => 'google-logo.svg', + { virtual: true } +); +jest.mock( + '@fxa/shared/assets/images/new-window.svg', + () => 'new-window.svg', + { virtual: true } +); +jest.mock('@fxa/shared/assets/images/error.svg', () => 'error.svg', { + virtual: true, +}); + +const MOCK_USER_ID = 'user-123'; + +const baseSession = { + user: { + id: MOCK_USER_ID, + email: 'user@example.com', + metricsEnabled: true, + }, +}; + +const basePageContent = { + accountCreditBalance: { balance: 0, currency: null }, + defaultPaymentMethod: undefined, + isStripeCustomer: true, + subscriptions: [], + appleIapSubscriptions: [], + googleIapSubscriptions: [], + trialSubscriptions: [], +}; + +const mockL10n = { + getString: (_id: string, ...rest: unknown[]) => { + const fallback = rest.length === 1 ? rest[0] : rest[1]; + return typeof fallback === 'string' ? fallback : ''; + }, + getLocalizedMonthYearString: () => '12/2030', + getLocalizedCurrencyString: () => '$0.00', +}; + +const defaultParams = Promise.resolve({ locale: 'en' }); +const defaultSearchParams = Promise.resolve({}); + +async function renderPage( + paramsOverride?: Promise>, + searchParamsOverride?: Promise> +) { + const jsx = await Manage({ + params: (paramsOverride ?? defaultParams) as any, + searchParams: (searchParamsOverride ?? defaultSearchParams) as any, + }); + return render(jsx); +} + +describe('Manage page — payment method error banner', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHeaders.mockResolvedValue({ get: () => 'en-US' }); + mockAuth.mockResolvedValue(baseSession); + mockGetL10n.mockReturnValue(mockL10n); + mockGetExperimentsAction.mockResolvedValue({ Features: {} }); + mockGetSubManPageContentAction.mockResolvedValue(basePageContent); + }); + + it('does not render error banner when there is no payment method error', async () => { + mockGetSubManPageContentAction.mockResolvedValue({ + ...basePageContent, + defaultPaymentMethod: { + type: 'external_paypal', + billingAgreementId: 'ba_active', + }, + subscriptions: [ + { + id: 'sub_1', + productName: 'Test Product', + currency: 'usd', + interval: 'monthly', + currentInvoiceTax: 0, + currentInvoiceTotal: 999, + currentPeriodEnd: 1700000000, + nextInvoiceDate: 1703000000, + isEligibleForChurnStaySubscribed: false, + }, + ], + }); + + await renderPage(); + + expect(screen.queryByTestId('banner-error')).not.toBeInTheDocument(); + }); + + it('renders error banner with PayPal funding source error content', async () => { + const paypalFundingSourceError = { + paymentMethodType: 'external_paypal', + bannerType: 'error', + bannerTitle: 'Invalid payment information', + bannerTitleFtl: + 'error-payment-method-banner-title-invalid-payment-information', + bannerMessage: 'There is an issue with your account.', + bannerMessageFtl: 'error-payment-method-banner-message-account-issue', + bannerLinkLabel: 'Manage payment method', + bannerLinkLabelFtl: + 'subscription-management-button-manage-payment-method-1', + message: + 'There is an issue with your PayPal account. Please resolve the issue to maintain your active subscriptions.', + messageFtl: 'subscription-management-error-paypal-billing-agreement', + }; + + mockGetSubManPageContentAction.mockResolvedValue({ + ...basePageContent, + defaultPaymentMethod: { + type: 'external_paypal', + billingAgreementId: 'ba_123', + hasPaymentMethodError: paypalFundingSourceError, + }, + subscriptions: [ + { + id: 'sub_1', + productName: 'Test Product', + currency: 'usd', + interval: 'monthly', + currentInvoiceTax: 0, + currentInvoiceTotal: 999, + currentPeriodEnd: 1700000000, + nextInvoiceDate: 1703000000, + isEligibleForChurnStaySubscribed: false, + }, + ], + }); + + await renderPage(); + + const errorBanner = screen.getByTestId('banner-error'); + expect(errorBanner).toBeInTheDocument(); + expect(errorBanner).toHaveTextContent('Invalid payment information'); + expect(errorBanner).toHaveTextContent( + 'There is an issue with your account.' + ); + expect(errorBanner).toHaveTextContent('Manage payment method'); + }); + + it('links to PayPal payment management page when error is on PayPal method', async () => { + const paypalError = { + paymentMethodType: 'external_paypal', + bannerType: 'error', + bannerTitle: 'Invalid payment information', + bannerTitleFtl: + 'error-payment-method-banner-title-invalid-payment-information', + bannerMessage: 'There is an issue with your account.', + bannerMessageFtl: 'error-payment-method-banner-message-account-issue', + bannerLinkLabel: 'Manage payment method', + bannerLinkLabelFtl: + 'subscription-management-button-manage-payment-method-1', + message: + 'There is an issue with your PayPal account. Please resolve the issue to maintain your active subscriptions.', + messageFtl: 'subscription-management-error-paypal-billing-agreement', + }; + + mockGetSubManPageContentAction.mockResolvedValue({ + ...basePageContent, + defaultPaymentMethod: { + type: 'external_paypal', + billingAgreementId: 'ba_123', + hasPaymentMethodError: paypalError, + }, + subscriptions: [ + { + id: 'sub_1', + productName: 'Test Product', + currency: 'usd', + interval: 'monthly', + currentInvoiceTax: 0, + currentInvoiceTotal: 999, + currentPeriodEnd: 1700000000, + nextInvoiceDate: 1703000000, + isEligibleForChurnStaySubscribed: false, + }, + ], + }); + + await renderPage(); + + const errorBanner = screen.getByTestId('banner-error'); + const bannerLink = errorBanner.querySelector('a'); + expect(bannerLink).toHaveAttribute( + 'href', + 'https://payments.example.com/en/subscriptions/payments/paypal' + ); + }); + + it('renders inline error message in payment method details section', async () => { + const paypalError = { + paymentMethodType: 'external_paypal', + bannerType: 'error', + bannerTitle: 'Invalid payment information', + bannerTitleFtl: + 'error-payment-method-banner-title-invalid-payment-information', + bannerMessage: 'There is an issue with your account.', + bannerMessageFtl: 'error-payment-method-banner-message-account-issue', + bannerLinkLabel: 'Manage payment method', + bannerLinkLabelFtl: + 'subscription-management-button-manage-payment-method-1', + message: + 'There is an issue with your PayPal account. Please resolve the issue to maintain your active subscriptions.', + messageFtl: 'subscription-management-error-paypal-billing-agreement', + }; + + mockGetSubManPageContentAction.mockResolvedValue({ + ...basePageContent, + defaultPaymentMethod: { + type: 'external_paypal', + billingAgreementId: 'ba_123', + hasPaymentMethodError: paypalError, + }, + subscriptions: [ + { + id: 'sub_1', + productName: 'Test Product', + currency: 'usd', + interval: 'monthly', + currentInvoiceTax: 0, + currentInvoiceTotal: 999, + currentPeriodEnd: 1700000000, + nextInvoiceDate: 1703000000, + isEligibleForChurnStaySubscribed: false, + }, + ], + }); + + await renderPage(); + + expect( + screen.getByText( + /There is an issue with your PayPal account\. Please resolve the issue to maintain your active subscriptions\./ + ) + ).toBeInTheDocument(); + }); +}); diff --git a/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.test.tsx b/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.test.tsx new file mode 100644 index 00000000000..45bacc99515 --- /dev/null +++ b/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.test.tsx @@ -0,0 +1,192 @@ +/* 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 { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import PaypalPaymentManagementPage from './page'; + +const mockAuth = jest.fn(); +const mockGetL10n = jest.fn(); +const mockRedirect = jest.fn(); +const mockHeaders = jest.fn(); +const mockGetPayPalBillingAgreementId = jest.fn(); +const mockDetermineCurrencyForCustomerAction = jest.fn(); +const mockPaypalManagement = jest.fn(); + +jest.mock('apps/payments/next/auth', () => ({ + __esModule: true, + auth: () => mockAuth(), +})); + +jest.mock('@fxa/payments/ui/server', () => ({ + __esModule: true, + getApp: () => ({ + getL10n: (...args: unknown[]) => mockGetL10n(...args), + }), +})); + +jest.mock('@fxa/payments/ui/actions', () => ({ + __esModule: true, + getPayPalBillingAgreementId: (...args: unknown[]) => + mockGetPayPalBillingAgreementId(...args), + determineCurrencyForCustomerAction: (...args: unknown[]) => + mockDetermineCurrencyForCustomerAction(...args), +})); + +jest.mock('apps/payments/next/config', () => ({ + __esModule: true, + config: { + paymentsNextHostedUrl: 'https://payments.example.com', + paypal: { clientId: 'paypal-client-id' }, + }, +})); + +jest.mock('next/navigation', () => ({ + __esModule: true, + redirect: (...args: unknown[]) => { + mockRedirect(...args); + throw new Error('NEXT_REDIRECT'); + }, +})); + +jest.mock('next/headers', () => ({ + __esModule: true, + headers: () => mockHeaders(), +})); + +// eslint-disable-next-line @next/next/no-img-element +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ + alt, + className, + }: { + alt?: string; + className?: string; + src?: unknown; + // eslint-disable-next-line @next/next/no-img-element + }) => {alt, +})); + +jest.mock('@fxa/payments/ui', () => ({ + __esModule: true, + PaypalManagement: (props: Record) => { + mockPaypalManagement(props); + return
; + }, +})); + +jest.mock('@fxa/shared/assets/images/error.svg', () => 'error.svg', { + virtual: true, +}); + +const MOCK_USER_ID = 'user-123'; + +const baseSession = { + user: { + id: MOCK_USER_ID, + email: 'user@example.com', + }, +}; + +const mockL10n = { + getString: (_id: string, ...rest: unknown[]) => { + const fallback = rest.length === 1 ? rest[0] : rest[1]; + return typeof fallback === 'string' ? fallback : ''; + }, +}; + +const defaultParams = Promise.resolve({ locale: 'en' }); + +async function renderPage(paramsOverride?: Promise>) { + const jsx = await PaypalPaymentManagementPage({ + params: (paramsOverride ?? defaultParams) as any, + }); + return render(jsx); +} + +describe('PayPal payment management page', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHeaders.mockResolvedValue({ get: () => 'en-US' }); + mockAuth.mockResolvedValue(baseSession); + mockGetL10n.mockReturnValue(mockL10n); + mockGetPayPalBillingAgreementId.mockResolvedValue(null); + mockDetermineCurrencyForCustomerAction.mockResolvedValue('usd'); + }); + + it('redirects to landing when user is not authenticated', async () => { + mockAuth.mockResolvedValue(null); + + await expect(renderPage()).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith( + 'https://payments.example.com/en/subscriptions/landing' + ); + }); + + it('redirects to landing when billing agreement already exists', async () => { + mockGetPayPalBillingAgreementId.mockResolvedValue('ba_existing'); + + await expect(renderPage()).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith( + 'https://payments.example.com/en/subscriptions/landing' + ); + }); + + it('redirects to landing when currency cannot be determined', async () => { + mockDetermineCurrencyForCustomerAction.mockResolvedValue(null); + + await expect(renderPage()).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith( + 'https://payments.example.com/en/subscriptions/landing' + ); + }); + + it('renders invalid billing information heading', async () => { + await renderPage(); + + expect( + screen.getByRole('heading', { name: /Invalid billing information/i }) + ).toBeInTheDocument(); + }); + + it('renders error description about PayPal account issue', async () => { + await renderPage(); + + expect( + screen.getByText( + /There seems to be an error with your PayPal account/i + ) + ).toBeInTheDocument(); + }); + + it('renders "Pay with PayPal" separator text', async () => { + await renderPage(); + + expect(screen.getByText(/Pay with PayPal/i)).toBeInTheDocument(); + }); + + it('renders PaypalManagement component with config and currency', async () => { + await renderPage(); + + expect(screen.getByTestId('paypal-management')).toBeInTheDocument(); + expect(mockPaypalManagement).toHaveBeenCalledWith( + expect.objectContaining({ + paypalClientId: 'paypal-client-id', + currency: 'usd', + }) + ); + }); + + it('renders the page section with correct test id', async () => { + await renderPage(); + + expect( + screen.getByTestId('paypal-payment-management') + ).toBeInTheDocument(); + }); +}); diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts index afa46442279..fa76d1a1bce 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts @@ -13,6 +13,8 @@ import { PaymentMethodManager, PriceManager, ProductManager, + STRIPE_CUSTOMER_METADATA, + STRIPE_INVOICE_METADATA, SubscriptionManager, } from '@fxa/payments/customer'; import { @@ -43,6 +45,7 @@ import { StripePriceFactory, StripePriceRecurringFactory, StripeProductFactory, + StripeInvoiceFactory, StripeResponseFactory, StripeSubscriptionFactory, StripeSubscriptionItemFactory, @@ -72,6 +75,7 @@ describe('BillingAndSubscriptionsService', () => { let accountCustomerManager: AccountCustomerManager; let customerManager: CustomerManager; let subscriptionManager: SubscriptionManager; + let invoiceManager: InvoiceManager; let paymentMethodManager: PaymentMethodManager; let priceManager: PriceManager; let productManager: ProductManager; @@ -123,6 +127,7 @@ describe('BillingAndSubscriptionsService', () => { accountCustomerManager = moduleRef.get(AccountCustomerManager); customerManager = moduleRef.get(CustomerManager); subscriptionManager = moduleRef.get(SubscriptionManager); + invoiceManager = moduleRef.get(InvoiceManager); paymentMethodManager = moduleRef.get(PaymentMethodManager); priceManager = moduleRef.get(PriceManager); productManager = moduleRef.get(ProductManager); @@ -458,6 +463,236 @@ describe('BillingAndSubscriptionsService', () => { expect(result.paypal_payment_error).toBe('missing_agreement'); }); + it('sets paypal_payment_error=funding_source when PayPal sub has agreement and open invoice with retry attempts', async () => { + const customer = StripeResponseFactory( + StripeCustomerFactory({ + id: STRIPE_CUSTOMER_ID, + metadata: { + [STRIPE_CUSTOMER_METADATA.PaypalAgreement]: 'ba_123', + }, + invoice_settings: { + custom_fields: null, + default_payment_method: null, + footer: null, + rendering_options: null, + }, + }) + ); + const price = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ interval: 'month' }), + }); + const sub = StripeSubscriptionFactory({ + status: 'active', + collection_method: 'send_invoice', + cancel_at_period_end: false, + latest_invoice: 'in_open_retry', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price, product: 'prod_p' }, + }), + ], + has_more: false, + url: '', + }, + }); + const openInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + id: 'in_open_retry', + status: 'open', + metadata: { + [STRIPE_INVOICE_METADATA.RetryAttempts]: '2', + }, + }) + ); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue({ + uid: UID, + stripeCustomerId: STRIPE_CUSTOMER_ID, + } as never); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(customer); + jest + .spyOn(subscriptionManager, 'listActiveForCustomer') + .mockResolvedValue([sub]); + jest + .spyOn(invoiceManager, 'retrieve') + .mockResolvedValue(openInvoice); + jest + .spyOn(capabilityManager, 'priceIdsToClientCapabilities') + .mockResolvedValue({ [CLIENT_ID]: ['cap'] }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + expect(result.payment_provider).toBe('paypal'); + expect(result.paypal_payment_error).toBe('funding_source'); + }); + + it('sets funding_source when only one of multiple PayPal subs has a failing invoice', async () => { + const customer = StripeResponseFactory( + StripeCustomerFactory({ + id: STRIPE_CUSTOMER_ID, + metadata: { + [STRIPE_CUSTOMER_METADATA.PaypalAgreement]: 'ba_123', + }, + invoice_settings: { + custom_fields: null, + default_payment_method: null, + footer: null, + rendering_options: null, + }, + }) + ); + const price1 = StripePriceFactory({ + id: 'price_ok', + recurring: StripePriceRecurringFactory({ interval: 'month' }), + }); + const price2 = StripePriceFactory({ + id: 'price_failing', + recurring: StripePriceRecurringFactory({ interval: 'year' }), + }); + const healthySub = StripeSubscriptionFactory({ + id: 'sub_healthy', + status: 'active', + collection_method: 'send_invoice', + cancel_at_period_end: false, + latest_invoice: 'in_healthy', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price1, product: 'prod_a' }, + }), + ], + has_more: false, + url: '', + }, + }); + const failingSub = StripeSubscriptionFactory({ + id: 'sub_failing', + status: 'active', + collection_method: 'send_invoice', + cancel_at_period_end: false, + latest_invoice: 'in_failing', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price2, product: 'prod_b' }, + }), + ], + has_more: false, + url: '', + }, + }); + const healthyInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + id: 'in_healthy', + status: 'paid', + metadata: {}, + }) + ); + const failingInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + id: 'in_failing', + status: 'open', + metadata: { + [STRIPE_INVOICE_METADATA.RetryAttempts]: '3', + }, + }) + ); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue({ + uid: UID, + stripeCustomerId: STRIPE_CUSTOMER_ID, + } as never); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(customer); + jest + .spyOn(subscriptionManager, 'listActiveForCustomer') + .mockResolvedValue([healthySub, failingSub]); + jest + .spyOn(invoiceManager, 'retrieve') + .mockImplementation(async (id: string) => + id === 'in_healthy' ? healthyInvoice : failingInvoice + ); + jest + .spyOn(capabilityManager, 'priceIdsToClientCapabilities') + .mockResolvedValue({ + [CLIENT_ID]: ['cap_a', 'cap_b'], + }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + expect(result.payment_provider).toBe('paypal'); + expect(result.paypal_payment_error).toBe('funding_source'); + }); + + it('does not set paypal_payment_error when PayPal sub has agreement but no open invoice with retries', async () => { + const customer = StripeResponseFactory( + StripeCustomerFactory({ + id: STRIPE_CUSTOMER_ID, + metadata: { + [STRIPE_CUSTOMER_METADATA.PaypalAgreement]: 'ba_123', + }, + invoice_settings: { + custom_fields: null, + default_payment_method: null, + footer: null, + rendering_options: null, + }, + }) + ); + const price = StripePriceFactory({ + recurring: StripePriceRecurringFactory({ interval: 'month' }), + }); + const sub = StripeSubscriptionFactory({ + status: 'active', + collection_method: 'send_invoice', + cancel_at_period_end: false, + latest_invoice: 'in_paid', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price, product: 'prod_p' }, + }), + ], + has_more: false, + url: '', + }, + }); + const paidInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + id: 'in_paid', + status: 'paid', + metadata: {}, + }) + ); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue({ + uid: UID, + stripeCustomerId: STRIPE_CUSTOMER_ID, + } as never); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(customer); + jest + .spyOn(subscriptionManager, 'listActiveForCustomer') + .mockResolvedValue([sub]); + jest + .spyOn(invoiceManager, 'retrieve') + .mockResolvedValue(paidInvoice); + jest + .spyOn(capabilityManager, 'priceIdsToClientCapabilities') + .mockResolvedValue({ [CLIENT_ID]: ['cap'] }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + expect(result.payment_provider).toBe('paypal'); + expect(result.paypal_payment_error).toBeUndefined(); + }); + it('sanitizes IAP misconfig errors and logs the pre-sanitization context', async () => { jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') diff --git a/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts b/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts index 82cf182796e..347eccaffbe 100644 --- a/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts +++ b/libs/payments/customer/src/lib/paymentMethod.manager.spec.ts @@ -335,6 +335,32 @@ describe('PaymentMethodManager', () => { }); }); + it('returns no error when PayPal billing agreement exists', async () => { + const mockPaypalBillingAgreementId = faker.string.sample(); + const mockStripeCustomer = StripeCustomerFactory(); + const mockSubscriptions = [StripeSubscriptionFactory()]; + const mockUid = faker.string.uuid(); + + jest.spyOn(paymentMethodManager, 'determineType').mockResolvedValue({ + provider: PaymentProvider.PayPal, + type: SubPlatPaymentMethodType.PayPal, + }); + jest + .spyOn(paypalBillingAgreementManager, 'retrieveActiveId') + .mockResolvedValue(mockPaypalBillingAgreementId); + + const result = await paymentMethodManager.getDefaultPaymentMethod( + mockStripeCustomer, + mockSubscriptions, + mockUid + ); + expect(result).toEqual({ + type: SubPlatPaymentMethodType.PayPal, + billingAgreementId: mockPaypalBillingAgreementId, + hasPaymentMethodError: undefined, + }); + }); + it('returns hasPaymentMethodError (generic issue) in payment method information - paypal', async () => { const mockStripeCustomer = StripeCustomerFactory(); const mockSubscriptions = [StripeSubscriptionFactory()]; diff --git a/libs/payments/customer/src/lib/util/hasOpenInvoiceWithPaymentAttempts.spec.ts b/libs/payments/customer/src/lib/util/hasOpenInvoiceWithPaymentAttempts.spec.ts index 6f3803d2de6..e02dde71732 100644 --- a/libs/payments/customer/src/lib/util/hasOpenInvoiceWithPaymentAttempts.spec.ts +++ b/libs/payments/customer/src/lib/util/hasOpenInvoiceWithPaymentAttempts.spec.ts @@ -49,4 +49,55 @@ describe('hasOpenInvoiceWithPaymentAttempts', () => { ); expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); }); + + it('returns false when metadata is null', () => { + const invoice = StripeResponseFactory( + StripeInvoiceFactory({ + status: 'open', + metadata: null, + }) + ); + expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); + }); + + it('returns false when retry attempts metadata is non-numeric', () => { + const invoice = StripeResponseFactory( + StripeInvoiceFactory({ + status: 'open', + metadata: { [STRIPE_INVOICE_METADATA.RetryAttempts]: 'abc' }, + }) + ); + // parseInt('abc') → NaN, and NaN > 0 is false + expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); + }); + + it('returns false for draft invoice with retry attempts', () => { + const invoice = StripeResponseFactory( + StripeInvoiceFactory({ + status: 'draft', + metadata: { [STRIPE_INVOICE_METADATA.RetryAttempts]: '1' }, + }) + ); + expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); + }); + + it('returns false for void invoice with retry attempts', () => { + const invoice = StripeResponseFactory( + StripeInvoiceFactory({ + status: 'void', + metadata: { [STRIPE_INVOICE_METADATA.RetryAttempts]: '1' }, + }) + ); + expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); + }); + + it('returns false for uncollectible invoice with retry attempts', () => { + const invoice = StripeResponseFactory( + StripeInvoiceFactory({ + status: 'uncollectible', + metadata: { [STRIPE_INVOICE_METADATA.RetryAttempts]: '1' }, + }) + ); + expect(hasOpenInvoiceWithPaymentAttempts(invoice)).toBe(false); + }); }); diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index ecc6ee3265b..f2d9b4293a3 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -31,6 +31,7 @@ import { SubscriptionManager, SetupIntentManager, CustomerSessionManager, + BannerVariant, SubPlatPaymentMethodType, } from '@fxa/payments/customer'; import { @@ -595,6 +596,82 @@ describe('SubscriptionManagementService', () => { }); }); + it('surfaces PayPal missing-agreement error through real PaymentMethodManager', async () => { + const mockUid = faker.string.uuid(); + const mockStripeCustomer = StripeResponseFactory( + StripeCustomerFactory({ currency: 'usd' }) + ); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: mockStripeCustomer.id, + }); + // PayPal subscription — collection_method: 'send_invoice' triggers + // PaymentMethodManager.determineType to identify PayPal provider + const paypalSub = StripeSubscriptionFactory({ + collection_method: 'send_invoice', + }); + const mockAppleIapPurchaseResult = AppleIapPurchaseResultFactory({ + storeIds: [], + purchaseDetails: [], + }); + const mockGoogleIapPurchaseResult = GoogleIapPurchaseResultFactory({ + storeIds: [], + purchaseDetails: [], + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(customerManager, 'retrieve') + .mockResolvedValue(mockStripeCustomer); + jest + .spyOn(subscriptionManager, 'listForCustomer') + .mockResolvedValue([paypalSub]); + // Do NOT mock paymentMethodManager.getDefaultPaymentMethod — + // let the real implementation run so we exercise the full chain. + // Mock only the low-level PayPal dependency: no active agreement. + jest + .spyOn(paypalBillingAgreementManager, 'retrieveActiveId') + .mockResolvedValue(undefined); + jest + .spyOn(subscriptionManagementService as any, 'getSubscriptionContent') + .mockResolvedValue(SubscriptionContentFactory()); + jest + .spyOn(churnInterventionService, 'determineStaySubscribedEligibility') + .mockResolvedValue({ + isEligible: false, + reason: 'not_eligible', + cmsChurnInterventionEntry: null, + cmsOfferingContent: null, + }); + jest + .spyOn(subscriptionManagementService as any, 'getAppleIapPurchases') + .mockResolvedValue(mockAppleIapPurchaseResult); + jest + .spyOn(subscriptionManagementService as any, 'getGoogleIapPurchases') + .mockResolvedValue(mockGoogleIapPurchaseResult); + + const result = + await subscriptionManagementService.getPageContent(mockUid); + + expect(result.defaultPaymentMethod).toBeDefined(); + expect(result.defaultPaymentMethod?.type).toBe( + SubPlatPaymentMethodType.PayPal + ); + expect( + result.defaultPaymentMethod?.hasPaymentMethodError + ).toBeDefined(); + expect( + result.defaultPaymentMethod?.hasPaymentMethodError?.bannerType + ).toBe(BannerVariant.Error); + expect( + result.defaultPaymentMethod?.hasPaymentMethodError?.paymentMethodType + ).toBe(SubPlatPaymentMethodType.PayPal); + expect( + result.defaultPaymentMethod?.hasPaymentMethodError?.bannerTitle + ).toBe('Invalid payment information'); + }); + it('returns if there are only IAP subscriptions', async () => { const mockUid = faker.string.uuid(); const mockAccountCustomer = ResultAccountCustomerFactory({ diff --git a/libs/payments/paypal/src/lib/paypalBillingAgreement.manager.spec.ts b/libs/payments/paypal/src/lib/paypalBillingAgreement.manager.spec.ts index a8bd7378e75..5c93a49adc6 100644 --- a/libs/payments/paypal/src/lib/paypalBillingAgreement.manager.spec.ts +++ b/libs/payments/paypal/src/lib/paypalBillingAgreement.manager.spec.ts @@ -14,7 +14,10 @@ import { } from './factories'; import { PayPalClient } from './paypal.client'; import { MockPaypalClientConfigProvider } from './paypal.client.config'; -import { PayPalActiveSubscriptionsMissingAgreementError, PaypalBillingAgreementMissingTokenError } from './paypal.error'; +import { + PayPalActiveSubscriptionsMissingAgreementError, + PaypalBillingAgreementMissingTokenError, +} from './paypal.error'; import { BillingAgreementStatus } from './paypal.types'; import { PaypalBillingAgreementManager } from './paypalBillingAgreement.manager'; import { PaypalCustomerMultipleRecordsError } from './paypalCustomer/paypalCustomer.error'; @@ -255,6 +258,58 @@ describe('PaypalBillingAgreementManager', () => { expect(baUpdateMock).toBeCalledTimes(1); expect(baUpdateMock).toBeCalledWith({ billingAgreementId }); }); + + it('throws when baUpdate rejects', async () => { + const billingAgreementId = faker.string.sample(); + + jest + .spyOn(paypalClient, 'baUpdate') + .mockRejectedValue(new Error('PayPal API failure')); + + await expect( + paypalBillingAgreementManager.retrieve(billingAgreementId) + ).rejects.toThrow('PayPal API failure'); + }); + }); + + describe('refresh billing agreement', () => { + it('creates a new billing agreement after the old one is cancelled', async () => { + const uid = faker.string.uuid(); + const oldBillingAgreementId = faker.string.uuid(); + const newToken = faker.string.uuid(); + const newBillingAgreementId = faker.string.uuid(); + + jest + .spyOn(paypalClient, 'baUpdate') + .mockResolvedValue(NVPBAUpdateTransactionResponseFactory()); + + jest + .spyOn(paypalBillingAgreementManager, 'retrieveActiveId') + .mockResolvedValue(undefined); + + jest + .spyOn(paypalBillingAgreementManager, 'create') + .mockResolvedValue(newBillingAgreementId); + + await paypalBillingAgreementManager.cancel(oldBillingAgreementId); + + expect(paypalClient.baUpdate).toHaveBeenCalledWith({ + billingAgreementId: oldBillingAgreementId, + cancel: true, + }); + + const result = await paypalBillingAgreementManager.retrieveOrCreateId( + uid, + false, + newToken + ); + + expect(result).toEqual(newBillingAgreementId); + expect(paypalBillingAgreementManager.create).toHaveBeenCalledWith( + uid, + newToken + ); + }); }); describe('retrieveActiveId', () => { diff --git a/packages/functional-tests/lib/sentry.ts b/packages/functional-tests/lib/sentry.ts new file mode 100644 index 00000000000..c6c8dcf9b1e --- /dev/null +++ b/packages/functional-tests/lib/sentry.ts @@ -0,0 +1,68 @@ +/* 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/. */ + +const SENTRY_DSN = process.env.SENTRY__CLIENT_DSN; + +/** + * Strip URL query parameters, tokens, and session IDs from error + * messages to avoid leaking sensitive data to Sentry. + */ +function sanitizeMessage(message: string): string { + return message + .replace(/https?:\/\/\S+/g, (url) => { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return '[redacted-url]'; + } + }) + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + '[redacted-uuid]' + ) + .replace(/\b[0-9a-f]{32,}\b/gi, '[redacted-token]') + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted-email]'); +} + +/** + * Report a warning-level event to the payments-next Sentry project. + * Uses the Sentry envelope API directly — no SDK dependency needed. + * No-ops silently when SENTRY__CLIENT_DSN is not set (local dev). + * Sanitizes messages to strip URLs, tokens, and session IDs. + */ +export async function reportToSentry( + message: string, + tags: Record = {} +): Promise { + if (!SENTRY_DSN) return; + + const dsnUrl = new URL(SENTRY_DSN); + const projectId = dsnUrl.pathname.replace(/^\//, ''); + const sentryKey = dsnUrl.username; + const envelopeUrl = `https://${dsnUrl.host}/api/${projectId}/envelope/`; + const eventId = crypto.randomUUID().replace(/-/g, ''); + + const envelope = [ + JSON.stringify({ event_id: eventId }), + JSON.stringify({ type: 'event' }), + JSON.stringify({ + event_id: eventId, + message: sanitizeMessage(message), + level: 'warning', + tags: { source: 'functional-tests', ...tags }, + }), + ].join('\n'); + + await fetch(envelopeUrl, { + method: 'POST', + body: envelope, + headers: { + 'X-Sentry-Auth': `Sentry sentry_key=${sentryKey}, sentry_version=7`, + }, + }).catch((err) => { + // eslint-disable-next-line no-console + console.debug('Sentry report failed:', err); + }); +} diff --git a/packages/functional-tests/pages/payments/checkout.ts b/packages/functional-tests/pages/payments/checkout.ts index ec3c91afd08..e8bbbcd64e3 100644 --- a/packages/functional-tests/pages/payments/checkout.ts +++ b/packages/functional-tests/pages/payments/checkout.ts @@ -4,6 +4,7 @@ import { expect } from '@playwright/test'; import { TestCardDefaults } from '../../lib/stripe-test-cards'; +import { reportToSentry } from '../../lib/sentry'; import { BasePaymentPage } from './base'; export class CheckoutPage extends BasePaymentPage { @@ -63,6 +64,140 @@ export class CheckoutPage extends BasePaymentPage { return this.stripeFrame.getByText(/Save (?:my )?information for/i); } + // PayPal (rendered by @paypal/react-paypal-js inside an iframe) + + /** + * The PayPal button iframe rendered by the PayPal JS SDK. + * Visible only after the user selects PayPal in the Stripe + * PaymentElement accordion. + */ + get paypalButton() { + // PayPal renders two iframes: the actual button and a prerender + // placeholder. Filter out the prerender frame by name. + // The PayPal button is rendered as a clickable div with + // role="link", not a semantic