Merge pull request #17980 from mozilla/FXA-10593

fix(auth): disable stripe tax when currency/country incompatible
This commit is contained in:
Julian Poyourow 2024-11-19 09:41:52 -08:00 коммит произвёл GitHub
Родитель 387cf9e11c e34d6e945b
Коммит cc70f3d98c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 213 добавлений и 21 удалений

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

@ -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(