зеркало из https://github.com/mozilla/fxa.git
This commit is contained in:
Родитель
13f7bc8202
Коммит
d4e0b2cbac
|
@ -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 can’t switch between currencies.
|
||||
location-unsupported = Your current location is not supported according to our Terms of Service.
|
||||
no-subscription-change = Sorry. You can’t change your subscription plan.
|
||||
# $mobileAppStore (String) - "Google Play Store" or "App Store", localized when the translation is available.
|
||||
iap-already-subscribed = You’re 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 isn’t 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 can’t 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,
|
||||
|
|
Загрузка…
Ссылка в новой задаче