Skip to content

Commit

Permalink
feat(payments): Do not allow duplicate subscriptions to the same offe…
Browse files Browse the repository at this point in the history
…ring and interval
  • Loading branch information
xlisachan committed Feb 6, 2025
1 parent b9534ab commit 9676c5f
Show file tree
Hide file tree
Showing 12 changed files with 158 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ 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.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { headers } from 'next/headers';
import Image from 'next/image';
import Link from 'next/link';

import { SUPPORT_URL } from '@fxa/payments/ui';
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';

import errorIcon from '@fxa/shared/assets/images/error.svg';
Expand All @@ -24,12 +25,25 @@ import { CartErrorReasonId } from '@fxa/shared/db/mysql/account';
// 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_invalid':
return {
buttonFtl: 'checkout-error-contact-support-button',
buttonLabel: 'Contact Support',
buttonUrl: SUPPORT_URL,
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',
Expand All @@ -38,6 +52,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',
};
Expand Down Expand Up @@ -74,7 +89,7 @@ export default async function CheckoutError({
cart.paymentInfo?.type
);

const errorReason = getErrorReason(cart.errorReasonId);
const errorReason = getErrorReason(cart.errorReasonId, params);

return (
<>
Expand All @@ -87,12 +102,14 @@ export default async function CheckoutError({
{l10n.getString(errorReason.messageFtl, errorReason.message)}
</p>

<Link
className="flex items-center justify-center bg-blue-500 hover:bg-blue-700 font-semibold h-12 my-8 rounded-md text-white w-full"
href={`/${params.offeringId}/${params.interval}/landing`}
>
{l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)}
</Link>
{errorReason.buttonUrl && (
<Link
className="flex items-center justify-center bg-blue-500 hover:bg-blue-700 font-semibold h-12 my-8 rounded-md text-white w-full"
href={errorReason.buttonUrl}
>
{l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)}
</Link>
)}
</section>
</>
);
Expand Down
4 changes: 1 addition & 3 deletions apps/payments/next/app/[locale]/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Sentry from '@sentry/nextjs';
import { useRouter, useParams } from 'next/navigation';
import Image from 'next/image';
import errorIcon from '@fxa/shared/assets/images/error.svg';
import { CheckoutParams, LoadingSpinner } from '@fxa/payments/ui';
import { CheckoutParams, LoadingSpinner, SUPPORT_URL } from '@fxa/payments/ui';
import Link from 'next/link';
import { Localized } from '@fluent/react';
import { restartCartAction, getCartAction } from '@fxa/payments/ui/actions';
Expand All @@ -33,8 +33,6 @@ export default function Error({
const { locale, offeringId, interval, cartId } = useParams<ErrorParams>();
const hasProductData = locale && offeringId && interval;

const SUPPORT_URL = process.env.SUPPORT_URL ?? 'https://support.mozilla.org';

// Reset the view if the cart is in a success state, or restart the cart and redirect
async function redirectWithCart() {
setLoading(true);
Expand Down
18 changes: 17 additions & 1 deletion libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -496,6 +500,18 @@ export class CartService {
);
}

if (cart.eligibilityStatus === CartEligibilityStatus.INVALID) {
return {
...cart,
state: CartState.FAIL,
errorReasonId: CartErrorReasonId.CartEligibilityStatusInvalid,
upcomingInvoicePreview,
metricsOptedOut,
latestInvoicePreview,
paymentInfo,
};
}

if (cart.state === CartState.SUCCESS) {
assert(
latestInvoicePreview,
Expand Down
2 changes: 1 addition & 1 deletion libs/payments/cart/src/lib/cart.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const cartEligibilityDetailsMap: Record<
[EligibilityStatus.INVALID]: {
eligibilityStatus: CartEligibilityStatus.INVALID,
state: CartState.FAIL,
errorReasonId: CartErrorReasonId.Unknown,
errorReasonId: CartErrorReasonId.CartEligibilityStatusInvalid,
},
};

Expand Down
98 changes: 98 additions & 0 deletions libs/payments/eligibility/src/lib/eligibility.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import {
StripePriceRecurringFactory,
} from '@fxa/payments/stripe';
import {
EligibilityContentByOfferingResultFactory,
EligibilityContentByOfferingResultUtil,
EligibilityContentByPlanIdsResultFactory,
EligibilityContentByPlanIdsResultUtil,
EligibilityContentOfferingResultFactory,
EligibilityContentSubgroupOfferingResultFactory,
EligibilityContentSubgroupResultFactory,
EligibilityOfferingResultFactory,
EligibilitySubgroupOfferingResultFactory,
EligibilitySubgroupResultFactory,
Expand Down Expand Up @@ -172,6 +176,47 @@ describe('EligibilityManager', () => {
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(
[priceId],
'',
targetOffering
);
expect(
productConfigurationManager.getPurchaseDetailsForEligibility
).toHaveBeenCalledWith([priceId, '']);
expect(result.length).toBe(1);
expect(result[0].comparison).toBe(OfferingComparison.SAME);
});

it('should return upgrade comparison for upgrade priceId', async () => {
const targetOfferingId = faker.string.uuid();
const fromOfferingId = faker.string.uuid();
Expand Down Expand Up @@ -216,6 +261,59 @@ 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(
[fromPriceId],
'',
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();
Expand Down
7 changes: 5 additions & 2 deletions libs/payments/eligibility/src/lib/eligibility.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export class EligibilityManager {
*/
async getOfferingOverlap(
priceIds: string[],
targetPriceId: string
targetPriceId: string,
providedTargetOffering?: EligibilityContentOfferingResult
): Promise<OfferingOverlapResult[]> {
if (!priceIds.length) return [];

Expand All @@ -46,7 +47,9 @@ export class EligibilityManager {

const result: OfferingOverlapResult[] = [];

const targetOffering = detailsResult.offeringForPlanId(targetPriceId);
const targetOffering = providedTargetOffering
? providedTargetOffering
: detailsResult.offeringForPlanId(targetPriceId);
if (!targetOffering) return [];

for (const priceId of priceIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ describe('EligibilityService', () => {
);
expect(eligibilityManager.getOfferingOverlap).toHaveBeenCalledWith(
[],
mockOffering.apiIdentifier
'',
mockOffering
);
expect(eligibilityManager.compareOverlap).toHaveBeenCalledWith(
mockOverlapResult,
Expand Down
3 changes: 2 additions & 1 deletion libs/payments/eligibility/src/lib/eligibility.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class EligibilityService {

const overlaps = await this.eligibilityManager.getOfferingOverlap(
priceIds,
offeringConfigId
'',
targetOffering
);

const eligibility = await this.eligibilityManager.compareOverlap(
Expand Down
1 change: 1 addition & 0 deletions libs/payments/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions libs/payments/ui/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ export const OFFERING_LINKS = {
pocket:
'https://app.adjust.com/hr2n0yz?redirect_macos=https%3A%2F%2Fgetpocket.com%2Fpocket-and-firefox&redirect_windows=https%3A%2F%2Fgetpocket.com%2Fpocket-and-firefox&engagement_type=fallback_click&fallback=https%3A%2F%2Fgetpocket.com%2Ffirefox_learnmore%3Fsrc%3Dff_bento&fallback_lp=https%3A%2F%2Fapps.apple.com%2Fapp%2Fpocket-save-read-grow%2Fid309601447',
};

export const SUPPORT_URL =
process.env.SUPPORT_URL ?? 'https://support.mozilla.org';
1 change: 1 addition & 0 deletions libs/shared/db/mysql/account/src/lib/kysely-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export enum CartErrorReasonId {
BASIC_ERROR = 'basic-error-message',
IAP_UPGRADE_CONTACT_SUPPORT = 'iap_upgrade_contact_support',
SUCCESS_CART_MISSING_REQUIRED = 'success_cart_missing_required',
CartEligibilityStatusInvalid = 'cart_eligibility_status_invalid',
Unknown = 'unknown',
}

Expand Down

0 comments on commit 9676c5f

Please sign in to comment.