From a2e6550b61436a0db572ec70c7b84019c5824695 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:48:59 -0500 Subject: [PATCH] [PM-5766] Enabled Automatic Tax for all customers (#3685) * Removed TaxRate logic when creating or updating a Stripe subscription and replaced it with AutomaticTax enabled flag * Updated Stripe webhook to update subscription to automatically calculate tax * Removed TaxRate unit tests since Stripe now handles tax * Removed test proration logic * Including taxInfo when updating payment method * Adding the address to the upgrade free org flow if it doesn't exist * Fixed failing tests and added a new test to validate that the customer is updated --- src/Billing/Controllers/StripeController.cs | 41 ++---- src/Billing/Services/IStripeFacade.cs | 6 + .../Services/Implementations/StripeFacade.cs | 7 + .../Implementations/OrganizationService.cs | 2 +- .../Implementations/StripePaymentService.cs | 107 +++++--------- .../Services/StripePaymentServiceTests.cs | 131 ++++++++++-------- 6 files changed, 127 insertions(+), 167 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index a0e6206a9..37378184d 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -21,7 +21,6 @@ using Customer = Stripe.Customer; using Event = Stripe.Event; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; -using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; using TransactionType = Bit.Core.Enums.TransactionType; @@ -223,9 +222,17 @@ public class StripeController : Controller $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); } - var updatedSubscription = await VerifyCorrectTaxRateForCharge(invoice, subscription); + if (!subscription.AutomaticTax.Enabled) + { + subscription = await _stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + DefaultTaxRates = new List(), + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } - var (organizationId, userId) = GetIdsFromMetaData(updatedSubscription.Metadata); + var (organizationId, userId) = GetIdsFromMetaData(subscription.Metadata); var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); @@ -246,7 +253,7 @@ public class StripeController : Controller if (organizationId.HasValue) { - if (IsSponsoredSubscription(updatedSubscription)) + if (IsSponsoredSubscription(subscription)) { await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); } @@ -828,32 +835,6 @@ public class StripeController : Controller invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; } - private async Task VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) - { - if (!string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) && !string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) - { - var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = invoice.CustomerAddress.Country, - PostalCode = invoice.CustomerAddress.PostalCode - } - ); - - if (localBitwardenTaxRates.Any()) - { - var stripeTaxRate = await new TaxRateService().GetAsync(localBitwardenTaxRates.First().Id); - if (stripeTaxRate != null && !subscription.DefaultTaxRates.Any(x => x == stripeTaxRate)) - { - subscription.DefaultTaxRates = new List { stripeTaxRate }; - var subscriptionOptions = new SubscriptionUpdateOptions() { DefaultTaxRates = new List() { stripeTaxRate.Id } }; - subscription = await new SubscriptionService().UpdateAsync(subscription.Id, subscriptionOptions); - } - } - } - return subscription; - } - private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index cbe36b6a4..4a49c75ea 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -33,4 +33,10 @@ public interface IStripeFacade SubscriptionGetOptions subscriptionGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task UpdateSubscription( + string subscriptionId, + SubscriptionUpdateOptions subscriptionGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 2ea4d0b93..db6062102 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -44,4 +44,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken); + + public async Task UpdateSubscription( + string subscriptionId, + SubscriptionUpdateOptions subscriptionUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _subscriptionService.UpdateAsync(subscriptionId, subscriptionUpdateOptions, requestOptions, cancellationToken); } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index c3a6a06e6..f97bd7c07 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -140,7 +140,7 @@ public class OrganizationService : IOrganizationService await _paymentService.SaveTaxInfoAsync(organization, taxInfo); var updated = await _paymentService.UpdatePaymentMethodAsync(organization, - paymentMethodType, paymentToken); + paymentMethodType, paymentToken, taxInfo); if (updated) { await ReplaceAndUpdateCacheAsync(organization); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 1aeda8807..cc960083a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -98,23 +98,6 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Payment method is not supported at this time."); } - if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) - { - var taxRateSearch = new TaxRate - { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode - }; - var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); - - // should only be one tax rate per country/zip combo - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - taxInfo.StripeTaxRateId = taxRate.Id; - } - } - var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon , additionalSmSeats, additionalServiceAccount); @@ -163,6 +146,9 @@ public class StripePaymentService : IPaymentService }); subCreateOptions.AddExpand("latest_invoice.payment_intent"); subCreateOptions.Customer = customer.Id; + + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) { @@ -244,25 +230,31 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Could not find customer payment profile."); } - var taxInfo = upgrade.TaxInfo; - if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) + if (customer.Address is null && + !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressCountry) && + !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressPostalCode)) { - var taxRateSearch = new TaxRate + var addressOptions = new Stripe.AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode + Country = upgrade.TaxInfo.BillingAddressCountry, + PostalCode = upgrade.TaxInfo.BillingAddressPostalCode, + // Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead. + Line1 = upgrade.TaxInfo.BillingAddressLine1 ?? string.Empty, + Line2 = upgrade.TaxInfo.BillingAddressLine2, + City = upgrade.TaxInfo.BillingAddressCity, + State = upgrade.TaxInfo.BillingAddressState, }; - var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); - - // should only be one tax rate per country/zip combo - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - taxInfo.StripeTaxRateId = taxRate.Id; - } + var customerUpdateOptions = new Stripe.CustomerUpdateOptions { Address = addressOptions }; + customerUpdateOptions.AddExpand("default_source"); + customerUpdateOptions.AddExpand("invoice_settings.default_payment_method"); + customer = await _stripeAdapter.CustomerUpdateAsync(org.GatewayCustomerId, customerUpdateOptions); } - var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade); + var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade) + { + DefaultTaxRates = new List(), + AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } + }; var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, @@ -459,26 +451,6 @@ public class StripePaymentService : IPaymentService Quantity = 1 }); - if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry) - && !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode)) - { - var taxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode - } - ); - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - subCreateOptions.DefaultTaxRates = new List(1) - { - taxRate.Id - }; - } - } - if (additionalStorageGb > 0) { subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions @@ -488,6 +460,8 @@ public class StripePaymentService : IPaymentService }); } + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer, stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer); @@ -525,7 +499,8 @@ public class StripePaymentService : IPaymentService { Customer = customer.Id, SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates, + AutomaticTax = + new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } }); if (previewInvoice.AmountDue > 0) @@ -583,7 +558,8 @@ public class StripePaymentService : IPaymentService { Customer = customer.Id, SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates, + AutomaticTax = + new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } }); if (previewInvoice.AmountDue > 0) { @@ -593,6 +569,7 @@ public class StripePaymentService : IPaymentService subCreateOptions.OffSession = true; subCreateOptions.AddExpand("latest_invoice.payment_intent"); + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) { @@ -692,6 +669,8 @@ public class StripePaymentService : IPaymentService DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice", ProrationDate = prorationDate, + DefaultTaxRates = new List(), + AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } }; if (!subscriptionUpdate.UpdateNeeded(sub)) @@ -700,28 +679,6 @@ public class StripePaymentService : IPaymentService return null; } - var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); - - if (!string.IsNullOrWhiteSpace(customer?.Address?.Country) - && !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode)) - { - var taxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = customer.Address.Country, - PostalCode = customer.Address.PostalCode - } - ); - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id))) - { - subUpdateOptions.DefaultTaxRates = new List(1) - { - taxRate.Id - }; - } - } - string paymentIntentClientSecret = null; try { diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index ea40f0d00..171fab0fb 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -2,7 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -14,7 +13,6 @@ using Xunit; using Customer = Braintree.Customer; using PaymentMethod = Braintree.PaymentMethod; using PaymentMethodType = Bit.Core.Enums.PaymentMethodType; -using TaxRate = Bit.Core.Entities.TaxRate; namespace Bit.Core.Test.Services; @@ -259,65 +257,6 @@ public class StripePaymentServiceTests )); } - [Theory, BitAutoData] - public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) - { - var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer - { - Id = "C-1", - }); - stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription - { - Id = "S-1", - CurrentPeriodEnd = DateTime.Today.AddDays(10), - }); - sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => - t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) - .Returns(new List { new() { Id = "T-1" } }); - - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); - - Assert.Null(result); - - await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => - s.DefaultTaxRates.Count == 1 && - s.DefaultTaxRates[0] == "T-1" - )); - } - - [Theory, BitAutoData] - public async void PurchaseOrganizationAsync_Stripe_TaxRate_SM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) - { - var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer - { - Id = "C-1", - }); - stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription - { - Id = "S-1", - CurrentPeriodEnd = DateTime.Today.AddDays(10), - }); - sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => - t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) - .Returns(new List { new() { Id = "T-1" } }); - - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 2, 2, - false, taxInfo, false, 2, 2); - - Assert.Null(result); - - await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => - s.DefaultTaxRates.Count == 1 && - s.DefaultTaxRates[0] == "T-1" - )); - } - [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { @@ -678,6 +617,14 @@ public class StripePaymentServiceTests { "btCustomerId", "B-123" }, } }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice { PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, @@ -715,6 +662,14 @@ public class StripePaymentServiceTests { "btCustomerId", "B-123" }, } }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice { PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, @@ -737,4 +692,58 @@ public class StripePaymentServiceTests Assert.Null(result); } + + [Theory, BitAutoData] + public async void UpgradeFreeOrganizationAsync_WhenCustomerHasNoAddress_UpdatesCustomerAddressWithTaxInfo( + SutProvider sutProvider, + Organization organization, + TaxInfo taxInfo) + { + organization.GatewaySubscriptionId = null; + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, + AmountDue = 0 + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); + + var upgrade = new OrganizationUpgrade() + { + AdditionalStorageGb = 1, + AdditionalSeats = 10, + PremiumAccessAddon = false, + TaxInfo = taxInfo, + AdditionalSmSeats = 5, + AdditionalServiceAccounts = 50 + }; + + var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + _ = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, upgrade); + + await stripeAdapter.Received() + .CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(c => + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState)); + } }