1
0
mirror of https://github.com/bitwarden/server synced 2026-01-04 09:33:40 +00:00

[PM-24350] fix tax calculation (#6251)

This commit is contained in:
Kyle Denney
2025-09-03 10:03:49 -05:00
committed by GitHub
parent fa8d65cc1f
commit ef8c7f656d
26 changed files with 663 additions and 1172 deletions

View File

@@ -22,6 +22,19 @@ public static class BillingExtensions
_ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType")
};
public static bool IsBusinessProductTierType(this PlanType planType)
=> IsBusinessProductTierType(planType.GetProductTier());
public static bool IsBusinessProductTierType(this ProductTierType productTierType)
=> productTierType switch
{
ProductTierType.Free => false,
ProductTierType.Families => false,
ProductTierType.Enterprise => true,
ProductTierType.Teams => true,
ProductTierType.TeamsStarter => true
};
public static bool IsBillable(this Provider provider) =>
provider is
{

View File

@@ -25,9 +25,6 @@ public static class ServiceCollectionExtensions
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.AddKeyedTransient<IAutomaticTaxStrategy, PersonalUseAutomaticTaxStrategy>(AutomaticTaxFactory.PersonalUse);
services.AddKeyedTransient<IAutomaticTaxStrategy, BusinessUseAutomaticTaxStrategy>(AutomaticTaxFactory.BusinessUse);
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
services.AddLicenseServices();
services.AddPricingClient();
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();

View File

@@ -275,7 +275,7 @@ public class OrganizationBillingService(
if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
customerSetup.TaxInformation.Country != "US")
customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates)
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
@@ -514,14 +514,14 @@ public class OrganizationBillingService(
customer = customer switch
{
{ Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await
{ Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await
stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions
{
Expand = expansions,
TaxExempt = StripeConstants.TaxExempt.Reverse
}),
{ Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await
{ Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await
stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions
{

View File

@@ -84,7 +84,7 @@ public class UpdateBillingAddressCommand(
State = billingAddress.State
},
Expand = ["subscriptions", "tax_ids"],
TaxExempt = billingAddress.Country != "US"
TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates
? StripeConstants.TaxExempt.Reverse
: StripeConstants.TaxExempt.None
});

View File

@@ -801,15 +801,13 @@ public class SubscriberService(
_ => false
};
if (isBusinessUseSubscriber)
{
switch (customer)
{
case
{
Address.Country: not "US",
Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates,
TaxExempt: not TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
@@ -817,7 +815,7 @@ public class SubscriberService(
break;
case
{
Address.Country: "US",
Address.Country: Core.Constants.CountryAbbreviations.UnitedStates,
TaxExempt: TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
@@ -840,8 +838,8 @@ public class SubscriberService(
{
User => true,
Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families ||
customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
_ => false
};

View File

@@ -95,17 +95,11 @@ public class PreviewTaxAmountCommand(
}
}
if (planType.GetProductTier() == ProductTierType.Families)
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
if (parameters.PlanType.IsBusinessProductTierType() &&
parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
else
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = options.CustomerDetails.Address.Country == "US" ||
options.CustomerDetails.TaxIds is [_, ..]
};
options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);

View File

@@ -1,11 +0,0 @@
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Tax.Services;
/// <summary>
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.
/// </summary>
public interface IAutomaticTaxFactory
{
Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters);
}

View File

@@ -1,33 +0,0 @@
#nullable enable
using Stripe;
namespace Bit.Core.Billing.Tax.Services;
public interface IAutomaticTaxStrategy
{
/// <summary>
///
/// </summary>
/// <param name="subscription"></param>
/// <returns>
/// Returns <see cref="SubscriptionUpdateOptions" /> if changes are to be applied to the subscription, returns null
/// otherwise.
/// </returns>
SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription);
/// <summary>
/// Modifies an existing <see cref="SubscriptionCreateOptions" /> object with the automatic tax flag set correctly.
/// </summary>
/// <param name="options"></param>
/// <param name="customer"></param>
void SetCreateOptions(SubscriptionCreateOptions options, Customer customer);
/// <summary>
/// Modifies an existing <see cref="SubscriptionUpdateOptions" /> object with the automatic tax flag set correctly.
/// </summary>
/// <param name="options"></param>
/// <param name="subscription"></param>
void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription);
void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options);
}

View File

@@ -1,50 +0,0 @@
#nullable enable
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class AutomaticTaxFactory(
IFeatureService featureService,
IPricingClient pricingClient) : IAutomaticTaxFactory
{
public const string BusinessUse = "business-use";
public const string PersonalUse = "personal-use";
private readonly Lazy<Task<IEnumerable<string>>> _personalUsePlansTask = new(async () =>
{
var plans = await Task.WhenAll(
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually));
return plans.Select(plan => plan.PasswordManager.StripePlanId);
});
public async Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters)
{
if (parameters.Subscriber is User)
{
return new PersonalUseAutomaticTaxStrategy(featureService);
}
if (parameters.PlanType.HasValue)
{
var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value);
return plan.CanBeUsedByBusiness
? new BusinessUseAutomaticTaxStrategy(featureService)
: new PersonalUseAutomaticTaxStrategy(featureService);
}
var personalUsePlans = await _personalUsePlansTask.Value;
if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x)))
{
return new PersonalUseAutomaticTaxStrategy(featureService);
}
return new BusinessUseAutomaticTaxStrategy(featureService);
}
}

View File

@@ -1,96 +0,0 @@
#nullable enable
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return null;
}
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
{
return null;
}
var options = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = shouldBeEnabled
},
DefaultTaxRates = []
};
return options;
}
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
{
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(customer)
};
}
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return;
}
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
{
return;
}
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = shouldBeEnabled
};
options.DefaultTaxRates = [];
}
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
{
options.AutomaticTax ??= new InvoiceAutomaticTaxOptions();
if (options.CustomerDetails.Address.Country == "US")
{
options.AutomaticTax.Enabled = true;
return;
}
options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any();
}
private bool ShouldBeEnabled(Customer customer)
{
if (!customer.HasRecognizedTaxLocation())
{
return false;
}
if (customer.Address.Country == "US")
{
return true;
}
if (customer.TaxIds == null)
{
throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded.");
}
return customer.TaxIds.Any();
}
}

View File

@@ -1,64 +0,0 @@
#nullable enable
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
{
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(customer)
};
}
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return;
}
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(subscription.Customer)
};
options.DefaultTaxRates = [];
}
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
{
return null;
}
if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer))
{
return null;
}
var options = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = ShouldBeEnabled(subscription.Customer),
},
DefaultTaxRates = []
};
return options;
}
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
private static bool ShouldBeEnabled(Customer customer)
{
return customer.HasRecognizedTaxLocation();
}
}

View File

@@ -52,6 +52,19 @@ public static class Constants
/// regardless of whether there is a proration or not.
/// </summary>
public const string AlwaysInvoice = "always_invoice";
/// <summary>
/// Used primarily to determine whether a customer's business is inside or outside the United States
/// for billing purposes.
/// </summary>
public static class CountryAbbreviations
{
/// <summary>
/// Abbreviation for The United States.
/// This value must match what Stripe uses for the `Country` field value for the United States.
/// </summary>
public const string UnitedStates = "US";
}
}
public static class AuthConstants

View File

@@ -13,5 +13,5 @@ public class TaxInfo
public string BillingAddressCity { get; set; }
public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; }
public string BillingAddressCountry { get; set; } = "US";
public string BillingAddressCountry { get; set; } = Constants.CountryAbbreviations.UnitedStates;
}

View File

@@ -9,11 +9,9 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -21,7 +19,6 @@ using Bit.Core.Models.BitStripe;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Stripe;
using PaymentMethod = Stripe.PaymentMethod;
@@ -41,8 +38,6 @@ public class StripePaymentService : IPaymentService
private readonly IFeatureService _featureService;
private readonly ITaxService _taxService;
private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxFactory _automaticTaxFactory;
private readonly IAutomaticTaxStrategy _personalUseTaxStrategy;
public StripePaymentService(
ITransactionRepository transactionRepository,
@@ -52,9 +47,7 @@ public class StripePaymentService : IPaymentService
IGlobalSettings globalSettings,
IFeatureService featureService,
ITaxService taxService,
IPricingClient pricingClient,
IAutomaticTaxFactory automaticTaxFactory,
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy)
IPricingClient pricingClient)
{
_transactionRepository = transactionRepository;
_logger = logger;
@@ -64,8 +57,6 @@ public class StripePaymentService : IPaymentService
_featureService = featureService;
_taxService = taxService;
_pricingClient = pricingClient;
_automaticTaxFactory = automaticTaxFactory;
_personalUseTaxStrategy = personalUseTaxStrategy;
}
private async Task ChangeOrganizationSponsorship(
@@ -137,7 +128,7 @@ public class StripePaymentService : IPaymentService
{
if (sub.Customer is
{
Address.Country: not "US",
Address.Country: not Constants.CountryAbbreviations.UnitedStates,
TaxExempt: not StripeConstants.TaxExempt.Reverse
})
{
@@ -987,8 +978,6 @@ public class StripePaymentService : IPaymentService
}
}
_personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options);
try
{
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
@@ -1152,9 +1141,12 @@ public class StripePaymentService : IPaymentService
}
}
var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan);
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters);
automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options);
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
if (parameters.PasswordManager.Plan.IsBusinessProductTierType() &&
parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates)
{
options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
try
{