зеркало из 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> {
|
}): Promise<InvoicePreview> {
|
||||||
const params: Stripe.InvoiceRetrieveUpcomingParams = {};
|
const params: Stripe.InvoiceRetrieveUpcomingParams = {};
|
||||||
|
|
||||||
|
const { currency: planCurrency } = await this.findAbbrevPlanById(priceId);
|
||||||
|
|
||||||
if (promotionCode) {
|
if (promotionCode) {
|
||||||
const stripePromotionCode = await this.findValidPromoCode(
|
const stripePromotionCode = await this.findValidPromoCode(
|
||||||
promotionCode,
|
promotionCode,
|
||||||
|
@ -691,8 +693,17 @@ export class StripeHelper extends StripeHelperBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
const automaticTax = !!(
|
const automaticTax = !!(
|
||||||
(customer && this.isCustomerStripeTaxEligible(customer)) ||
|
(customer &&
|
||||||
(!customer && taxAddress)
|
this.isCustomerTaxableWithSubscriptionCurrency(
|
||||||
|
customer,
|
||||||
|
planCurrency
|
||||||
|
)) ||
|
||||||
|
(!customer &&
|
||||||
|
taxAddress &&
|
||||||
|
this.currencyHelper.isCurrencyCompatibleWithCountry(
|
||||||
|
planCurrency,
|
||||||
|
taxAddress.countryCode
|
||||||
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
const shipping =
|
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(
|
async updateSubscriptionAndBackfill(
|
||||||
subscription: Stripe.Subscription,
|
subscription: Stripe.Subscription,
|
||||||
newProps: Stripe.SubscriptionUpdateParams
|
newProps: Stripe.SubscriptionUpdateParams
|
||||||
|
|
|
@ -95,10 +95,15 @@ export class PayPalHandler extends StripeWebhookHandler {
|
||||||
throw error.unknownCustomer(uid);
|
throw error.unknownCustomer(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const automaticTax =
|
|
||||||
this.stripeHelper.isCustomerStripeTaxEligible(customer);
|
|
||||||
|
|
||||||
const { priceId } = request.payload as Record<string, string>;
|
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
|
// Make sure to clean up any subscriptions that may be hanging with no payment
|
||||||
const existingSubscription =
|
const existingSubscription =
|
||||||
|
|
|
@ -243,8 +243,8 @@ export class StripeHandler {
|
||||||
// Stripe does not allow customers to change currency after a currency is set, which
|
// 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)
|
// occurs on initial subscription. (https://stripe.com/docs/billing/customer#payment)
|
||||||
const customer = await this.stripeHelper.fetchCustomer(uid);
|
const customer = await this.stripeHelper.fetchCustomer(uid);
|
||||||
const planCurrency = (await this.stripeHelper.findAbbrevPlanById(planId))
|
const { currency: planCurrency } =
|
||||||
.currency;
|
await this.stripeHelper.findAbbrevPlanById(planId);
|
||||||
if (customer && customer.currency !== planCurrency) {
|
if (customer && customer.currency !== planCurrency) {
|
||||||
throw error.currencyCurrencyMismatch(customer.currency, planCurrency);
|
throw error.currencyCurrencyMismatch(customer.currency, planCurrency);
|
||||||
}
|
}
|
||||||
|
@ -596,9 +596,6 @@ export class StripeHandler {
|
||||||
throw error.unknownCustomer(uid);
|
throw error.unknownCustomer(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const automaticTax =
|
|
||||||
this.stripeHelper.isCustomerStripeTaxEligible(customer);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
priceId,
|
priceId,
|
||||||
paymentMethodId,
|
paymentMethodId,
|
||||||
|
@ -630,11 +627,17 @@ export class StripeHandler {
|
||||||
|
|
||||||
let paymentMethod: Stripe.PaymentMethod | undefined;
|
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.
|
// Skip the payment source check if there's no payment method id.
|
||||||
if (paymentMethodId) {
|
if (paymentMethodId) {
|
||||||
const planCurrency = (
|
|
||||||
await this.stripeHelper.findAbbrevPlanById(priceId)
|
|
||||||
).currency;
|
|
||||||
paymentMethod = await this.stripeHelper.getPaymentMethod(
|
paymentMethod = await this.stripeHelper.getPaymentMethod(
|
||||||
paymentMethodId
|
paymentMethodId
|
||||||
);
|
);
|
||||||
|
|
|
@ -2025,6 +2025,16 @@ describe('#integration - StripeHelper', () => {
|
||||||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||||
.resolves();
|
.resolves();
|
||||||
|
|
||||||
|
sandbox
|
||||||
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
||||||
|
.returns(true);
|
||||||
|
|
||||||
|
const findAbbrevPlanByIdStub = sandbox
|
||||||
|
.stub(stripeHelper, 'findAbbrevPlanById')
|
||||||
|
.resolves({
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
|
||||||
await stripeHelper.previewInvoice({
|
await stripeHelper.previewInvoice({
|
||||||
priceId: 'priceId',
|
priceId: 'priceId',
|
||||||
taxAddress: {
|
taxAddress: {
|
||||||
|
@ -2055,6 +2065,57 @@ describe('#integration - StripeHelper', () => {
|
||||||
],
|
],
|
||||||
expand: ['total_tax_amounts.tax_rate'],
|
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 () => {
|
it('excludes shipping address when shipping address not passed', async () => {
|
||||||
|
@ -2062,6 +2123,10 @@ describe('#integration - StripeHelper', () => {
|
||||||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||||
.resolves();
|
.resolves();
|
||||||
|
|
||||||
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
|
||||||
await stripeHelper.previewInvoice({
|
await stripeHelper.previewInvoice({
|
||||||
priceId: 'priceId',
|
priceId: 'priceId',
|
||||||
taxAddress: undefined,
|
taxAddress: undefined,
|
||||||
|
@ -2090,6 +2155,10 @@ describe('#integration - StripeHelper', () => {
|
||||||
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
||||||
.throws(new Error());
|
.throws(new Error());
|
||||||
|
|
||||||
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stripeHelper.previewInvoice({
|
await stripeHelper.previewInvoice({
|
||||||
priceId: 'priceId',
|
priceId: 'priceId',
|
||||||
|
@ -2109,6 +2178,10 @@ describe('#integration - StripeHelper', () => {
|
||||||
.resolves();
|
.resolves();
|
||||||
sandbox.stub(Math, 'floor').returns(1);
|
sandbox.stub(Math, 'floor').returns(1);
|
||||||
|
|
||||||
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
|
||||||
await stripeHelper.previewInvoice({
|
await stripeHelper.previewInvoice({
|
||||||
customer: customer1,
|
customer: customer1,
|
||||||
priceId: 'priceId',
|
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', () => {
|
describe('removeFirestoreCustomer', () => {
|
||||||
it('completes successfully and returns array of deleted paths', async () => {
|
it('completes successfully and returns array of deleted paths', async () => {
|
||||||
const expected = ['/path', '/path/subpath'];
|
const expected = ['/path', '/path/subpath'];
|
||||||
|
|
|
@ -433,7 +433,8 @@ describe('subscriptions payPalRoutes', () => {
|
||||||
authDbModule.getAccountCustomerByUid =
|
authDbModule.getAccountCustomerByUid =
|
||||||
sinon.fake.resolves(accountCustomer);
|
sinon.fake.resolves(accountCustomer);
|
||||||
stripeHelper.updateCustomerPaypalAgreement = sinon.fake.resolves({});
|
stripeHelper.updateCustomerPaypalAgreement = sinon.fake.resolves({});
|
||||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(true);
|
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||||
|
sinon.fake.returns(true);
|
||||||
payPalHelper.processInvoice = sinon.fake.resolves({});
|
payPalHelper.processInvoice = sinon.fake.resolves({});
|
||||||
payPalHelper.processZeroInvoice = sinon.fake.resolves({});
|
payPalHelper.processZeroInvoice = sinon.fake.resolves({});
|
||||||
});
|
});
|
||||||
|
@ -515,7 +516,8 @@ describe('subscriptions payPalRoutes', () => {
|
||||||
state: 'Ontario',
|
state: 'Ontario',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(false);
|
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||||
|
sinon.fake.returns(false);
|
||||||
const actual = await runTest('/oauth/subscriptions/active/new-paypal', {
|
const actual = await runTest('/oauth/subscriptions/active/new-paypal', {
|
||||||
...requestOptions,
|
...requestOptions,
|
||||||
payload: { token },
|
payload: { token },
|
||||||
|
@ -639,7 +641,8 @@ describe('subscriptions payPalRoutes', () => {
|
||||||
};
|
};
|
||||||
c.subscriptions.data[0].collection_method = 'send_invoice';
|
c.subscriptions.data[0].collection_method = 'send_invoice';
|
||||||
stripeHelper.fetchCustomer = sinon.fake.resolves(c);
|
stripeHelper.fetchCustomer = sinon.fake.resolves(c);
|
||||||
stripeHelper.isCustomerStripeTaxEligible = sinon.fake.returns(true);
|
stripeHelper.isCustomerTaxableWithSubscriptionCurrency =
|
||||||
|
sinon.fake.returns(true);
|
||||||
stripeHelper.getCustomerPaypalAgreement =
|
stripeHelper.getCustomerPaypalAgreement =
|
||||||
sinon.fake.returns(paypalAgreementId);
|
sinon.fake.returns(paypalAgreementId);
|
||||||
payPalHelper.processInvoice = sinon.fake.resolves({});
|
payPalHelper.processInvoice = sinon.fake.resolves({});
|
||||||
|
|
|
@ -1200,7 +1200,7 @@ describe('DirectStripeRoutes', () => {
|
||||||
|
|
||||||
it('creates a subscription with a payment method and promotion code', async () => {
|
it('creates a subscription with a payment method and promotion code', async () => {
|
||||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
directStripeRoutesInstance.extractPromotionCode = sinon.stub().resolves({
|
directStripeRoutesInstance.extractPromotionCode = sinon.stub().resolves({
|
||||||
|
@ -1226,7 +1226,7 @@ describe('DirectStripeRoutes', () => {
|
||||||
|
|
||||||
it('creates a subscription with a payment method', async () => {
|
it('creates a subscription with a payment method', async () => {
|
||||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
const actual = await directStripeRoutesInstance.createSubscriptionWithPMI(
|
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 () => {
|
it('creates a subscription with a payment method using automatic tax but in an unsupported region', async () => {
|
||||||
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
const { sourceCountry, expected } = setupCreateSuccessWithTaxIds();
|
||||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const actual = await directStripeRoutesInstance.createSubscriptionWithPMI(
|
const actual = await directStripeRoutesInstance.createSubscriptionWithPMI(
|
||||||
|
@ -1503,7 +1503,7 @@ describe('DirectStripeRoutes', () => {
|
||||||
);
|
);
|
||||||
const customer = deepCopy(emptyCustomer);
|
const customer = deepCopy(emptyCustomer);
|
||||||
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
||||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
const expected = deepCopy(subscription2);
|
const expected = deepCopy(subscription2);
|
||||||
|
@ -1561,7 +1561,7 @@ describe('DirectStripeRoutes', () => {
|
||||||
);
|
);
|
||||||
const customer = deepCopy(emptyCustomer);
|
const customer = deepCopy(emptyCustomer);
|
||||||
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer);
|
||||||
directStripeRoutesInstance.stripeHelper.isCustomerStripeTaxEligible.returns(
|
directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves(
|
directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves(
|
||||||
|
|
Загрузка…
Ссылка в новой задаче