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,
+ });
});
});