diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index e76b44ac54..d0a3adfef3 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -680,6 +680,8 @@ export class StripeHelper extends StripeHelperBase { }): Promise { 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 diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/paypal.ts b/packages/fxa-auth-server/lib/routes/subscriptions/paypal.ts index 00310604b3..f0c49b1f02 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/paypal.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/paypal.ts @@ -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; + 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 = diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts index e5e30b89f9..66c0f70785 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts @@ -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 ); diff --git a/packages/fxa-auth-server/test/local/payments/stripe.js b/packages/fxa-auth-server/test/local/payments/stripe.js index 8e3b4fe2d8..8f78fdfda0 100644 --- a/packages/fxa-auth-server/test/local/payments/stripe.js +++ b/packages/fxa-auth-server/test/local/payments/stripe.js @@ -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']; diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js b/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js index 9c8594cce1..66dc827199 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js @@ -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({}); diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js index c336edfbc2..2f6b9238ab 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js @@ -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(