From e1603bd304697f4a48cdfbe9c6a3aad8e0dac7fa Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Wed, 5 Feb 2025 15:10:07 -0500 Subject: [PATCH] feat(payments): Do not allow duplicate subscriptions to the same offering and interval --- .../[interval]/checkout/[cartId]/error/en.ftl | 3 + .../checkout/[cartId]/error/page.tsx | 41 ++++- apps/payments/next/config/index.ts | 3 + .../cart/src/lib/cart.service.spec.ts | 72 +++++++- libs/payments/cart/src/lib/cart.service.ts | 20 ++- libs/payments/cart/src/lib/cart.utils.ts | 4 +- .../src/lib/eligibility.manager.spec.ts | 155 +++++++++++++++--- .../src/lib/eligibility.manager.ts | 26 ++- .../src/lib/eligibility.service.spec.ts | 8 +- .../src/lib/eligibility.service.ts | 6 +- libs/payments/ui/src/index.ts | 1 + .../db/mysql/account/src/lib/kysely-types.ts | 2 + .../lib/payments/capability.ts | 16 +- .../test/local/payments/capability.js | 40 +++++ 14 files changed, 343 insertions(+), 54 deletions(-) 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 { + async getOfferingOverlap({ + priceIds, + targetPriceId, + providedTargetOffering, + }: { + priceIds: string[]; + targetPriceId?: string; + providedTargetOffering?: EligibilityContentOfferingResult; + }): Promise { if (!priceIds.length) return []; + if (!targetPriceId && !providedTargetOffering) return []; + + const ids = targetPriceId ? [...priceIds, targetPriceId] : [...priceIds]; const detailsResult = await this.productConfigurationManager.getPurchaseDetailsForEligibility( - Array.from(new Set([...priceIds, targetPriceId])) + Array.from(new Set(ids)) ); const result: OfferingOverlapResult[] = []; - const targetOffering = detailsResult.offeringForPlanId(targetPriceId); + let targetOffering; + if (providedTargetOffering) { + targetOffering = providedTargetOffering; + } + if (targetPriceId) { + targetOffering = detailsResult.offeringForPlanId(targetPriceId); + } if (!targetOffering) return []; for (const priceId of priceIds) { diff --git a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts index 2b936d2fd9a..56a4ffabd9f 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts @@ -140,10 +140,10 @@ describe('EligibilityService', () => { expect(subscriptionManager.listForCustomer).toHaveBeenCalledWith( mockCustomer.id ); - expect(eligibilityManager.getOfferingOverlap).toHaveBeenCalledWith( - [], - mockOffering.apiIdentifier - ); + expect(eligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ + priceIds: [], + providedTargetOffering: mockOffering, + }); expect(eligibilityManager.compareOverlap).toHaveBeenCalledWith( mockOverlapResult, mockOffering, diff --git a/libs/payments/eligibility/src/lib/eligibility.service.ts b/libs/payments/eligibility/src/lib/eligibility.service.ts index 5e8fde9a311..e47ce7f0027 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.ts @@ -45,10 +45,10 @@ export class EligibilityService { const priceIds = subscribedPrices.map((price) => price.id); - const overlaps = await this.eligibilityManager.getOfferingOverlap( + const overlaps = await this.eligibilityManager.getOfferingOverlap({ priceIds, - offeringConfigId - ); + providedTargetOffering: targetOffering, + }); const eligibility = await this.eligibilityManager.compareOverlap( overlaps, diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts index 32c0262cf8b..b0c8206166b 100644 --- a/libs/payments/ui/src/index.ts +++ b/libs/payments/ui/src/index.ts @@ -20,6 +20,7 @@ export * from './lib/client/components/LoadingSpinner'; export * from './lib/client/components/MetricsWrapper'; export * from './lib/client/components/StripeWrapper'; export * from './lib/client/providers/Providers'; +export * from './lib/constants'; export * from './lib/utils/helpers'; export * from './lib/utils/types'; export * from './lib/utils/get-cart'; diff --git a/libs/shared/db/mysql/account/src/lib/kysely-types.ts b/libs/shared/db/mysql/account/src/lib/kysely-types.ts index f44cc756825..d765f679709 100644 --- a/libs/shared/db/mysql/account/src/lib/kysely-types.ts +++ b/libs/shared/db/mysql/account/src/lib/kysely-types.ts @@ -34,6 +34,8 @@ export enum CartErrorReasonId { BASIC_ERROR = 'basic-error-message', IAP_UPGRADE_CONTACT_SUPPORT = 'iap_upgrade_contact_support', SUCCESS_CART_MISSING_REQUIRED = 'success_cart_missing_required', + CartEligibilityStatusDowngrade = 'cart_eligibility_status_downgrade', + CartEligibilityStatusInvalid = 'cart_eligibility_status_invalid', Unknown = 'unknown', } diff --git a/packages/fxa-auth-server/lib/payments/capability.ts b/packages/fxa-auth-server/lib/payments/capability.ts index d9cc0517031..8617809332a 100644 --- a/packages/fxa-auth-server/lib/payments/capability.ts +++ b/packages/fxa-auth-server/lib/payments/capability.ts @@ -441,15 +441,15 @@ export class CapabilityService { subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID, }; const stripePlanIds = stripeSubscribedPlans.map((p) => p.plan_id); - const stripeOverlaps = await this.eligibilityManager.getOfferingOverlap( - stripePlanIds, - targetPlan.plan_id - ); + const stripeOverlaps = await this.eligibilityManager.getOfferingOverlap({ + priceIds: stripePlanIds, + targetPriceId: targetPlan.plan_id, + }); const iapPlanIds = iapSubscribedPlans.map((p) => p.plan_id); - const iapOverlaps = await this.eligibilityManager.getOfferingOverlap( - iapPlanIds, - targetPlan.plan_id - ); + const iapOverlaps = await this.eligibilityManager.getOfferingOverlap({ + priceIds: iapPlanIds, + targetPriceId: targetPlan.plan_id, + }); const overlaps = [...stripeOverlaps, ...iapOverlaps]; // No overlap, we can create a new subscription diff --git a/packages/fxa-auth-server/test/local/payments/capability.js b/packages/fxa-auth-server/test/local/payments/capability.js index 4c7cf124170..065df06ff2e 100644 --- a/packages/fxa-auth-server/test/local/payments/capability.js +++ b/packages/fxa-auth-server/test/local/payments/capability.js @@ -645,6 +645,10 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.BLOCKED_IAP, eligibleSourcePlan: mockPlanTier1ShortInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier1LongInterval.plan_id, + }); }); it('returns create for targetPlan with offering user is not subscribed to', async () => { @@ -658,6 +662,10 @@ describe('CapabilityService', () => { assert.deepEqual(actual, { subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [], + targetPriceId: mockPlanTier1ShortInterval.plan_id, + }); }); it('returns upgrade for targetPlan with offering user is subscribed to a lower tier of', async () => { @@ -682,6 +690,10 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier2LongInterval.plan_id, + }); }); it('returns downgrade for targetPlan with offering user is subscribed to a higher tier of', async () => { @@ -707,6 +719,10 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.DOWNGRADE, eligibleSourcePlan: undefined, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier2LongInterval.plan_id], + targetPriceId: mockPlanTier1ShortInterval.plan_id, + }); }); it('returns upgrade for targetPlan with offering user is subscribed to a higher interval of', async () => { @@ -731,6 +747,10 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier1LongInterval.plan_id, + }); }); it('returns upgrade for targetPlan with offering user is subscribed and interval is not shorter', async () => { @@ -755,6 +775,10 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier2ShortInterval.plan_id, + }); }); it('returns upgrade for targetPlan with same offering and longer interval', async () => { @@ -779,6 +803,10 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier1LongInterval.plan_id, + }); }); it('returns downgrade for targetPlan with shorter interval but higher tier than user is subscribed to', async () => { @@ -806,6 +834,10 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.DOWNGRADE, eligibleSourcePlan: mockPlanTier1LongInterval, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1LongInterval.plan_id], + targetPriceId: mockPlanTier2ShortInterval.plan_id, + }); }); it('returns invalid for targetPlan with same offering user is subscribed to', async () => { @@ -829,6 +861,10 @@ describe('CapabilityService', () => { assert.deepEqual(actual, { subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier1ShortInterval.plan_id], + targetPriceId: mockPlanTier1ShortInterval.plan_id, + }); }); it('returns invalid for targetPlan with same offering user is subscribed to but different currency', async () => { @@ -852,6 +888,10 @@ describe('CapabilityService', () => { assert.deepEqual(actual, { subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID, }); + sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + priceIds: [mockPlanTier2LongInterval.plan_id], + targetPriceId: mockPlanTier2LongIntervalDiffCurr.plan_id, + }); }); });