diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/en.ftl b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/en.ftl index a670a9f120c..5bd8cbc4cc3 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/en.ftl +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/en.ftl @@ -2,3 +2,6 @@ next-payment-error-manage-subscription-button = Manage my subscription next-iap-upgrade-contact-support = You can still get this product — please contact support so we can help you. next-payment-error-retry-button = Try again next-basic-error-message = Something went wrong. Please try again later. +checkout-error-contact-support-button = Contact Support +checkout-error-not-eligible = You are not eligible to subscribe to this product - please contact support so we can help you. +checkout-error-contact-support = Please contact support so we can help you. diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx index 5af6d24f0f2..6355843cbf5 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx @@ -19,17 +19,39 @@ import { recordEmitterEventAction, } from '@fxa/payments/ui/actions'; import { CartErrorReasonId } from '@fxa/shared/db/mysql/account'; +import { config } from 'apps/payments/next/config'; // forces dynamic rendering // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config export const dynamic = 'force-dynamic'; -const getErrorReason = (reason: CartErrorReasonId | null) => { +const getErrorReason = ( + reason: CartErrorReasonId | null, + params: CheckoutParams +) => { switch (reason) { + case 'cart_eligibility_status_downgrade': + return { + buttonFtl: 'checkout-error-contact-support-button', + buttonLabel: 'Contact Support', + buttonUrl: config.supportUrl, + message: 'Please contact support so we can help you.', + messageFtl: 'checkout-error-contact-support', + }; + case 'cart_eligibility_status_invalid': + return { + buttonFtl: 'checkout-error-contact-support-button', + buttonLabel: 'Contact Support', + buttonUrl: config.supportUrl, + message: + 'You are not eligible to subscribe to this product - please contact support so we can help you.', + messageFtl: 'checkout-error-not-eligible', + }; case 'iap_upgrade_contact_support': return { buttonFtl: 'next-payment-error-manage-subscription-button', buttonLabel: 'Manage my subscription', + buttonUrl: `/${params.offeringId}/${params.interval}/landing`, message: 'You can still get this product — please contact support so we can help you.', messageFtl: 'next-iap-upgrade-contact-support', @@ -38,6 +60,7 @@ const getErrorReason = (reason: CartErrorReasonId | null) => { return { buttonFtl: 'next-payment-error-retry-button', buttonLabel: 'Try again', + buttonUrl: `/${params.offeringId}/${params.interval}/landing`, message: 'Something went wrong. Please try again later.', messageFtl: 'next-basic-error-message', }; @@ -74,7 +97,7 @@ export default async function CheckoutError({ cart.paymentInfo?.type ); - const errorReason = getErrorReason(cart.errorReasonId); + const errorReason = getErrorReason(cart.errorReasonId, params); return ( <> @@ -87,12 +110,14 @@ export default async function CheckoutError({ {l10n.getString(errorReason.messageFtl, errorReason.message)}
- - {l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)} - + {errorReason.buttonUrl && ( + + {l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)} + + )} > ); diff --git a/apps/payments/next/config/index.ts b/apps/payments/next/config/index.ts index 538ab9d44dd..458b0543c44 100644 --- a/apps/payments/next/config/index.ts +++ b/apps/payments/next/config/index.ts @@ -133,6 +133,9 @@ export class PaymentsNextConfig extends NestAppRootConfig { @IsString() subscriptionsUnsupportedLocations!: string; + @IsUrl({ require_tld: false }) + supportUrl!: string; + /** * Nextjs Public Environment Variables */ diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 33a7056ac1a..c065b1d6f55 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -311,6 +311,74 @@ describe('CartService', () => { expect(cartManager.createCart).not.toHaveBeenCalled(); }); + + it('returns cart eligibility status downgrade', async () => { + const mockResultCart = ResultCartFactory(); + const mockResolvedCurrency = faker.finance.currencyCode(); + + jest + .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') + .mockResolvedValue(undefined); + jest + .spyOn(currencyManager, 'getCurrencyForCountry') + .mockReturnValue(mockResolvedCurrency); + jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart); + jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]); + jest + .spyOn(eligibilityService, 'checkEligibility') + .mockResolvedValue(EligibilityStatus.DOWNGRADE); + jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue(); + + const result = await cartService.setupCart(args); + + expect(cartManager.createCart).toHaveBeenCalledWith({ + interval: args.interval, + offeringConfigId: args.offeringConfigId, + amount: mockInvoicePreview.subtotal, + uid: args.uid, + stripeCustomerId: mockAccountCustomer.stripeCustomerId, + experiment: args.experiment, + taxAddress, + currency: mockResolvedCurrency, + eligibilityStatus: CartEligibilityStatus.DOWNGRADE, + couponCode: args.promoCode, + }); + expect(result).toEqual(mockResultCart); + }); + + it('returns cart eligibility status invalid', async () => { + const mockResultCart = ResultCartFactory(); + const mockResolvedCurrency = faker.finance.currencyCode(); + + jest + .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') + .mockResolvedValue(undefined); + jest + .spyOn(currencyManager, 'getCurrencyForCountry') + .mockReturnValue(mockResolvedCurrency); + jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart); + jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]); + jest + .spyOn(eligibilityService, 'checkEligibility') + .mockResolvedValue(EligibilityStatus.INVALID); + jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue(); + + const result = await cartService.setupCart(args); + + expect(cartManager.createCart).toHaveBeenCalledWith({ + interval: args.interval, + offeringConfigId: args.offeringConfigId, + amount: mockInvoicePreview.subtotal, + uid: args.uid, + stripeCustomerId: mockAccountCustomer.stripeCustomerId, + experiment: args.experiment, + taxAddress, + currency: mockResolvedCurrency, + eligibilityStatus: CartEligibilityStatus.INVALID, + couponCode: args.promoCode, + }); + expect(result).toEqual(mockResultCart); + }); }); describe('restartCart', () => { @@ -720,7 +788,9 @@ describe('CartService', () => { }); it('returns cart and upcomingInvoicePreview', async () => { - const mockCart = ResultCartFactory({ stripeSubscriptionId: null }); + const mockCart = ResultCartFactory({ + stripeSubscriptionId: null, + }); const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockPrice = StripePriceFactory(); const mockInvoicePreview = InvoicePreviewFactory(); diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index 01c86d6139d..1f412fa3cd4 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -27,7 +27,11 @@ import { } from '@fxa/payments/stripe'; import { ProductConfigurationManager } from '@fxa/shared/cms'; import { CurrencyManager } from '@fxa/payments/currency'; -import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account'; +import { + CartEligibilityStatus, + CartErrorReasonId, + CartState, +} from '@fxa/shared/db/mysql/account'; import { GeoDBManager } from '@fxa/shared/geodb'; import { CartManager } from './cart.manager'; @@ -218,6 +222,20 @@ export class CartService { couponCode: args.promoCode, }); + if (cartEligibilityStatus === CartEligibilityStatus.INVALID) { + await this.finalizeCartWithError( + cart.id, + CartErrorReasonId.CartEligibilityStatusInvalid + ); + } + + if (cartEligibilityStatus === CartEligibilityStatus.DOWNGRADE) { + await this.finalizeCartWithError( + cart.id, + CartErrorReasonId.CartEligibilityStatusDowngrade + ); + } + return cart; } diff --git a/libs/payments/cart/src/lib/cart.utils.ts b/libs/payments/cart/src/lib/cart.utils.ts index 788c945d75a..1fab98e906c 100644 --- a/libs/payments/cart/src/lib/cart.utils.ts +++ b/libs/payments/cart/src/lib/cart.utils.ts @@ -34,7 +34,7 @@ export const cartEligibilityDetailsMap: Record< [EligibilityStatus.DOWNGRADE]: { eligibilityStatus: CartEligibilityStatus.DOWNGRADE, state: CartState.FAIL, - errorReasonId: CartErrorReasonId.BASIC_ERROR, + errorReasonId: CartErrorReasonId.CartEligibilityStatusDowngrade, }, [EligibilityStatus.BLOCKED_IAP]: { eligibilityStatus: CartEligibilityStatus.BLOCKED_IAP, @@ -44,7 +44,7 @@ export const cartEligibilityDetailsMap: Record< [EligibilityStatus.INVALID]: { eligibilityStatus: CartEligibilityStatus.INVALID, state: CartState.FAIL, - errorReasonId: CartErrorReasonId.Unknown, + errorReasonId: CartErrorReasonId.CartEligibilityStatusInvalid, }, }; diff --git a/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts b/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts index 7f1ddf14604..a6686c81f36 100644 --- a/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts +++ b/libs/payments/eligibility/src/lib/eligibility.manager.spec.ts @@ -13,9 +13,13 @@ import { StripePriceRecurringFactory, } from '@fxa/payments/stripe'; import { + EligibilityContentByOfferingResultFactory, + EligibilityContentByOfferingResultUtil, EligibilityContentByPlanIdsResultFactory, EligibilityContentByPlanIdsResultUtil, EligibilityContentOfferingResultFactory, + EligibilityContentSubgroupOfferingResultFactory, + EligibilityContentSubgroupResultFactory, EligibilityOfferingResultFactory, EligibilitySubgroupOfferingResultFactory, EligibilitySubgroupResultFactory, @@ -66,10 +70,24 @@ describe('EligibilityManager', () => { ) ); - const result = await manager.getOfferingOverlap( - [faker.string.uuid()], - faker.string.uuid() - ); + const result = await manager.getOfferingOverlap({ + priceIds: [faker.string.uuid()], + targetPriceId: faker.string.uuid(), + }); + expect(result).toHaveLength(0); + }); + + it('should return empty result when providedTargetOffering or targetPriceId not provided', async () => { + const eligibilityContentByPlanIdsResultUtil = + new EligibilityContentByPlanIdsResultUtil({ purchases: [] }); + + jest + .spyOn(productConfigurationManager, 'getPurchaseDetailsForEligibility') + .mockResolvedValue(eligibilityContentByPlanIdsResultUtil); + + const result = await manager.getOfferingOverlap({ + priceIds: [faker.string.uuid()], + }); expect(result).toHaveLength(0); }); @@ -87,10 +105,10 @@ describe('EligibilityManager', () => { .spyOn(eligibilityContentByPlanIdsResultUtil, 'offeringForPlanId') .mockReturnValueOnce(targetOffering); - const result = await manager.getOfferingOverlap( - [faker.string.uuid()], - faker.string.uuid() - ); + const result = await manager.getOfferingOverlap({ + priceIds: [faker.string.uuid()], + targetPriceId: faker.string.uuid(), + }); expect(result).toHaveLength(0); }); @@ -137,10 +155,10 @@ describe('EligibilityManager', () => { .mockReturnValueOnce(targetOffering) .mockReturnValueOnce(fromOffering); - const result = await manager.getOfferingOverlap( - [faker.string.uuid()], - faker.string.uuid() - ); + const result = await manager.getOfferingOverlap({ + priceIds: [faker.string.uuid()], + targetPriceId: faker.string.uuid(), + }); expect(result).toHaveLength(0); }); @@ -164,7 +182,50 @@ describe('EligibilityManager', () => { .mockReturnValueOnce(fromOffering); const priceId = faker.string.uuid(); - const result = await manager.getOfferingOverlap([priceId], priceId); + const result = await manager.getOfferingOverlap({ + priceIds: [priceId], + targetPriceId: priceId, + }); + expect( + productConfigurationManager.getPurchaseDetailsForEligibility + ).toHaveBeenCalledWith([priceId]); + expect(result.length).toBe(1); + expect(result[0].comparison).toBe(OfferingComparison.SAME); + }); + + it('should return same comparison for same price from offeringConfigId', async () => { + const targetOfferingId = faker.string.uuid(); + const fromOfferingId = targetOfferingId; + const targetOffering = EligibilityContentOfferingResultFactory({ + apiIdentifier: targetOfferingId, + }); + const fromOffering = EligibilityOfferingResultFactory({ + apiIdentifier: fromOfferingId, + }); + const eligibilityContentByPlanIdsResultUtil = + new EligibilityContentByPlanIdsResultUtil({ purchases: [] }); + + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue( + new EligibilityContentByOfferingResultUtil( + EligibilityContentByOfferingResultFactory({ + offerings: [targetOffering], + }) + ) + ); + jest + .spyOn(productConfigurationManager, 'getPurchaseDetailsForEligibility') + .mockResolvedValue(eligibilityContentByPlanIdsResultUtil); + jest + .spyOn(eligibilityContentByPlanIdsResultUtil, 'offeringForPlanId') + .mockReturnValueOnce(fromOffering); + + const priceId = faker.string.uuid(); + const result = await manager.getOfferingOverlap({ + priceIds: [priceId], + providedTargetOffering: targetOffering, + }); expect( productConfigurationManager.getPurchaseDetailsForEligibility ).toHaveBeenCalledWith([priceId]); @@ -205,10 +266,10 @@ describe('EligibilityManager', () => { const targetPriceId = faker.string.uuid(); const fromPriceId = faker.string.uuid(); - const result = await manager.getOfferingOverlap( - [fromPriceId], - targetPriceId - ); + const result = await manager.getOfferingOverlap({ + priceIds: [fromPriceId], + targetPriceId, + }); expect( productConfigurationManager.getPurchaseDetailsForEligibility ).toHaveBeenCalledWith([fromPriceId, targetPriceId]); @@ -216,6 +277,58 @@ describe('EligibilityManager', () => { expect(result[0].comparison).toBe(OfferingComparison.UPGRADE); }); + it('should return upgrade comparison for upgrade - targetOffering', async () => { + const targetOfferingId = faker.string.uuid(); + const fromOfferingId = faker.string.uuid(); + const targetOffering = EligibilityContentOfferingResultFactory({ + apiIdentifier: targetOfferingId, + subGroups: [ + EligibilityContentSubgroupResultFactory({ + offerings: [ + EligibilityContentSubgroupOfferingResultFactory({ + apiIdentifier: fromOfferingId, + }), + EligibilityContentSubgroupOfferingResultFactory({ + apiIdentifier: targetOfferingId, + }), + ], + }), + ], + }); + const fromOffering = EligibilityOfferingResultFactory({ + apiIdentifier: fromOfferingId, + }); + const eligibilityContentByPlanIdsResultUtil = + new EligibilityContentByPlanIdsResultUtil({ purchases: [] }); + + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue( + new EligibilityContentByOfferingResultUtil( + EligibilityContentByOfferingResultFactory({ + offerings: [targetOffering], + }) + ) + ); + jest + .spyOn(productConfigurationManager, 'getPurchaseDetailsForEligibility') + .mockResolvedValue(eligibilityContentByPlanIdsResultUtil); + jest + .spyOn(eligibilityContentByPlanIdsResultUtil, 'offeringForPlanId') + .mockReturnValueOnce(fromOffering); + + const fromPriceId = faker.string.uuid(); + const result = await manager.getOfferingOverlap({ + priceIds: [fromPriceId], + providedTargetOffering: targetOffering, + }); + expect( + productConfigurationManager.getPurchaseDetailsForEligibility + ).toHaveBeenCalledWith([fromPriceId]); + expect(result.length).toBe(1); + expect(result[0].comparison).toBe(OfferingComparison.UPGRADE); + }); + it('should return multiple comparisons in multiple subgroups', async () => { const targetOfferingId = faker.string.uuid(); const fromOfferingId1 = faker.string.uuid(); @@ -268,10 +381,10 @@ describe('EligibilityManager', () => { const targetPriceId = faker.string.uuid(); const fromPriceId1 = faker.string.uuid(); const fromPriceId2 = faker.string.uuid(); - const result = await manager.getOfferingOverlap( - [fromPriceId1, fromPriceId2], - targetPriceId - ); + const result = await manager.getOfferingOverlap({ + priceIds: [fromPriceId1, fromPriceId2], + targetPriceId, + }); expect( productConfigurationManager.getPurchaseDetailsForEligibility ).toHaveBeenCalledWith([fromPriceId1, fromPriceId2, targetPriceId]); diff --git a/libs/payments/eligibility/src/lib/eligibility.manager.ts b/libs/payments/eligibility/src/lib/eligibility.manager.ts index 709e831eac0..8c9dcac0e25 100644 --- a/libs/payments/eligibility/src/lib/eligibility.manager.ts +++ b/libs/payments/eligibility/src/lib/eligibility.manager.ts @@ -33,20 +33,34 @@ export class EligibilityManager { * @returns Array of overlapping priceIds/offeringProductIds and their comparison * to the target price. */ - async getOfferingOverlap( - priceIds: string[], - targetPriceId: string - ): Promise