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 7, 2025
1 parent 1463f62 commit e1603bd
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 e1603bd

Please sign in to comment.