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..a2d6286dc23
--- /dev/null
+++ b/apps/payments/next/app/[locale]/subscriptions/manage/page.test.tsx
@@ -0,0 +1,325 @@
+/* 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 {
+ DefaultPaymentMethodFactory,
+ DefaultPaymentMethodErrorFactory,
+ SubPlatPaymentMethodType,
+} from '@fxa/payments/customer/testing';
+import { SubscriptionContentFactory } from '@fxa/payments/management/testing';
+import { SessionFactory } from '@fxa/payments/ui-auth/testing';
+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
+ }) =>
,
+}));
+
+jest.mock('@fxa/payments/customer', () => {
+ const actual = jest.requireActual('@fxa/payments/customer/testing');
+ return {
+ __esModule: true,
+ ...actual,
+ 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 baseSession = SessionFactory();
+
+const basePageContent = {
+ accountCreditBalance: { balance: 0, currency: null },
+ defaultPaymentMethod: undefined as
+ | ReturnType
+ | undefined,
+ isStripeCustomer: true,
+ subscriptions: [] as ReturnType[],
+ 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: DefaultPaymentMethodFactory({
+ type: SubPlatPaymentMethodType.PayPal,
+ billingAgreementId: 'ba_active',
+ hasPaymentMethodError: undefined,
+ }),
+ subscriptions: [SubscriptionContentFactory()],
+ });
+
+ await renderPage();
+
+ expect(screen.queryByTestId('banner-error')).not.toBeInTheDocument();
+ });
+
+ it('renders error banner with PayPal funding source error content', async () => {
+ const paypalFundingSourceError = DefaultPaymentMethodErrorFactory({
+ paymentMethodType: SubPlatPaymentMethodType.PayPal,
+ 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: DefaultPaymentMethodFactory({
+ type: SubPlatPaymentMethodType.PayPal,
+ billingAgreementId: 'ba_123',
+ hasPaymentMethodError: paypalFundingSourceError,
+ }),
+ subscriptions: [SubscriptionContentFactory()],
+ });
+
+ 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 = DefaultPaymentMethodErrorFactory({
+ paymentMethodType: SubPlatPaymentMethodType.PayPal,
+ });
+
+ mockGetSubManPageContentAction.mockResolvedValue({
+ ...basePageContent,
+ defaultPaymentMethod: DefaultPaymentMethodFactory({
+ type: SubPlatPaymentMethodType.PayPal,
+ billingAgreementId: 'ba_123',
+ hasPaymentMethodError: paypalError,
+ }),
+ subscriptions: [SubscriptionContentFactory()],
+ });
+
+ 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 = DefaultPaymentMethodErrorFactory({
+ paymentMethodType: SubPlatPaymentMethodType.PayPal,
+ 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: DefaultPaymentMethodFactory({
+ type: SubPlatPaymentMethodType.PayPal,
+ billingAgreementId: 'ba_123',
+ hasPaymentMethodError: paypalError,
+ }),
+ subscriptions: [SubscriptionContentFactory()],
+ });
+
+ 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..3774709a60c
--- /dev/null
+++ b/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.test.tsx
@@ -0,0 +1,186 @@
+/* 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 { SessionFactory } from '@fxa/payments/ui-auth/testing';
+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
+ }) =>
,
+}));
+
+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 baseSession = SessionFactory();
+
+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/factories/paymentMethod.factory.ts b/libs/payments/customer/src/lib/factories/paymentMethod.factory.ts
index f50930d44cb..1231cdcc5ff 100644
--- a/libs/payments/customer/src/lib/factories/paymentMethod.factory.ts
+++ b/libs/payments/customer/src/lib/factories/paymentMethod.factory.ts
@@ -4,8 +4,11 @@
import { faker } from '@faker-js/faker';
import {
+ BannerVariant,
PaymentProvider,
SubPlatPaymentMethodType,
+ type DefaultPaymentMethod,
+ type DefaultPaymentMethodError,
type StripePaymentMethod,
} from '../types';
@@ -23,3 +26,31 @@ export const StripePaymentMethodTypeResponseFactory = (
paymentMethodId: `pm_${faker.string.alphanumeric({ length: 14 })}`,
...override,
});
+
+export const DefaultPaymentMethodErrorFactory = (
+ override?: Partial
+): DefaultPaymentMethodError => ({
+ paymentMethodType: SubPlatPaymentMethodType.PayPal,
+ bannerType: BannerVariant.Error,
+ bannerTitle: faker.lorem.words(3),
+ bannerTitleFtl: faker.lorem.slug(5),
+ bannerMessage: faker.lorem.sentence(),
+ bannerMessageFtl: faker.lorem.slug(5),
+ bannerLinkLabel: faker.lorem.words(3),
+ bannerLinkLabelFtl: faker.lorem.slug(5),
+ message: faker.lorem.sentence(),
+ messageFtl: faker.lorem.slug(5),
+ ...override,
+});
+
+export const DefaultPaymentMethodFactory = (
+ override?: Partial
+): DefaultPaymentMethod => ({
+ type: SubPlatPaymentMethodType.Card,
+ brand: 'visa',
+ last4: faker.string.numeric(4),
+ expMonth: faker.number.int({ min: 1, max: 12 }),
+ expYear: faker.number.int({ min: 2025, max: 2035 }),
+ hasPaymentMethodError: undefined,
+ ...override,
+});
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/customer/src/testing.ts b/libs/payments/customer/src/testing.ts
new file mode 100644
index 00000000000..10de0497e4e
--- /dev/null
+++ b/libs/payments/customer/src/testing.ts
@@ -0,0 +1,7 @@
+/* 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/. */
+
+export * from './lib/factories/paymentMethod.factory';
+export * from './lib/factories/tax-address.factory';
+export * from './lib/types';
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/management/src/testing.ts b/libs/payments/management/src/testing.ts
new file mode 100644
index 00000000000..bfa0ea6520a
--- /dev/null
+++ b/libs/payments/management/src/testing.ts
@@ -0,0 +1,6 @@
+/* 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/. */
+
+export * from './lib/factories/subscriptionContent.factory';
+export * from './lib/types';
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/libs/payments/ui-auth/src/index.ts b/libs/payments/ui-auth/src/index.ts
index 691b21275a3..0f60ecc46f5 100644
--- a/libs/payments/ui-auth/src/index.ts
+++ b/libs/payments/ui-auth/src/index.ts
@@ -7,3 +7,4 @@ export type { UiAuthOptions } from './lib/auth';
export { authConfig } from './lib/auth.config';
export { AuthError, UnauthenticatedError } from './lib/auth.error';
export { getSessionUid, requireSessionUid } from './lib/session';
+export { SessionFactory } from './lib/session.factory';
diff --git a/libs/payments/ui-auth/src/lib/session.factory.ts b/libs/payments/ui-auth/src/lib/session.factory.ts
new file mode 100644
index 00000000000..53b6bcc8786
--- /dev/null
+++ b/libs/payments/ui-auth/src/lib/session.factory.ts
@@ -0,0 +1,26 @@
+/* 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 { faker } from '@faker-js/faker';
+
+interface SessionUser {
+ id: string;
+ email: string;
+ metricsEnabled?: boolean;
+}
+
+interface Session {
+ user: SessionUser;
+}
+
+export const SessionFactory = (
+ override?: Partial
+): Session => ({
+ user: {
+ id: faker.string.hexadecimal({ length: 32, prefix: '' }),
+ email: faker.internet.email(),
+ metricsEnabled: true,
+ ...override,
+ },
+});
diff --git a/libs/payments/ui-auth/src/testing.ts b/libs/payments/ui-auth/src/testing.ts
new file mode 100644
index 00000000000..1bbcfd7fdf5
--- /dev/null
+++ b/libs/payments/ui-auth/src/testing.ts
@@ -0,0 +1,5 @@
+/* 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/. */
+
+export { SessionFactory } from './lib/session.factory';
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