зеркало из https://github.com/mozilla/fxa.git
Merge pull request #17980 from mozilla/FXA-10593
fix(auth): disable stripe tax when currency/country incompatible
This commit is contained in:
Коммит
cc70f3d98c
|
@ -680,6 +680,8 @@ export class StripeHelper extends StripeHelperBase {
|
|||
}): Promise<InvoicePreview> {
|
||||
const params: Stripe.InvoiceRetrieveUpcomingParams = {};
|
||||
|
||||
const { currency: planCurrency } = await this.findAbbrevPlanById(priceId);
|
||||
|
||||
if (promotionCode) {
|
||||
const stripePromotionCode = await this.findValidPromoCode(
|
||||
promotionCode,
|
||||
|
@ -691,8 +693,17 @@ export class StripeHelper extends StripeHelperBase {
|
|||
}
|
||||
|
||||
const automaticTax = !!(
|
||||
(customer && this.isCustomerStripeTaxEligible(customer)) ||
|
||||
(!customer && taxAddress)
|
||||
(customer &&
|
||||
this.isCustomerTaxableWithSubscriptionCurrency(
|
||||
customer,
|
||||
planCurrency
|
||||
)) ||
|
||||
(!customer &&
|
||||
taxAddress &&
|
||||
this.currencyHelper.isCurrencyCompatibleWithCountry(
|
||||
planCurrency,
|
||||
taxAddress.countryCode
|
||||
))
|
||||
);
|
||||
|
||||
const shipping =
|
||||
|
@ -2020,6 +2031,30 @@ export class StripeHelper extends StripeHelperBase {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should enable stripe tax for a given customer and subscription currency.
|
||||
*/
|
||||
isCustomerTaxableWithSubscriptionCurrency(
|
||||
customer: Stripe.Customer,
|
||||
targetCurrency: string
|
||||
) {
|
||||
const taxCountry = customer.tax?.location?.country;
|
||||
if (!taxCountry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isCurrencyCompatibleWithCountry =
|
||||
this.currencyHelper.isCurrencyCompatibleWithCountry(
|
||||
targetCurrency,
|
||||
taxCountry
|
||||
);
|
||||
if (!isCurrencyCompatibleWithCountry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isCustomerStripeTaxEligible(customer);
|
||||
}
|
||||
|
||||
async updateSubscriptionAndBackfill(
|
||||
subscription: Stripe.Subscription,
|
||||
newProps: Stripe.SubscriptionUpdateParams
|
||||
|
|
|
@ -95,10 +95,15 @@ export class PayPalHandler extends StripeWebhookHandler {
|
|||
throw error.unknownCustomer(uid);
|
||||
}
|
||||
|
||||
const automaticTax =
|
||||
this.stripeHelper.isCustomerStripeTaxEligible(customer);
|
||||
|
||||
const { priceId } = request.payload as Record<string, string>;
|
||||
const { currency: planCurrency } =
|
||||
await this.stripeHelper.findAbbrevPlanById(priceId);
|
||||
|
||||
const automaticTax =
|
||||
this.stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
||||
customer,
|
||||
planCurrency
|
||||
);
|
||||
|
||||
// Make sure to clean up any subscriptions that may be hanging with no payment
|
||||
const existingSubscription =
|
||||
|
|
|
@ -243,8 +243,8 @@ export class StripeHandler {
|
|||
// Stripe does not allow customers to change currency after a currency is set, which
|
||||
// occurs on initial subscription. (https://stripe.com/docs/billing/customer#payment)
|
||||
const customer = await this.stripeHelper.fetchCustomer(uid);
|
||||
const planCurrency = (await this.stripeHelper.findAbbrevPlanById(planId))
|
||||
.currency;
|
||||
const { currency: planCurrency } =
|
||||
await this.stripeHelper.findAbbrevPlanById(planId);
|
||||
if (customer && customer.currency !== planCurrency) {
|
||||
throw error.currencyCurrencyMismatch(customer.currency, planCurrency);
|
||||
}
|
||||
|
@ -596,9 +596,6 @@ export class StripeHandler {
|
|||
throw error.unknownCustomer(uid);
|
||||
}
|
||||
|
||||
const automaticTax =
|
||||
this.stripeHelper.isCustomerStripeTaxEligible(customer);
|
||||
|
||||
const {
|
||||
priceId,
|
||||
paymentMethodId,
|
||||
|
@ -630,11 +627,17 @@ export class StripeHandler {
|
|||
|
||||
let paymentMethod: Stripe.PaymentMethod | undefined;
|
||||
|
||||
const planCurrency = (await this.stripeHelper.findAbbrevPlanById(priceId))
|
||||
.currency;
|
||||
|
||||
const automaticTax =
|
||||
this.stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
||||
customer,
|
||||
planCurrency
|
||||
);
|
||||
|
||||
// Skip the payment source check if there's no payment method id.
|
||||
if (paymentMethodId) {
|
||||
const planCurrency = (
|
||||
await this.stripeHelper.findAbbrevPlanById(priceId)
|
||||
).currency;
|
||||
paymentMethod = await this.stripeHelper.getPaymentMethod(
|
||||
paymentMethodId
|
||||
);
|
||||
|
|
|
@ -2025,6 +2025,16 @@ describe('#integration - StripeHelper', () => {
|
|||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||
.resolves();
|
||||
|
||||
sandbox
|
||||
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||
.returns(true);
|
||||
|
||||
const findAbbrevPlanByIdStub = sandbox
|
||||
.stub(stripeHelper, 'findAbbrevPlanById')
|
||||
.resolves({
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
await stripeHelper.previewInvoice({
|
||||
priceId: 'priceId',
|
||||
taxAddress: {
|
||||
|
@ -2055,6 +2065,57 @@ describe('#integration - StripeHelper', () => {
|
|||
],
|
||||
expand: ['total_tax_amounts.tax_rate'],
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(findAbbrevPlanByIdStub, 'priceId');
|
||||
});
|
||||
|
||||
it('disables stripe tax when currency is incompatible with country', async () => {
|
||||
const stripeStub = sandbox
|
||||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||
.resolves();
|
||||
|
||||
const findAbbrevPlanByIdStub = sandbox
|
||||
.stub(stripeHelper, 'findAbbrevPlanById')
|
||||
.resolves({
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
sandbox
|
||||
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||
.returns(false);
|
||||
|
||||
await stripeHelper.previewInvoice({
|
||||
priceId: 'priceId',
|
||||
taxAddress: {
|
||||
countryCode: 'US',
|
||||
postalCode: '92841',
|
||||
},
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(stripeStub, {
|
||||
customer: undefined,
|
||||
automatic_tax: {
|
||||
enabled: false,
|
||||
},
|
||||
customer_details: {
|
||||
tax_exempt: 'none',
|
||||
shipping: {
|
||||
name: sinon.match.any,
|
||||
address: {
|
||||
country: 'US',
|
||||
postal_code: '92841',
|
||||
},
|
||||
},
|
||||
},
|
||||
subscription_items: [
|
||||
{
|
||||
price: 'priceId',
|
||||
},
|
||||
],
|
||||
expand: ['total_tax_amounts.tax_rate'],
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(findAbbrevPlanByIdStub, 'priceId');
|
||||
});
|
||||
|
||||
it('excludes shipping address when shipping address not passed', async () => {
|
||||
|
@ -2062,6 +2123,10 @@ describe('#integration - StripeHelper', () => {
|
|||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||
.resolves();
|
||||
|
||||
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
await stripeHelper.previewInvoice({
|
||||
priceId: 'priceId',
|
||||
taxAddress: undefined,
|
||||
|
@ -2090,6 +2155,10 @@ describe('#integration - StripeHelper', () => {
|
|||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||
.throws(new Error());
|
||||
|
||||
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
try {
|
||||
await stripeHelper.previewInvoice({
|
||||
priceId: 'priceId',
|
||||
|
@ -2109,6 +2178,10 @@ describe('#integration - StripeHelper', () => {
|
|||
.resolves();
|
||||
sandbox.stub(Math, 'floor').returns(1);
|
||||
|
||||
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
await stripeHelper.previewInvoice({
|
||||
customer: customer1,
|
||||
priceId: 'priceId',
|
||||
|
@ -7690,6 +7763,79 @@ describe('#integration - StripeHelper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isCustomerTaxableWithSubscriptionCurrency', () => {
|
||||
it('returns true when currency is compatible with country and customer is stripe taxable', () => {
|
||||
sandbox
|
||||
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||
.returns(true);
|
||||
|
||||
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
||||
{
|
||||
tax: {
|
||||
automatic_tax: 'supported',
|
||||
location: {
|
||||
country: 'US',
|
||||
},
|
||||
},
|
||||
},
|
||||
'USD'
|
||||
);
|
||||
|
||||
assert.equal(actual, true);
|
||||
});
|
||||
|
||||
it('returns false for a currency not compatible with the tax country', () => {
|
||||
sandbox
|
||||
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||
.returns(false);
|
||||
|
||||
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
||||
{
|
||||
tax: {
|
||||
automatic_tax: 'supported',
|
||||
location: {
|
||||
country: 'US',
|
||||
},
|
||||
},
|
||||
},
|
||||
'USD'
|
||||
);
|
||||
|
||||
assert.equal(actual, false);
|
||||
});
|
||||
|
||||
it('returns false if customer does not have tax location', () => {
|
||||
sandbox
|
||||
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||
.returns(false);
|
||||
|
||||
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
||||
{
|
||||
tax: {
|
||||
automatic_tax: 'supported',
|
||||
location: undefined,
|
||||
},
|
||||
},
|
||||
'USD'
|
||||
);
|
||||
|
||||
assert.equal(actual, false);
|
||||
});
|
||||
|
||||
it('returns false for a customer in a unrecognized location', () => {
|
||||
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency({
|
||||
tax: {
|
||||
automatic_tax: 'unrecognized_location',
|
||||
location: {
|
||||
country: 'US',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(actual, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFirestoreCustomer', () => {
|
||||
it('completes successfully and returns array of deleted paths', async () => {
|
||||
const expected = ['/path', '/path/subpath'];
|
||||
|
|
|
@ -433,7 +433,8 @@ describe('subscriptions payPalRoutes', () => {
|
|||
authDbModule.getAccountCustomerByUid =
|
||||
sinon.fake.resolves(accountCustomer);
|
||||
stripeHelper.updateCustomerPaypalAgreement = sinon.fake.resolves({});
|
||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(true);
|
||||
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||
sinon.fake.returns(true);
|
||||
payPalHelper.processInvoice = sinon.fake.resolves({});
|
||||
payPalHelper.processZeroInvoice = sinon.fake.resolves({});
|
||||
});
|
||||
|
@ -515,7 +516,8 @@ describe('subscriptions payPalRoutes', () => {
|
|||
state: 'Ontario',
|
||||
},
|
||||
};
|
||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(false);
|
||||
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||
sinon.fake.returns(false);
|
||||
const actual = await runTest('/oauth/subscriptions/active/new-paypal', {
|
||||
...requestOptions,
|
||||
payload: { token },
|
||||
|
@ -639,7 +641,8 @@ describe('subscriptions payPalRoutes', () => {
|
|||
};
|
||||
c.subscriptions.data[0].collection_method = 'send_invoice';
|
||||
stripeHelper.fetchCustomer = sinon.fake.resolves(c);
|
||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(true);
|
||||
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||
sinon.fake.returns(true);
|
||||
stripeHelper.getCustomerPaypalAgreement =
|
||||
sinon.fake.returns(paypalAgreementId);
|
||||
payPalHelper.processInvoice = sinon.fake.resolves({});
|
||||
|
|
|
@ -1200,7 +1200,7 @@ describe('DirectStripeRoutes', () => {
|
|||
|
||||
it('creates a subscription with a payment method and promotion code', async () => {
|
||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||
true
|
||||
);
|
||||
directStripeRoutesInstance.extractPromotionCode = sinon.stub().resolves({
|
||||
|
@ -1226,7 +1226,7 @@ describe('DirectStripeRoutes', () => {
|
|||
|
||||
it('creates a subscription with a payment method', async () => {
|
||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||
true
|
||||
);
|
||||
const actual = await directStripeRoutesInstance.createSubscriptionWithPMI(
|
||||
|
@ -1247,7 +1247,7 @@ describe('DirectStripeRoutes', () => {
|
|||
|
||||
it('creates a subscription with a payment method using automatic tax but in an unsupported region', async () => {
|
||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||
false
|
||||
);
|
||||
const actual = await directStripeRoutesInstance.createSubscriptionWithPMI(
|
||||
|
@ -1503,7 +1503,7 @@ describe('DirectStripeRoutes', () => {
|
|||
);
|
||||
const customer = deepCopy(emptyCustomer);
|
||||
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||
true
|
||||
);
|
||||
const expected = deepCopy(subscription2);
|
||||
|
@ -1561,7 +1561,7 @@ describe('DirectStripeRoutes', () => {
|
|||
);
|
||||
const customer = deepCopy(emptyCustomer);
|
||||
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
||||
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||
true
|
||||
);
|
||||
directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves(
|
||||
|
|
Загрузка…
Ссылка в новой задаче