Skip to content

Commit e1603bd

Browse files
committed
feat(payments): Do not allow duplicate subscriptions to the same offering and interval
1 parent 1463f62 commit e1603bd

14 files changed

Lines changed: 343 additions & 54 deletions

File tree

apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/en.ftl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@ next-payment-error-manage-subscription-button = Manage my subscription
22
next-iap-upgrade-contact-support = You can still get this product — please contact support so we can help you.
33
next-payment-error-retry-button = Try again
44
next-basic-error-message = Something went wrong. Please try again later.
5+
checkout-error-contact-support-button = Contact Support
6+
checkout-error-not-eligible = You are not eligible to subscribe to this product - please contact support so we can help you.
7+
checkout-error-contact-support = Please contact support so we can help you.

apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,39 @@ import {
1919
recordEmitterEventAction,
2020
} from '@fxa/payments/ui/actions';
2121
import { CartErrorReasonId } from '@fxa/shared/db/mysql/account';
22+
import { config } from 'apps/payments/next/config';
2223

2324
// forces dynamic rendering
2425
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
2526
export const dynamic = 'force-dynamic';
2627

27-
const getErrorReason = (reason: CartErrorReasonId | null) => {
28+
const getErrorReason = (
29+
reason: CartErrorReasonId | null,
30+
params: CheckoutParams
31+
) => {
2832
switch (reason) {
33+
case 'cart_eligibility_status_downgrade':
34+
return {
35+
buttonFtl: 'checkout-error-contact-support-button',
36+
buttonLabel: 'Contact Support',
37+
buttonUrl: config.supportUrl,
38+
message: 'Please contact support so we can help you.',
39+
messageFtl: 'checkout-error-contact-support',
40+
};
41+
case 'cart_eligibility_status_invalid':
42+
return {
43+
buttonFtl: 'checkout-error-contact-support-button',
44+
buttonLabel: 'Contact Support',
45+
buttonUrl: config.supportUrl,
46+
message:
47+
'You are not eligible to subscribe to this product - please contact support so we can help you.',
48+
messageFtl: 'checkout-error-not-eligible',
49+
};
2950
case 'iap_upgrade_contact_support':
3051
return {
3152
buttonFtl: 'next-payment-error-manage-subscription-button',
3253
buttonLabel: 'Manage my subscription',
54+
buttonUrl: `/${params.offeringId}/${params.interval}/landing`,
3355
message:
3456
'You can still get this product — please contact support so we can help you.',
3557
messageFtl: 'next-iap-upgrade-contact-support',
@@ -38,6 +60,7 @@ const getErrorReason = (reason: CartErrorReasonId | null) => {
3860
return {
3961
buttonFtl: 'next-payment-error-retry-button',
4062
buttonLabel: 'Try again',
63+
buttonUrl: `/${params.offeringId}/${params.interval}/landing`,
4164
message: 'Something went wrong. Please try again later.',
4265
messageFtl: 'next-basic-error-message',
4366
};
@@ -74,7 +97,7 @@ export default async function CheckoutError({
7497
cart.paymentInfo?.type
7598
);
7699

77-
const errorReason = getErrorReason(cart.errorReasonId);
100+
const errorReason = getErrorReason(cart.errorReasonId, params);
78101

79102
return (
80103
<>
@@ -87,12 +110,14 @@ export default async function CheckoutError({
87110
{l10n.getString(errorReason.messageFtl, errorReason.message)}
88111
</p>
89112

90-
<Link
91-
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"
92-
href={`/${params.offeringId}/${params.interval}/landing`}
93-
>
94-
{l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)}
95-
</Link>
113+
{errorReason.buttonUrl && (
114+
<Link
115+
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"
116+
href={errorReason.buttonUrl}
117+
>
118+
{l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)}
119+
</Link>
120+
)}
96121
</section>
97122
</>
98123
);

apps/payments/next/config/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ export class PaymentsNextConfig extends NestAppRootConfig {
133133
@IsString()
134134
subscriptionsUnsupportedLocations!: string;
135135

136+
@IsUrl({ require_tld: false })
137+
supportUrl!: string;
138+
136139
/**
137140
* Nextjs Public Environment Variables
138141
*/

libs/payments/cart/src/lib/cart.service.spec.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,74 @@ describe('CartService', () => {
311311

312312
expect(cartManager.createCart).not.toHaveBeenCalled();
313313
});
314+
315+
it('returns cart eligibility status downgrade', async () => {
316+
const mockResultCart = ResultCartFactory();
317+
const mockResolvedCurrency = faker.finance.currencyCode();
318+
319+
jest
320+
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
321+
.mockResolvedValue(undefined);
322+
jest
323+
.spyOn(currencyManager, 'getCurrencyForCountry')
324+
.mockReturnValue(mockResolvedCurrency);
325+
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
326+
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
327+
jest
328+
.spyOn(eligibilityService, 'checkEligibility')
329+
.mockResolvedValue(EligibilityStatus.DOWNGRADE);
330+
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
331+
332+
const result = await cartService.setupCart(args);
333+
334+
expect(cartManager.createCart).toHaveBeenCalledWith({
335+
interval: args.interval,
336+
offeringConfigId: args.offeringConfigId,
337+
amount: mockInvoicePreview.subtotal,
338+
uid: args.uid,
339+
stripeCustomerId: mockAccountCustomer.stripeCustomerId,
340+
experiment: args.experiment,
341+
taxAddress,
342+
currency: mockResolvedCurrency,
343+
eligibilityStatus: CartEligibilityStatus.DOWNGRADE,
344+
couponCode: args.promoCode,
345+
});
346+
expect(result).toEqual(mockResultCart);
347+
});
348+
349+
it('returns cart eligibility status invalid', async () => {
350+
const mockResultCart = ResultCartFactory();
351+
const mockResolvedCurrency = faker.finance.currencyCode();
352+
353+
jest
354+
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
355+
.mockResolvedValue(undefined);
356+
jest
357+
.spyOn(currencyManager, 'getCurrencyForCountry')
358+
.mockReturnValue(mockResolvedCurrency);
359+
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
360+
jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
361+
jest
362+
.spyOn(eligibilityService, 'checkEligibility')
363+
.mockResolvedValue(EligibilityStatus.INVALID);
364+
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
365+
366+
const result = await cartService.setupCart(args);
367+
368+
expect(cartManager.createCart).toHaveBeenCalledWith({
369+
interval: args.interval,
370+
offeringConfigId: args.offeringConfigId,
371+
amount: mockInvoicePreview.subtotal,
372+
uid: args.uid,
373+
stripeCustomerId: mockAccountCustomer.stripeCustomerId,
374+
experiment: args.experiment,
375+
taxAddress,
376+
currency: mockResolvedCurrency,
377+
eligibilityStatus: CartEligibilityStatus.INVALID,
378+
couponCode: args.promoCode,
379+
});
380+
expect(result).toEqual(mockResultCart);
381+
});
314382
});
315383

316384
describe('restartCart', () => {
@@ -720,7 +788,9 @@ describe('CartService', () => {
720788
});
721789

722790
it('returns cart and upcomingInvoicePreview', async () => {
723-
const mockCart = ResultCartFactory({ stripeSubscriptionId: null });
791+
const mockCart = ResultCartFactory({
792+
stripeSubscriptionId: null,
793+
});
724794
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
725795
const mockPrice = StripePriceFactory();
726796
const mockInvoicePreview = InvoicePreviewFactory();

libs/payments/cart/src/lib/cart.service.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
} from '@fxa/payments/stripe';
2828
import { ProductConfigurationManager } from '@fxa/shared/cms';
2929
import { CurrencyManager } from '@fxa/payments/currency';
30-
import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account';
30+
import {
31+
CartEligibilityStatus,
32+
CartErrorReasonId,
33+
CartState,
34+
} from '@fxa/shared/db/mysql/account';
3135
import { GeoDBManager } from '@fxa/shared/geodb';
3236

3337
import { CartManager } from './cart.manager';
@@ -218,6 +222,20 @@ export class CartService {
218222
couponCode: args.promoCode,
219223
});
220224

225+
if (cartEligibilityStatus === CartEligibilityStatus.INVALID) {
226+
await this.finalizeCartWithError(
227+
cart.id,
228+
CartErrorReasonId.CartEligibilityStatusInvalid
229+
);
230+
}
231+
232+
if (cartEligibilityStatus === CartEligibilityStatus.DOWNGRADE) {
233+
await this.finalizeCartWithError(
234+
cart.id,
235+
CartErrorReasonId.CartEligibilityStatusDowngrade
236+
);
237+
}
238+
221239
return cart;
222240
}
223241

libs/payments/cart/src/lib/cart.utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const cartEligibilityDetailsMap: Record<
3434
[EligibilityStatus.DOWNGRADE]: {
3535
eligibilityStatus: CartEligibilityStatus.DOWNGRADE,
3636
state: CartState.FAIL,
37-
errorReasonId: CartErrorReasonId.BASIC_ERROR,
37+
errorReasonId: CartErrorReasonId.CartEligibilityStatusDowngrade,
3838
},
3939
[EligibilityStatus.BLOCKED_IAP]: {
4040
eligibilityStatus: CartEligibilityStatus.BLOCKED_IAP,
@@ -44,7 +44,7 @@ export const cartEligibilityDetailsMap: Record<
4444
[EligibilityStatus.INVALID]: {
4545
eligibilityStatus: CartEligibilityStatus.INVALID,
4646
state: CartState.FAIL,
47-
errorReasonId: CartErrorReasonId.Unknown,
47+
errorReasonId: CartErrorReasonId.CartEligibilityStatusInvalid,
4848
},
4949
};
5050

0 commit comments

Comments
 (0)