Skip to content

Commit 31bf452

Browse files
committed
feat(payments): Add Upgrade Layout
1 parent 8bacb97 commit 31bf452

22 files changed

Lines changed: 578 additions & 276 deletions

File tree

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { fetchCMSData, getCartAction } from '@fxa/payments/ui/actions';
1212
import {
1313
getApp,
1414
CheckoutParams,
15-
Details,
1615
PriceInterval,
1716
SignedIn,
1817
SubscriptionTitle,
@@ -74,23 +73,25 @@ export default async function RootLayout({
7473
aria-label="Purchase details"
7574
>
7675
<PurchaseDetails
76+
invoice={cart.upcomingInvoicePreview}
77+
purchaseDetails={purchaseDetails}
7778
priceInterval={
7879
<PriceInterval
7980
l10n={l10n}
81+
amount={cart.upcomingInvoicePreview.listAmount}
8082
currency={cart.upcomingInvoicePreview.currency}
8183
interval={cart.interval}
82-
listAmount={cart.upcomingInvoicePreview.listAmount}
8384
/>
8485
}
85-
purchaseDetails={purchaseDetails}
86-
>
87-
<Details
88-
l10n={l10n}
89-
interval={cart.interval}
90-
invoice={cart.upcomingInvoicePreview}
91-
purchaseDetails={purchaseDetails}
92-
/>
93-
</PurchaseDetails>
86+
totalPrice={
87+
<PriceInterval
88+
l10n={l10n}
89+
amount={cart.upcomingInvoicePreview.totalAmount}
90+
currency={cart.upcomingInvoicePreview.currency}
91+
interval={cart.interval}
92+
/>
93+
}
94+
/>
9495
<SelectTaxLocation
9596
cartId={cart.id}
9697
cartVersion={cart.version}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export default async function CheckoutSuccess({
102102
{
103103
email: cart.email || '',
104104
},
105-
`You'll receive an email at ${cart.email} with instructions about your subscription, as well as your payment details.`
105+
`Youll receive an email at ${cart.email} with instructions about your subscription, as well as your payment details.`
106106
)}
107107
</p>
108108
</div>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import assert from 'assert';
6+
import { headers } from 'next/headers';
7+
import { MetricsWrapper } from '@fxa/payments/ui';
8+
import { fetchCMSData, getCartAction } from '@fxa/payments/ui/actions';
9+
import {
10+
getApp,
11+
CheckoutParams,
12+
SubscriptionTitle,
13+
TermsAndPrivacy,
14+
UpgradePurchaseDetails,
15+
} from '@fxa/payments/ui/server';
16+
import { CartEligibilityStatus } from '@fxa/shared/db/mysql/account';
17+
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
18+
import { config } from 'apps/payments/next/config';
19+
20+
export default async function UpgradeLayout({
21+
children,
22+
params,
23+
}: {
24+
children: React.ReactNode;
25+
params: CheckoutParams;
26+
}) {
27+
// Temporarily defaulting to `accept-language`
28+
// This to be updated in FXA-9404
29+
//const locale = getLocaleFromRequest(
30+
// params,
31+
// headers().get('accept-language')
32+
//);
33+
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
34+
35+
const cartDataPromise = getCartAction(params.cartId);
36+
const cmsDataPromise = fetchCMSData(params.offeringId, locale);
37+
const l10n = getApp().getL10n(locale);
38+
const [cms, cart] = await Promise.all([cmsDataPromise, cartDataPromise]);
39+
const purchaseDetails =
40+
cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
41+
cms.defaultPurchase.purchaseDetails;
42+
43+
assert(cart.fromOfferingConfigId, 'fromOfferingConfigId is missing in cart');
44+
assert(cart.fromPrice, 'fromPrice is missing in cart');
45+
46+
const currentCmsDataPromise = fetchCMSData(cart.fromOfferingConfigId, locale);
47+
const currentCms = await currentCmsDataPromise;
48+
const currentPurchaseDetails =
49+
currentCms.defaultPurchase.purchaseDetails.localizations.at(0) ||
50+
currentCms.defaultPurchase.purchaseDetails;
51+
52+
return (
53+
<MetricsWrapper cart={cart}>
54+
<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:mt-4 tablet:mb-auto desktop:grid-cols-[600px_1fr]">
55+
<SubscriptionTitle
56+
cartState={cart.state}
57+
cartEligibilityStatus={CartEligibilityStatus.UPGRADE}
58+
l10n={l10n}
59+
/>
60+
61+
<section
62+
className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:col-end-auto tablet:row-start-1 tablet:row-end-3"
63+
aria-label="Upgrade details"
64+
>
65+
<UpgradePurchaseDetails
66+
l10n={l10n}
67+
interval={cart.interval}
68+
invoice={cart.upcomingInvoicePreview}
69+
currentPrice={cart.fromPrice}
70+
currentPurchaseDetails={currentPurchaseDetails}
71+
purchaseDetails={purchaseDetails}
72+
/>
73+
</section>
74+
75+
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 border-t-0 mb-6 pt-4 px-4 pb-14 rounded-t-lg text-grey-600 tablet:clip-shadow tablet:rounded-t-none desktop:px-12 desktop:pb-12">
76+
{children}
77+
<TermsAndPrivacy
78+
l10n={l10n}
79+
{...cart}
80+
{...purchaseDetails}
81+
{...(cms.commonContent.localizations.at(0) || cms.commonContent)}
82+
contentServerUrl={config.contentServerUrl}
83+
showFXALinks={true}
84+
/>
85+
</div>
86+
</div>
87+
</MetricsWrapper>
88+
);
89+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export default async function Upgrade() {
8+
return (
9+
<section aria-label="Upgrade">INSERT UPGRADE PAGE CONTENT HERE</section>
10+
);
11+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,11 @@ describe('CartService', () => {
12511251
},
12521252
metricsOptedOut: false,
12531253
fromOfferingConfigId: mockFromOfferingId,
1254-
fromPrice: mockFromPrice,
1254+
fromPrice: {
1255+
currency: mockFromPrice.currency,
1256+
interval: mockFromPrice.recurring?.interval,
1257+
listAmount: mockFromPrice.unit_amount,
1258+
},
12551259
});
12561260

12571261
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { Injectable } from '@nestjs/common';
66
import * as Sentry from '@sentry/node';
77
import assert from 'assert';
8+
import assertNotNull from 'assert';
89

910
import {
1011
CustomerManager,
@@ -52,6 +53,7 @@ import { CartManager } from './cart.manager';
5253
import type {
5354
CartDTO,
5455
CheckoutCustomerData,
56+
FromPrice,
5557
GetNeedsInputResponse,
5658
NoInputNeededResponse,
5759
PaymentInfo,
@@ -641,6 +643,18 @@ export class CartService {
641643
};
642644
}
643645

646+
let fromPrice: FromPrice | undefined;
647+
if (cartEligibilityStatus === CartEligibilityStatus.UPGRADE) {
648+
assert('fromPrice' in eligibility, 'fromPrice not present for upgrade');
649+
assertNotNull(eligibility.fromPrice.unit_amount);
650+
assertNotNull(eligibility.fromPrice.recurring);
651+
fromPrice = {
652+
currency: eligibility.fromPrice.currency,
653+
interval: eligibility.fromPrice.recurring.interval,
654+
listAmount: eligibility.fromPrice.unit_amount,
655+
};
656+
}
657+
644658
return {
645659
...cart,
646660
state: cart.state,
@@ -652,7 +666,7 @@ export class CartService {
652666
'fromOfferingConfigId' in eligibility
653667
? eligibility.fromOfferingConfigId
654668
: undefined,
655-
fromPrice: 'fromPrice' in eligibility ? eligibility.fromPrice : undefined,
669+
fromPrice: 'fromPrice' in eligibility ? fromPrice : undefined,
656670
};
657671
}
658672

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { TaxAddress } from '@fxa/payments/customer';
6-
import { StripePrice } from '@fxa/payments/stripe';
76
import {
87
Cart,
98
CartEligibilityStatus,
109
CartErrorReasonId,
1110
CartState,
1211
} from '@fxa/shared/db/mysql/account';
12+
import { StripePrice } from '@fxa/payments/stripe';
1313
import Stripe from 'stripe';
1414

1515
export type CheckoutCustomerData = {
@@ -60,13 +60,19 @@ export type ResultCart = Readonly<Omit<Cart, 'id' | 'uid'>> & {
6060
readonly uid?: string;
6161
};
6262

63+
export type FromPrice = {
64+
currency: string;
65+
interval: NonNullable<StripePrice['recurring']>['interval'];
66+
listAmount: number;
67+
};
68+
6369
export type BaseCartDTO = Omit<ResultCart, 'state'> & {
6470
metricsOptedOut: boolean;
6571
upcomingInvoicePreview: Invoice;
6672
latestInvoicePreview?: Invoice;
6773
paymentInfo?: PaymentInfo;
6874
fromOfferingConfigId?: string;
69-
fromPrice?: StripePrice;
75+
fromPrice?: FromPrice;
7076
};
7177

7278
export type StartCartDTO = BaseCartDTO & {

libs/payments/customer/src/lib/invoice.manager.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ export class InvoiceManager {
9595
tax_exempt: 'none', // Param required when shipping address not present
9696
shipping,
9797
},
98-
subscription_items: [{ price: priceId }],
98+
subscription_details: {
99+
items: [{ price: priceId }],
100+
},
99101
discounts: [{ promotion_code: promoCode?.id }],
100102
};
101103

@@ -146,7 +148,6 @@ export class InvoiceManager {
146148
: undefined;
147149

148150
const requestObject: Stripe.InvoiceRetrieveUpcomingParams = {
149-
currency,
150151
customer: customer?.id,
151152
automatic_tax: {
152153
enabled: automaticTax,
@@ -155,7 +156,9 @@ export class InvoiceManager {
155156
tax_exempt: 'none', // Param required when shipping address not present
156157
shipping,
157158
},
158-
subscription_items: [{ price: priceId }],
159+
subscription_details: {
160+
items: [{ price: priceId }],
161+
},
159162
discounts: [{ promotion_code: promoCode?.id }],
160163
};
161164

@@ -167,9 +170,6 @@ export class InvoiceManager {
167170
couponCode,
168171
});
169172

170-
requestObject.subscription_proration_behavior = 'always_invoice';
171-
requestObject.subscription_proration_date = Math.floor(Date.now() / 1000);
172-
173173
const subscriptions = await this.stripeClient.subscriptionsList({
174174
customer: customer?.id,
175175
});
@@ -178,10 +178,15 @@ export class InvoiceManager {
178178
.flatMap((subscription) => subscription.items.data)
179179
?.find((subscription) => subscription.plan.id === fromPrice?.id);
180180

181-
const firstSubItem = requestObject.subscription_items?.at(0);
181+
const firstSubItem = requestObject.subscription_details?.items?.at(0);
182182
if (!firstSubItem) throw new Error('No subscription item found');
183183
firstSubItem.id = subscriptionItem?.id;
184184
requestObject.subscription = subscriptionItem?.subscription;
185+
requestObject.subscription_details = {
186+
...requestObject.subscription_details,
187+
proration_behavior: 'always_invoice',
188+
proration_date: Math.floor(Date.now() / 1000),
189+
};
185190

186191
const proratedInvoice = await this.stripeClient.invoicesRetrieveUpcoming(
187192
requestObject

libs/payments/eligibility/src/lib/eligibility.manager.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('EligibilityManager', () => {
7777
expect(result).toHaveLength(0);
7878
});
7979

80-
it('should return empty result when providedTargetOffering or targetPriceId not provided', async () => {
80+
it('should return empty result when targetOffering or targetPriceId not provided', async () => {
8181
const eligibilityContentByPlanIdsResultUtil =
8282
new EligibilityContentByPlanIdsResultUtil({ purchases: [] });
8383

@@ -224,7 +224,7 @@ describe('EligibilityManager', () => {
224224
const priceId = faker.string.uuid();
225225
const result = await manager.getOfferingOverlap({
226226
priceIds: [priceId],
227-
providedTargetOffering: targetOffering,
227+
targetOffering: targetOffering,
228228
});
229229
expect(
230230
productConfigurationManager.getPurchaseDetailsForEligibility
@@ -320,7 +320,7 @@ describe('EligibilityManager', () => {
320320
const fromPriceId = faker.string.uuid();
321321
const result = await manager.getOfferingOverlap({
322322
priceIds: [fromPriceId],
323-
providedTargetOffering: targetOffering,
323+
targetOffering: targetOffering,
324324
});
325325
expect(
326326
productConfigurationManager.getPurchaseDetailsForEligibility

libs/payments/eligibility/src/lib/eligibility.manager.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ export class EligibilityManager {
3737
async getOfferingOverlap({
3838
priceIds,
3939
targetPriceId,
40-
providedTargetOffering,
40+
targetOffering,
4141
}: {
4242
priceIds: string[];
4343
targetPriceId?: string;
44-
providedTargetOffering?: EligibilityContentOfferingResult;
44+
targetOffering?: EligibilityContentOfferingResult;
4545
}): Promise<OfferingOverlapResult[]> {
4646
if (!priceIds.length) return [];
47-
if (!targetPriceId && !providedTargetOffering) return [];
47+
if (!targetPriceId && !targetOffering) return [];
4848

4949
const ids = targetPriceId ? [...priceIds, targetPriceId] : [...priceIds];
5050

@@ -55,22 +55,23 @@ export class EligibilityManager {
5555

5656
const result: OfferingOverlapResult[] = [];
5757

58-
let targetOffering;
59-
if (providedTargetOffering) {
60-
targetOffering = providedTargetOffering;
58+
let targetOfferingForComparison;
59+
if (targetOffering) {
60+
targetOfferingForComparison = targetOffering;
6161
}
6262
if (targetPriceId) {
63-
targetOffering = detailsResult.offeringForPlanId(targetPriceId);
63+
targetOfferingForComparison =
64+
detailsResult.offeringForPlanId(targetPriceId);
6465
}
65-
if (!targetOffering) return [];
66+
if (!targetOfferingForComparison) return [];
6667

6768
for (const priceId of priceIds) {
6869
const fromOffering = detailsResult.offeringForPlanId(priceId);
6970
if (!fromOffering) continue;
7071

7172
const comparison = offeringComparison(
7273
fromOffering.apiIdentifier,
73-
targetOffering
74+
targetOfferingForComparison
7475
);
7576
if (comparison)
7677
result.push({

0 commit comments

Comments
 (0)