Skip to content

Commit 0cb4f5f

Browse files
committed
feat(payments-next): Add t&cs for free trials
1 parent fcffd9f commit 0cb4f5f

11 files changed

Lines changed: 247 additions & 54 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ export default async function CheckoutLayout({
7474
<SignedIn email={session.user.email} />
7575
</section>
7676
)}
77-
<div className="mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr]">
77+
<div
78+
className={`mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr] ${session?.user?.email ? 'mt-12 tablet:mt-0' : ''}`}
79+
>
7880
<SubscriptionTitle cart={cart} l10n={l10n} />
7981

8082
<div className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:row-start-1 tablet:row-span-3">

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ export default async function Checkout({
231231
className={clsx(
232232
'font-semibold text-grey-600 text-lg mt-10 mb-5',
233233
!session?.user?.email &&
234-
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
234+
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
235235
)}
236236
data-testid="header-prefix"
237237
>
@@ -256,7 +256,7 @@ export default async function Checkout({
256256
className={clsx(
257257
'font-semibold text-grey-600 text-start',
258258
!session?.user?.email &&
259-
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
259+
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
260260
)}
261261
>
262262
{l10n.getString(

apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export default async function UpgradeSuccessLayout({
5656
<SignedIn email={session.user.email} />
5757
</section>
5858
)}
59-
<div className="mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr]">
59+
<div
60+
className={`mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr] ${session?.user?.email ? 'mt-12 tablet:mt-0' : ''}`}
61+
>
6062
<SubscriptionTitle cart={cart} l10n={l10n} />
6163
<div className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:row-start-1 tablet:row-span-3">
6264
<PurchaseDetails

apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(startLayout)/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ export default async function UpgradeLayout({
6666
<SignedIn email={session.user.email} />
6767
</section>
6868
)}
69-
<div className="mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr]">
69+
<div
70+
className={`mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr] ${session?.user?.email ? 'mt-12 tablet:mt-0' : ''}`}
71+
>
7072
<SubscriptionTitle cart={cart} l10n={l10n} />
7173

7274
<div className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:row-start-1 tablet:row-span-3">

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

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
ProfileClient,
6161
} from '@fxa/profile/client';
6262
import {
63+
FreeTrialFactory,
6364
MockStrapiClientConfigProvider,
6465
ProductConfigurationManager,
6566
StrapiClient,
@@ -383,9 +384,7 @@ describe('CartService', () => {
383384
jest
384385
.spyOn(subscriptionManager, 'retrieve')
385386
.mockResolvedValue(mockSubscription);
386-
jest
387-
.spyOn(subscriptionManager, 'cancel')
388-
.mockRejectedValue(stripeError);
387+
jest.spyOn(subscriptionManager, 'cancel').mockRejectedValue(stripeError);
389388
jest
390389
.spyOn(paymentIntentManager, 'retrieve')
391390
.mockResolvedValue(mockPaymentIntent);
@@ -1722,6 +1721,9 @@ describe('CartService', () => {
17221721
jest
17231722
.spyOn(invoiceManager, 'preview')
17241723
.mockResolvedValue(mockLatestInvoicePreview);
1724+
jest
1725+
.spyOn(checkoutService, 'getFreeTrialEligibility')
1726+
.mockResolvedValue(null);
17251727
});
17261728

17271729
it('returns cart and upcomingInvoicePreview', async () => {
@@ -1754,6 +1756,7 @@ describe('CartService', () => {
17541756
},
17551757
metricsOptedOut: false,
17561758
hasActiveSubscriptions: true,
1759+
freeTrialEligibility: null,
17571760
});
17581761

17591762
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -1816,6 +1819,7 @@ describe('CartService', () => {
18161819
customerSessionClientSecret: mockCustomerSession.client_secret,
18171820
},
18181821
hasActiveSubscriptions: true,
1822+
freeTrialEligibility: null,
18191823
});
18201824
expect(
18211825
'latestInvoicePreview' in result && result.latestInvoicePreview
@@ -1884,6 +1888,7 @@ describe('CartService', () => {
18841888
customerSessionClientSecret: mockCustomerSession.client_secret,
18851889
},
18861890
hasActiveSubscriptions: true,
1891+
freeTrialEligibility: null,
18871892
});
18881893
expect(
18891894
'latestInvoicePreview' in result && result.latestInvoicePreview
@@ -1934,6 +1939,7 @@ describe('CartService', () => {
19341939
upcomingInvoicePreview: mockInvoicePreview,
19351940
metricsOptedOut: false,
19361941
hasActiveSubscriptions: false,
1942+
freeTrialEligibility: null,
19371943
});
19381944

19391945
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -2006,6 +2012,7 @@ describe('CartService', () => {
20062012
unitAmount: mockFromPrice.unit_amount,
20072013
},
20082014
hasActiveSubscriptions: true,
2015+
freeTrialEligibility: null,
20092016
});
20102017

20112018
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -2312,6 +2319,60 @@ describe('CartService', () => {
23122319
);
23132320
});
23142321
});
2322+
2323+
it('includes freeTrialEligibility when cart has uid and eligibility is CREATE', async () => {
2324+
const mockFreeTrial = FreeTrialFactory({ trialLengthDays: 7 });
2325+
const mockCart = ResultCartFactory({
2326+
uid: faker.string.uuid(),
2327+
state: CartState.START,
2328+
stripeSubscriptionId: null,
2329+
eligibilityStatus: CartEligibilityStatus.CREATE,
2330+
});
2331+
const mockInvoicePreview = InvoicePreviewFactory();
2332+
2333+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2334+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
2335+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
2336+
});
2337+
jest
2338+
.spyOn(invoiceManager, 'previewUpcoming')
2339+
.mockResolvedValue(mockInvoicePreview);
2340+
jest
2341+
.spyOn(checkoutService, 'getFreeTrialEligibility')
2342+
.mockResolvedValue(mockFreeTrial);
2343+
2344+
const result = await cartService.getCart(mockCart.id);
2345+
expect(result.freeTrialEligibility).toEqual(mockFreeTrial);
2346+
expect(checkoutService.getFreeTrialEligibility).toHaveBeenCalledWith({
2347+
uid: mockCart.uid,
2348+
offeringConfigId: mockCart.offeringConfigId,
2349+
countryCode: mockCart.taxAddress?.countryCode,
2350+
interval: mockCart.interval,
2351+
eligibilityStatus: EligibilityStatus.CREATE,
2352+
});
2353+
});
2354+
2355+
it('sets freeTrialEligibility to null when cart has no uid', async () => {
2356+
const mockCart = ResultCartFactory({
2357+
uid: undefined,
2358+
stripeCustomerId: null,
2359+
stripeSubscriptionId: null,
2360+
eligibilityStatus: CartEligibilityStatus.CREATE,
2361+
});
2362+
const mockInvoicePreview = InvoicePreviewFactory();
2363+
2364+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2365+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
2366+
subscriptionEligibilityResult: EligibilityStatus.CREATE,
2367+
});
2368+
jest
2369+
.spyOn(invoiceManager, 'previewUpcoming')
2370+
.mockResolvedValue(mockInvoicePreview);
2371+
2372+
const result = await cartService.getCart(mockCart.id);
2373+
expect(result.freeTrialEligibility).toBeNull();
2374+
expect(checkoutService.getFreeTrialEligibility).not.toHaveBeenCalled();
2375+
});
23152376
});
23162377

23172378
describe('metricsOptedOut', () => {

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ export class CartService {
288288
} catch (e) {
289289
if (
290290
e.code === 'resource_missing' ||
291-
e.message?.startsWith('No such subscription')
291+
e.message?.startsWith('No such subscription')
292292
) {
293293
this.log.log(
294294
'cartService.wrapWithCartCatch.subscriptionNotFound',
@@ -299,9 +299,7 @@ export class CartService {
299299
interval: cart.interval,
300300
}
301301
);
302-
this.statsd.increment(
303-
'subscription_deletion_failed_not_found'
304-
);
302+
this.statsd.increment('subscription_deletion_failed_not_found');
305303
} else {
306304
throw new CartSubscriptionDeletionFailedError(
307305
cartId,
@@ -888,6 +886,17 @@ export class CartService {
888886
]);
889887
const cartEligibilityStatus =
890888
handleEligibilityStatusMap[eligibility.subscriptionEligibilityResult];
889+
890+
const freeTrialEligibility = cart.uid
891+
? await this.checkoutService.getFreeTrialEligibility({
892+
uid: cart.uid,
893+
offeringConfigId: cart.offeringConfigId,
894+
countryCode: cart.taxAddress.countryCode || '',
895+
interval: cart.interval as SubplatInterval,
896+
eligibilityStatus: eligibility.subscriptionEligibilityResult,
897+
})
898+
: null;
899+
891900
const { unitAmountForCurrency: offeringPrice } =
892901
await this.priceManager.retrievePricingForCurrency(
893902
price.id,
@@ -1008,6 +1017,7 @@ export class CartService {
10081017
latestInvoicePreview,
10091018
paymentInfo,
10101019
hasActiveSubscriptions: !!subscriptions?.length,
1020+
freeTrialEligibility,
10111021
};
10121022
}
10131023

@@ -1059,6 +1069,7 @@ export class CartService {
10591069
: undefined,
10601070
fromPrice: 'fromPrice' in eligibility ? fromPrice : undefined,
10611071
hasActiveSubscriptions: !!subscriptions?.length,
1072+
freeTrialEligibility,
10621073
};
10631074
}
10641075

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CartErrorReasonId,
1010
CartState,
1111
} from '@fxa/shared/db/mysql/account';
12+
import type { FreeTrial } from '@fxa/shared/cms';
1213
import Stripe from 'stripe';
1314

1415
export type FinishCart = {
@@ -78,6 +79,7 @@ export type BaseCartDTO = Omit<ResultCart, 'state'> & {
7879
fromPrice?: FromPrice;
7980
taxAddress: TaxAddress;
8081
currency: string;
82+
freeTrialEligibility?: FreeTrial | null;
8183
};
8284

8385
export type StartCartDTO = BaseCartDTO & {
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Component - Payment Consent Checkbox
22

33
next-payment-confirm-with-legal-links-static-3 = I authorize { -brand-mozilla } to charge my payment method for the amount shown, according to <termsOfServiceLink>Terms of Service</termsOfServiceLink> and <privacyNoticeLink>Privacy Notice</privacyNoticeLink>, until I cancel my subscription.
4-
4+
## $endDate (Date) - The end date of the free trial
5+
checkbox-payment-required-no-charge = A payment method is required to start your free trial. You will not be charged until { $endDate }.
6+
checkbox-confirm-free-trial-with-legal-links = I authorize { -brand-mozilla } to charge my payment method for the amount shown after the free trial ends on { $endDate }, according to the <termsOfServiceLink>Terms of Service</termsOfServiceLink> and <privacyNoticeLink>Privacy Notice</privacyNoticeLink>, until I cancel my subscription.
57
next-payment-confirm-checkbox-error = You need to complete this before moving forward

0 commit comments

Comments
 (0)