This commit is contained in:
Lisa Chan 2024-11-19 13:34:12 -05:00
Родитель 13f7bc8202
Коммит d4e0b2cbac
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9052E177BBC5E764
15 изменённых файлов: 231 добавлений и 9 удалений

Просмотреть файл

@ -158,6 +158,12 @@ const convictConf = convict({
default: {},
env: 'GEODB_LOCATION_OVERRIDE',
},
unsupportedLocations: {
doc: 'list of unsupported locations according to ToS',
default: [],
env: 'GEODB_UNSUPPORTED_LOCATIONS',
format: Array,
},
},
appleAuthConfig: {
clientId: {

Просмотреть файл

@ -590,6 +590,20 @@ AppError.invalidRegion = (region) => {
);
};
AppError.unsupportedLocation = (country) => {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: ERRNO.UNSUPPORTED_LOCATION,
message: 'Location is not supported according to our Terms of Service.',
},
{
country,
}
);
};
AppError.currencyCountryMismatch = (currency, country) => {
return new AppError(
{

Просмотреть файл

@ -87,6 +87,7 @@ export class StripeHandler {
subscriptionAccountReminders: any;
capabilityService: CapabilityService;
promotionCodeManager: PromotionCodeManager;
unsupportedLocations: Array<string>;
constructor(
// FIXME: For some reason Logger methods were not being detected in
@ -104,6 +105,8 @@ export class StripeHandler {
require('../../subscription-account-reminders')(log, config);
this.capabilityService = Container.get(CapabilityService);
this.promotionCodeManager = Container.get(PromotionCodeManager);
this.unsupportedLocations =
(config.geodb.unsupportedLocations as string[]) || [];
}
/**
@ -388,6 +391,15 @@ export class StripeHandler {
request: AuthRequest
): Promise<invoiceDTO.firstInvoicePreviewSchema> {
this.log.begin('subscriptions.previewInvoice', request);
const taxAddress = this.buildTaxAddress(
request.app.clientAddress,
request.app.geo.location
);
const country = taxAddress?.countryCode;
if (country && this.isLocationUnsupported(country)) {
throw error.unsupportedLocation(country);
}
const { promotionCode, priceId } = request.payload as Record<
string,
@ -410,11 +422,6 @@ export class StripeHandler {
await this.customs.checkIpOnly(request, 'previewInvoice');
}
const taxAddress = this.buildTaxAddress(
request.app.clientAddress,
request.app.geo.location
);
try {
let isUpgrade = false,
sourcePlan;
@ -583,6 +590,16 @@ export class StripeHandler {
subscription: DeepPartial<Stripe.Subscription>;
}> {
this.log.begin('subscriptions.createSubscriptionWithPMI', request);
const taxAddress = this.buildTaxAddress(
request.app.clientAddress,
request.app.geo.location
);
const country = taxAddress?.countryCode;
if (country && this.isLocationUnsupported(country)) {
throw error.unsupportedLocation(country);
}
const { uid, email, account } = await handleAuth(
this.db,
request.auth,
@ -895,6 +912,15 @@ export class StripeHandler {
return;
}
/**
* Checks if country code is an unsupported location
*/
isLocationUnsupported = (countryCode: string): boolean => {
console.log('unsupportedLocations', this.unsupportedLocations);
return this.unsupportedLocations.includes(countryCode);
};
}
export const stripeRoutes = (

Просмотреть файл

@ -56,6 +56,9 @@ describe('PaypalProcessor', () => {
subscriptions: {
paypalNvpSigCredentials: { enabled: false },
},
geodb: {
unsupportedLocations: [],
},
};
mockStripeHelper = {};
mockPaypalHelper = {};

Просмотреть файл

@ -65,6 +65,9 @@ describe('PayPalNotificationHandler', () => {
enabled: false,
},
},
geodb: {
unsupportedLocations: [],
},
};
log = mocks.mockLog();

Просмотреть файл

@ -128,6 +128,9 @@ describe('subscriptions payPalRoutes', () => {
support: {
ticketPayloadLimit: 131072,
},
geodb: {
unsupportedLocations: [],
},
};
currencyHelper = new CurrencyHelper(config);
log = mocks.mockLog();

Просмотреть файл

@ -98,6 +98,9 @@ describe('StripeWebhookHandler', () => {
},
productConfigsFirestore: { enabled: false },
},
geodb: {
unsupportedLocations: [],
},
};
log = mocks.mockLog();

Просмотреть файл

@ -172,6 +172,9 @@ describe('subscriptions stripeRoutes', () => {
support: {
ticketPayloadLimit: 131072,
},
geodb: {
unsupportedLocations: [],
},
};
Container.set(AppConfig, config);
@ -401,6 +404,9 @@ describe('DirectStripeRoutes', () => {
stripeApiKey: 'sk_test_1234',
productConfigsFirestore: { enabled: false },
},
geodb: {
unsupportedLocations: ['CN'],
},
};
log = mocks.mockLog();
@ -800,6 +806,29 @@ describe('DirectStripeRoutes', () => {
assert.equal(err.errno, error.ERRNO.INVALID_INVOICE_PREVIEW_REQUEST);
}
});
it('errors when country code is an unsupported location', async () => {
const request = deepCopy(VALID_REQUEST);
directStripeRoutesInstance.buildTaxAddress = sinon
.stub()
.returns({ countryCode: 'CN' });
directStripeRoutesInstance.isLocationUnsupported = sinon
.stub()
.returns(true);
try {
await directStripeRoutesInstance.previewInvoice(request);
assert.fail('Preview Invoice should fail');
} catch (err) {
assert.instanceOf(err, WError);
assert.equal(err.errno, error.ERRNO.UNSUPPORTED_LOCATION);
assert.equal(
err.message,
'Location is not supported according to our Terms of Service.'
);
}
});
});
async function successInvoices(
@ -1266,6 +1295,29 @@ describe('DirectStripeRoutes', () => {
assertSuccess(sourceCountry, actual, expected);
});
it('errors when country code is an unsupported location', async () => {
const request = deepCopy(VALID_REQUEST);
directStripeRoutesInstance.buildTaxAddress = sinon
.stub()
.returns({ countryCode: 'CN' });
directStripeRoutesInstance.isLocationUnsupported = sinon
.stub()
.returns(true);
try {
await directStripeRoutesInstance.createSubscriptionWithPMI(request);
assert.fail('Create subscription should fail');
} catch (err) {
assert.instanceOf(err, WError);
assert.equal(err.errno, error.ERRNO.UNSUPPORTED_LOCATION);
assert.equal(
err.message,
'Location is not supported according to our Terms of Service.'
);
}
});
it('errors when a customer has not been created', async () => {
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(undefined);
VALID_REQUEST.payload = {
@ -1444,6 +1496,9 @@ describe('DirectStripeRoutes', () => {
managementTokenTTL: MOCK_TTL,
stripeApiKey: 'sk_test_1234',
},
geodb: {
unsupportedLocations: [],
},
};
log = mocks.mockLog();
@ -2349,4 +2404,24 @@ describe('DirectStripeRoutes', () => {
assert.deepEqual(taxAddress, undefined);
});
});
describe('isLocationUnsupported', () => {
it('returns false as the country is a supported location', async () => {
const countryCode = 'US';
const isLocationUnsupported =
await directStripeRoutesInstance.isLocationUnsupported(countryCode);
assert.isFalse(isLocationUnsupported);
});
it('returns true as the country is an unsupported location', async () => {
const countryCode = 'CN';
const isLocationUnsupported =
await directStripeRoutesInstance.isLocationUnsupported(countryCode);
assert.isTrue(isLocationUnsupported);
});
});
});

Просмотреть файл

@ -17,6 +17,7 @@ coupon-expired = It looks like that promo code has expired.
card-error = Your transaction could not be processed. Please verify your credit card information and try again.
country-currency-mismatch = The currency of this subscription is not valid for the country associated with your payment.
currency-currency-mismatch = Sorry. You cant switch between currencies.
location-unsupported = Your current location is not supported according to our Terms of Service.
no-subscription-change = Sorry. You cant change your subscription plan.
# $mobileAppStore (String) - "Google Play Store" or "App Store", localized when the translation is available.
iap-already-subscribed = Youre already subscribed through the { $mobileAppStore }.
@ -32,6 +33,7 @@ product-profile-error =
product-customer-error =
.title = Problem loading customer
product-plan-not-found = Plan not found
product-location-unsupported-error = Location not supported
## Hooks - coupons

Просмотреть файл

@ -5,6 +5,7 @@ import {
BASIC_ERROR,
PAYMENT_ERROR_1,
COUNTRY_CURRENCY_MISMATCH,
LOCATION_UNSUPPORTED,
} from './errors';
describe('lib/errors', () => {
@ -36,6 +37,12 @@ describe('lib/errors', () => {
);
});
it('returns unsupported location error id based on error number available in error map', () => {
expect(getErrorMessageId({ code: 'test', errno: 213 })).toEqual(
LOCATION_UNSUPPORTED
);
});
it('returns generic error message if provided error id is undefined', () => {
expect(getFallbackTextByFluentId(undefined)).toEqual(
'Something went wrong. Please try again later.'

Просмотреть файл

@ -17,6 +17,7 @@ const AuthServerErrno = {
UNKNOWN_SUBSCRIPTION: 177,
UNKNOWN_SUBSCRIPTION_CUSTOMER: 176,
UNKNOWN_SUBSCRIPTION_PLAN: 178,
UNSUPPORTED_LOCATION: 213,
};
export enum CouponErrorMessageType {
@ -39,6 +40,7 @@ const FXA_SIGNUP_ERROR = 'fxa-account-signup-error-2';
const IAP_ALREADY_SUBSCRIBED = 'iap-already-subscribed';
const INSTANT_PAYOUTS_UNSUPPORTED = 'instant-payouts-unsupported';
const INSUFFICIENT_FUNDS_ERROR = 'insufficient-funds-error';
const LOCATION_UNSUPPORTED = 'location-unsupported';
const NO_SUBSCRIPTION_CHANGE = 'no-subscription-change';
const PAYMENT_ERROR_1 = 'payment-error-1';
const PAYMENT_ERROR_2 = 'payment-error-2';
@ -158,6 +160,9 @@ const errorToErrorMessageIdMap: { [key: string]: string } = {
iap_already_subscribed: IAP_ALREADY_SUBSCRIBED,
iap_upgrade_contact_support: 'iap-upgrade-contact-support',
no_subscription_change: NO_SUBSCRIPTION_CHANGE,
// Unsupported location
location_unsupported: LOCATION_UNSUPPORTED,
};
// Dictionary of fluentIds and corresponding human-readable error messages
@ -179,6 +184,8 @@ const fallbackErrorMessage: { [key: string]: string } = {
'It looks like your debit card isnt setup for instant payments. Try another debit or credit card.',
[INSUFFICIENT_FUNDS_ERROR]:
'It looks like your card has insufficient funds. Try another card.',
[LOCATION_UNSUPPORTED]:
'Your current location is not supported according to our Terms of Service.',
[NO_SUBSCRIPTION_CHANGE]: 'Sorry. You cant change your subscription plan.',
[WITHDRAW_COUNT_LIMIT_EXCEEDED_ERROR]:
'It looks like this transaction will put you over your credit limit. Try another card.',
@ -225,6 +232,9 @@ function getErrorMessageId(error: undefined | StripeError | GeneralError) {
case AuthServerErrno.INVALID_CURRENCY:
lookup = 'currency_currency_mismatch';
break;
case AuthServerErrno.UNSUPPORTED_LOCATION:
lookup = 'location_unsupported';
break;
}
}
return errorToErrorMessageIdMap[lookup];
@ -240,13 +250,14 @@ const getFallbackTextByFluentId = (key?: string) => {
return fallbackErrorMessage[key];
};
// BASIC_ERROR, COUNTRY_CURRENCY_MISMATCH and PAYMENT_ERROR_1 are exported for errors.test.tsx
// BASIC_ERROR, COUNTRY_CURRENCY_MISMATCH, LOCATION_UNSUPPORTED, and PAYMENT_ERROR_1 are exported for errors.test.tsx
export {
AuthServerErrno,
getErrorMessageId,
getFallbackTextByFluentId,
BASIC_ERROR,
COUNTRY_CURRENCY_MISMATCH,
LOCATION_UNSUPPORTED,
PAYMENT_ERROR_1,
PAYMENT_ERROR_2,
PAYMENT_ERROR_3,

Просмотреть файл

@ -271,7 +271,7 @@ export function useFetchInvoicePreview(
) {
const [invoicePreview, setInvoicePreview] = useState<{
loading: boolean;
error: boolean;
error: any;
result?: FirstInvoicePreview;
}>({ loading: false, error: false, result: undefined });
const isMounted = useRef(false);
@ -316,7 +316,7 @@ export function useFetchInvoicePreview(
if (isMounted.current) {
setInvoicePreview({
loading: false,
error: true,
error: err,
result: undefined,
});
}

Просмотреть файл

@ -241,6 +241,48 @@ describe('routes/Product', () => {
expectNockScopesDone(apiMocks);
});
it('displays an error for unsupported location', async () => {
const apiMocks = [
nock(profileServer)
.get('/v1/profile')
.reply(200, MOCK_PROFILE, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.get('/v1/oauth/subscriptions/plans')
.reply(200, MOCK_PLANS, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.get(
'/v1/oauth/mozilla-subscriptions/customer/billing-and-subscriptions'
)
.reply(200, MOCK_CUSTOMER, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.persist()
.get(
`/v1/oauth/mozilla-subscriptions/customer/plan-eligibility/${PLAN_ID}`
)
.reply(
200,
{ eligibility: 'invalid' },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.persist()
.post('/v1/oauth/subscriptions/invoice/preview')
.reply(
400,
{
errno: AuthServerErrno.UNSUPPORTED_LOCATION,
},
{
'Access-Control-Allow-Origin': '*',
}
),
];
const { findByTestId } = renderWithLocalizationProvider(<Subject />);
const errorEl = await findByTestId('product-location-unsupported-error');
expect(errorEl).toBeInTheDocument();
expectNockScopesDone(apiMocks);
});
it('displays an error on failure to load profile', async () => {
const apiMocks = [
nock(profileServer)

Просмотреть файл

@ -253,6 +253,31 @@ export const Product = ({
if (invoicePreview.error || !invoicePreview.result) {
const ariaLabelledBy = 'product-invoice-preview-error-title';
const ariaDescribedBy = 'product-invoice-preview-error-text';
if (invoicePreview.error.errno === AuthServerErrno.UNSUPPORTED_LOCATION) {
return (
<DialogMessage
className="dialog-error"
onDismiss={locationReload}
headerId="product-location-unsupported-error-title"
descId="product-location-unsupported-error-text"
>
<Localized id="product-location-unsupported-error">
<h4
id="product-location-unsupported-error-title"
data-testid="product-location-unsupported-error"
>
Location not supported
</h4>
</Localized>
<Localized id="location-unsupported">
<p id="product-location-unsupported-error-text">
Your current location is not supported according to our Terms of
Service.
</p>
</Localized>
</DialogMessage>
);
}
return (
<DialogMessage
className="dialog-error"

Просмотреть файл

@ -123,7 +123,9 @@ export const AUTH_SERVER_ERRNOS = {
UNABLE_TO_LOGIN_NO_PASSWORD_SET: 210,
SUBSCRIPTION_PROMO_CODE_NOT_APPLIED: 211,
SUBSCRIPTION_PROMO_CODE_NOT_APPLIED: 212,
UNSUPPORTED_LOCATION: 213,
INTERNAL_VALIDATION_ERROR: 998,
UNEXPECTED_ERROR: 999,