Skip to content

Commit

Permalink
Merge pull request #18322 from mozilla/FXA-10994
Browse files Browse the repository at this point in the history
feat(payments): Do not allow duplicate subscriptions to the same offering and interval
  • Loading branch information
xlisachan authored Feb 7, 2025
2 parents 93debd9 + e1603bd commit 818d7d7
Show file tree
Hide file tree
Showing 14 changed files with 343 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
};
Expand Down Expand Up @@ -74,7 +97,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 +110,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
3 changes: 3 additions & 0 deletions apps/payments/next/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ export class PaymentsNextConfig extends NestAppRootConfig {
@IsString()
subscriptionsUnsupportedLocations!: string;

@IsUrl({ require_tld: false })
supportUrl!: string;

/**
* Nextjs Public Environment Variables
*/
Expand Down
72 changes: 71 additions & 1 deletion libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 19 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 @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions libs/payments/cart/src/lib/cart.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,7 +44,7 @@ export const cartEligibilityDetailsMap: Record<
[EligibilityStatus.INVALID]: {
eligibilityStatus: CartEligibilityStatus.INVALID,
state: CartState.FAIL,
errorReasonId: CartErrorReasonId.Unknown,
errorReasonId: CartErrorReasonId.CartEligibilityStatusInvalid,
},
};

Expand Down
Loading

0 comments on commit 818d7d7

Please sign in to comment.