Skip to content

Commit db4e9f3

Browse files
committed
feat(next): allow location change on active sub
Because: - Allow customers with active subscriptions to change tax location as long as currency isnt altered. - Display message informing customers that a change in tax location could impact all existing subscriptions. This commit: - Add new method to TaxService to check if a tax location change is allowed. - Consolidate location validity checks into ValidateLocationAction - Update Location and New pages, as well as SelectTaxLocation saveAction methods to use ValidateLocationAction - Adds message to SelectTaxLocation and is only shown when a customer has an active subscription. - For carts with state Start, add new field indicating whether or not an active subscription exists Closes #FXA-11508
1 parent 01d06aa commit db4e9f3

35 files changed

Lines changed: 546 additions & 239 deletions

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,28 @@ export default async function CheckoutLayout({
100100
<SelectTaxLocation
101101
saveAction={async (countryCode, postalCode) => {
102102
'use server';
103-
return updateTaxAddressAction(cart.id, cart.version, {
104-
countryCode,
105-
postalCode,
106-
});
103+
const result = await updateTaxAddressAction(
104+
cart.id,
105+
cart.version,
106+
params.offeringId,
107+
{
108+
countryCode,
109+
postalCode,
110+
},
111+
session?.user?.id
112+
);
113+
114+
if (result.ok) {
115+
return {
116+
ok: true,
117+
data: result.taxAddress,
118+
};
119+
} else {
120+
return {
121+
ok: false,
122+
error: result.error,
123+
};
124+
}
107125
}}
108126
cmsCountries={cms.countries}
109127
locale={locale.substring(0, 2)}
@@ -113,6 +131,8 @@ export default async function CheckoutLayout({
113131
}
114132
countryCode={cart.taxAddress?.countryCode}
115133
postalCode={cart.taxAddress?.postalCode}
134+
currentCurrency={cart.currency}
135+
showNewTaxRateInfoMessage={cart.hasActiveSubscriptions}
116136
/>
117137
</div>
118138
)}

apps/payments/next/app/[locale]/[offeringId]/[interval]/location/en.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
location-header = Select your country and enter your postal code <p>to continue to checkout for { $productName }</p>
33
location-banner-info = We weren’t able to detect your location automatically
44
location-required-disclaimer = We only use this information to calculate taxes and currency.
5+
location-banner-currency-change = Currency change not supported. To continue, select a country that matches your current billing currency.

apps/payments/next/app/[locale]/[offeringId]/[interval]/location/page.tsx

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ import {
1414
IsolatedSelectTaxLocation,
1515
buildRedirectUrl,
1616
} from '@fxa/payments/ui';
17-
import {
18-
fetchCMSData,
19-
getProductAvailabilityForLocation,
20-
} from '@fxa/payments/ui/actions';
17+
import { fetchCMSData, validateLocationAction } from '@fxa/payments/ui/actions';
2118
import { getApp, TermsAndPrivacy } from '@fxa/payments/ui/server';
2219
import locationIcon from '@fxa/shared/assets/images/confirm-pairing.svg';
2320
import type { PageContentOfferingTransformed } from '@fxa/shared/cms';
2421
import { config } from 'apps/payments/next/config';
22+
import { auth } from 'apps/payments/next/auth';
23+
import { TaxChangeAllowedStatus } from '@fxa/payments/cart';
2524

2625
export const dynamic = 'force-dynamic';
2726

@@ -35,26 +34,30 @@ export default async function Location({
3534
const acceptLanguage = headers().get('accept-language');
3635
const l10n = getApp().getL10n(acceptLanguage, params.locale);
3736
const emitterService = getApp().getEmitterService();
37+
const session = await auth();
38+
const providedCountryCode = searchParams['countryCode'];
39+
const providedPostalCode = searchParams['postalCode'];
40+
const taxAddress =
41+
providedCountryCode && providedPostalCode
42+
? {
43+
countryCode: providedCountryCode,
44+
postalCode: providedPostalCode,
45+
}
46+
: undefined;
47+
48+
const fxaUid = session?.user?.id;
3849

3950
let cms: PageContentOfferingTransformed | undefined;
40-
let locationStatus: LocationStatus | undefined;
51+
let locationStatus: LocationStatus | TaxChangeAllowedStatus | undefined;
52+
let customerCurrency: string | undefined;
4153
try {
42-
const cmsDataPromise = fetchCMSData(
43-
params.offeringId,
44-
acceptLanguage,
45-
params.locale
46-
);
47-
const locationPromise = await getProductAvailabilityForLocation(
48-
params.offeringId,
49-
searchParams['countryCode']
50-
);
51-
52-
const [cmsData, locationData] = await Promise.all([
53-
cmsDataPromise,
54-
locationPromise,
54+
const [cmsData, validateLocationResults] = await Promise.all([
55+
fetchCMSData(params.offeringId, acceptLanguage, params.locale),
56+
validateLocationAction(params.offeringId, taxAddress, fxaUid),
5557
]);
5658
cms = cmsData;
57-
locationStatus = locationData.status;
59+
locationStatus = validateLocationResults.status;
60+
customerCurrency = validateLocationResults.currentCurrency;
5861

5962
emitterService.emit('locationView', locationStatus);
6063
} catch (error) {
@@ -105,14 +108,22 @@ export default async function Location({
105108
'Your current location is not supported according to our Terms of Service.'
106109
)}
107110
</Banner>
108-
) : locationStatus === LocationStatus.ProductNotAvailable ? (
111+
) : locationStatus === LocationStatus.ProductNotAvailable ||
112+
locationStatus === TaxChangeAllowedStatus.CurrencyNotFound ? (
109113
<Banner variant={BannerVariant.Error} showCloseButton={true}>
110114
{l10n.getString(
111115
'select-tax-location-product-not-available',
112116
{ productName: purchaseDetails.productName },
113117
`${purchaseDetails.productName} is not available in this location.`
114118
)}
115119
</Banner>
120+
) : locationStatus === TaxChangeAllowedStatus.CurrencyChange ? (
121+
<Banner variant={BannerVariant.Error} showCloseButton={true}>
122+
{l10n.getString(
123+
'location-banner-currency-change',
124+
'Currency change not supported. To continue, select a country that matches your current billing currency.'
125+
)}
126+
</Banner>
116127
) : (
117128
<Banner variant={BannerVariant.Info} showCloseButton={true}>
118129
{l10n.getString(
@@ -127,12 +138,30 @@ export default async function Location({
127138
cmsCountries={cms.countries}
128139
locale={params.locale.substring(0, 2)}
129140
productName={purchaseDetails.productName}
141+
showNewTaxRateInfoMessage={false}
130142
unsupportedLocations={
131143
config.location.subscriptionsUnsupportedLocations
132144
}
145+
currentCurrency={customerCurrency}
133146
saveAction={async (countryCode: string, postalCode: string) => {
134147
'use server';
135148

149+
if (fxaUid) {
150+
// call server Action here to validate if tax location change is allowed
151+
const result = await validateLocationAction(
152+
params.offeringId,
153+
{ countryCode, postalCode },
154+
fxaUid
155+
);
156+
157+
if (!result.isValid) {
158+
return {
159+
ok: false,
160+
error: result.status,
161+
};
162+
}
163+
}
164+
136165
searchParams['countryCode'] = countryCode;
137166
searchParams['postalCode'] = postalCode;
138167
const redirectUrl = new URL(

apps/payments/next/app/[locale]/[offeringId]/[interval]/new/page.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

55
import { auth } from 'apps/payments/next/auth';
66
import { notFound, redirect } from 'next/navigation';
7-
import { LocationStatus } from '@fxa/payments/eligibility';
87
import {
9-
getProductAvailabilityForLocation,
8+
validateLocationAction,
109
getTaxAddressAction,
1110
setupCartAction,
1211
} from '@fxa/payments/ui/actions';
@@ -56,22 +55,30 @@ export default async function New({
5655

5756
const fxaUid = session?.user?.id;
5857
const coupon = searchParams.coupon || undefined;
58+
const countryCode = searchParams.countryCode;
59+
const postalCode = searchParams.postalCode;
5960

60-
const taxAddress = await getTaxAddressAction(ipAddress, fxaUid);
61+
const taxAddress =
62+
countryCode && postalCode
63+
? { countryCode, postalCode }
64+
: await getTaxAddressAction(ipAddress, fxaUid);
6165

6266
// Check if the customer is in a location not supported by Subscription Platform
6367
// or whether the product is not available in the customer's location
64-
const { status } = await getProductAvailabilityForLocation(
68+
const { isValid: locationIsValid } = await validateLocationAction(
6569
offeringId,
66-
taxAddress?.countryCode
70+
taxAddress,
71+
fxaUid
6772
);
6873

69-
if (
70-
!taxAddress ||
71-
status === LocationStatus.SanctionedLocation ||
72-
status === LocationStatus.ProductNotAvailable ||
73-
status === LocationStatus.Unresolved
74-
) {
74+
if (!taxAddress || !locationIsValid) {
75+
if (taxAddress?.countryCode) {
76+
searchParams.countryCode = taxAddress.countryCode;
77+
}
78+
if (taxAddress?.postalCode) {
79+
searchParams.postalCode = taxAddress.postalCode;
80+
}
81+
7582
const locationPageUrl = new URL(
7683
buildRedirectUrl(
7784
params.offeringId,

apps/payments/next/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const nextConfig = {
3131
'@apollo',
3232
'@faker-js/faker',
3333
'@google-cloud/firestore',
34+
'@googleapis/androidpublisher',
35+
'@googlemaps/google-maps-services-js',
3436
'@grpc',
3537
"@nestjs/apollo",
3638
"@nestjs/common",
@@ -48,6 +50,7 @@ const nextConfig = {
4850
'@sentry/nestjs',
4951
'@sentry/open-telemetry',
5052
'@type-cacheable/core',
53+
'app-store-server-api',
5154
'aws-sdk',
5255
'class-transformer',
5356
'class-validator',

libs/payments/cart/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { CartInvalidStateForActionError } from './lib/cart.error';
1010
export * from './lib/checkout.service';
1111
export * from './lib/checkout.error';
1212
export * from './lib/tax.service';
13+
export * from './lib/tax.types';

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,7 @@ describe('CartService', () => {
13021302
customerSessionClientSecret: mockCustomerSession.client_secret,
13031303
},
13041304
metricsOptedOut: false,
1305+
hasActiveSubscriptions: true,
13051306
});
13061307

13071308
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -1362,6 +1363,7 @@ describe('CartService', () => {
13621363
brand: mockPaymentMethod.card?.brand,
13631364
customerSessionClientSecret: mockCustomerSession.client_secret,
13641365
},
1366+
hasActiveSubscriptions: true,
13651367
});
13661368
expect(
13671369
'latestInvoicePreview' in result && result.latestInvoicePreview
@@ -1410,6 +1412,7 @@ describe('CartService', () => {
14101412
...mockCart,
14111413
upcomingInvoicePreview: mockInvoicePreview,
14121414
metricsOptedOut: false,
1415+
hasActiveSubscriptions: false,
14131416
});
14141417

14151418
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -1472,6 +1475,7 @@ describe('CartService', () => {
14721475
interval: mockFromPrice.recurring?.interval,
14731476
listAmount: mockFromPrice.unit_amount,
14741477
},
1478+
hasActiveSubscriptions: true,
14751479
});
14761480

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

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,8 @@ export class CartService {
565565
if (!cartDetails.currency) {
566566
throw new CartCurrencyNotFoundError(
567567
cartDetails.currency,
568-
cartDetailsInput.taxAddress.countryCode
568+
cartDetailsInput.taxAddress.countryCode,
569+
cartId
569570
);
570571
}
571572

@@ -775,6 +776,7 @@ export class CartService {
775776
? eligibility.fromOfferingConfigId
776777
: undefined,
777778
fromPrice: 'fromPrice' in eligibility ? fromPrice : undefined,
779+
hasActiveSubscriptions: !!subscriptions.length,
778780
};
779781
}
780782

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type BaseCartDTO = Omit<ResultCart, 'state'> & {
8181

8282
export type StartCartDTO = BaseCartDTO & {
8383
state: CartState.START;
84+
hasActiveSubscriptions: boolean;
8485
};
8586

8687
export type ProcessingCartDTO = BaseCartDTO & {

0 commit comments

Comments
 (0)