Skip to content

Commit

Permalink
Merge pull request #17980 from mozilla/FXA-10593
Browse files Browse the repository at this point in the history
fix(auth): disable stripe tax when currency/country incompatible
  • Loading branch information
julianpoy authored Nov 19, 2024
2 parents 387cf9e + e34d6e9 commit cc70f3d
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 21 deletions.
39 changes: 37 additions & 2 deletions packages/fxa-auth-server/lib/payments/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions packages/fxa-auth-server/lib/routes/subscriptions/paypal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
19 changes: 11 additions & 8 deletions packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -596,9 +596,6 @@ export class StripeHandler {
throw error.unknownCustomer(uid);
}

const automaticTax =
this.stripeHelper.isCustomerStripeTaxEligible(customer);

const {
priceId,
paymentMethodId,
Expand Down Expand Up @@ -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
);
Expand Down
146 changes: 146 additions & 0 deletions packages/fxa-auth-server/test/local/payments/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -2055,13 +2065,68 @@ 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 () => {
const stripeStub = sandbox
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
.resolves();

sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
currency: 'USD',
});

await stripeHelper.previewInvoice({
priceId: 'priceId',
taxAddress: undefined,
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit cc70f3d

Please sign in to comment.