mirror of
https://github.com/bitwarden/server
synced 2026-01-06 18:43:36 +00:00
[PM-21092] Set tax exemption to reverse charge for non-US business-use customers (#5812)
* Set automatic tax to enabled and tax exempt to reverse where applicable when ff is on * Fix and add tests * Run dotnet format * Run dotnet format * PM-21745: Resolve defect * PM-21770: Resolve defect * Run dotnet format'
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Enums;
|
||||
@@ -35,16 +35,15 @@ public class OrganizationBillingService(
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
ITaxService taxService,
|
||||
IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService
|
||||
ITaxService taxService) : IOrganizationBillingService
|
||||
{
|
||||
public async Task Finalize(OrganizationSale sale)
|
||||
{
|
||||
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||
|
||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
|
||||
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
|
||||
: await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||
|
||||
@@ -121,7 +120,8 @@ public class OrganizationBillingService(
|
||||
subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
||||
public async Task UpdatePaymentMethod(
|
||||
public async Task
|
||||
UpdatePaymentMethod(
|
||||
Organization organization,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
TaxInformation taxInformation)
|
||||
@@ -151,8 +151,11 @@ public class OrganizationBillingService(
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
Organization organization,
|
||||
CustomerSetup customerSetup)
|
||||
CustomerSetup customerSetup,
|
||||
PlanType? updatedPlanType = null)
|
||||
{
|
||||
var planType = updatedPlanType ?? organization.PlanType;
|
||||
|
||||
var displayName = organization.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
@@ -212,13 +215,24 @@ public class OrganizationBillingService(
|
||||
City = customerSetup.TaxInformation.City,
|
||||
PostalCode = customerSetup.TaxInformation.PostalCode,
|
||||
State = customerSetup.TaxInformation.State,
|
||||
Country = customerSetup.TaxInformation.Country,
|
||||
Country = customerSetup.TaxInformation.Country
|
||||
};
|
||||
|
||||
customerCreateOptions.Tax = new CustomerTaxOptions
|
||||
{
|
||||
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge &&
|
||||
planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
|
||||
customerSetup.TaxInformation.Country != "US")
|
||||
{
|
||||
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId))
|
||||
{
|
||||
var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country,
|
||||
@@ -399,21 +413,68 @@ public class OrganizationBillingService(
|
||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
{
|
||||
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType);
|
||||
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else
|
||||
else if (customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions();
|
||||
subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation();
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled =
|
||||
subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families ||
|
||||
customer.Address.Country == "US" ||
|
||||
customer.TaxIds.Any()
|
||||
};
|
||||
}
|
||||
|
||||
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
}
|
||||
|
||||
private async Task<Customer> GetCustomerWhileEnsuringCorrectTaxExemptionAsync(
|
||||
Organization organization,
|
||||
SubscriptionSetup subscriptionSetup)
|
||||
{
|
||||
var customer = await subscriberService.GetCustomerOrThrow(organization,
|
||||
new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (!setNonUSBusinessUseToReverseCharge || subscriptionSetup.PlanType.GetProductTier() is
|
||||
not (ProductTierType.Teams or
|
||||
ProductTierType.TeamsStarter or
|
||||
ProductTierType.Enterprise))
|
||||
{
|
||||
return customer;
|
||||
}
|
||||
|
||||
List<string> expansions = ["tax", "tax_ids"];
|
||||
|
||||
customer = customer switch
|
||||
{
|
||||
{ Address.Country: not "US", 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
|
||||
stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Expand = expansions,
|
||||
TaxExempt = StripeConstants.TaxExempt.None
|
||||
}),
|
||||
_ => customer
|
||||
};
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
private async Task<bool> IsEligibleForSelfHostAsync(
|
||||
Organization organization)
|
||||
{
|
||||
|
||||
@@ -3,8 +3,6 @@ using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
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;
|
||||
@@ -12,7 +10,6 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using Customer = Stripe.Customer;
|
||||
@@ -24,20 +21,18 @@ using static Utilities;
|
||||
|
||||
public class PremiumUserBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<PremiumUserBillingService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository,
|
||||
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService
|
||||
IUserRepository userRepository) : IPremiumUserBillingService
|
||||
{
|
||||
public async Task Credit(User user, decimal amount)
|
||||
{
|
||||
var customer = await subscriberService.GetCustomer(user);
|
||||
|
||||
// Negative credit represents a balance and all Stripe denomination is in cents.
|
||||
// Negative credit represents a balance, and all Stripe denomination is in cents.
|
||||
var credit = (long)(amount * -100);
|
||||
|
||||
if (customer == null)
|
||||
@@ -184,7 +179,7 @@ public class PremiumUserBillingService(
|
||||
City = customerSetup.TaxInformation.City,
|
||||
PostalCode = customerSetup.TaxInformation.PostalCode,
|
||||
State = customerSetup.TaxInformation.State,
|
||||
Country = customerSetup.TaxInformation.Country,
|
||||
Country = customerSetup.TaxInformation.Country
|
||||
},
|
||||
Description = user.Name,
|
||||
Email = user.Email,
|
||||
@@ -324,6 +319,10 @@ public class PremiumUserBillingService(
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
Customer = customer.Id,
|
||||
Items = subscriptionItemOptionsList,
|
||||
@@ -337,18 +336,6 @@ public class PremiumUserBillingService(
|
||||
OffSession = true
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
{
|
||||
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
||||
};
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
@@ -380,7 +367,7 @@ public class PremiumUserBillingService(
|
||||
City = taxInformation.City,
|
||||
PostalCode = taxInformation.PostalCode,
|
||||
State = taxInformation.State,
|
||||
Country = taxInformation.Country,
|
||||
Country = taxInformation.Country
|
||||
},
|
||||
Expand = ["tax"],
|
||||
Tax = new CustomerTaxOptions
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Entities;
|
||||
@@ -28,8 +31,7 @@ public class SubscriberService(
|
||||
ILogger<SubscriberService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ITaxService taxService,
|
||||
IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService
|
||||
ITaxService taxService) : ISubscriberService
|
||||
{
|
||||
public async Task CancelSubscription(
|
||||
ISubscriber subscriber,
|
||||
@@ -128,7 +130,7 @@ public class SubscriberService(
|
||||
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
|
||||
},
|
||||
Email = subscriber.BillingEmailAddress(),
|
||||
PaymentMethodNonce = paymentMethodNonce,
|
||||
PaymentMethodNonce = paymentMethodNonce
|
||||
});
|
||||
|
||||
if (customerResult.IsSuccess())
|
||||
@@ -482,7 +484,7 @@ public class SubscriberService(
|
||||
|
||||
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First();
|
||||
|
||||
// Find the customer's existing setup intents that should be cancelled.
|
||||
// Find the customer's existing setup intents that should be canceled.
|
||||
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
|
||||
.Where(si =>
|
||||
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
|
||||
@@ -519,7 +521,7 @@ public class SubscriberService(
|
||||
await stripeAdapter.PaymentMethodAttachAsync(token,
|
||||
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
|
||||
|
||||
// Find the customer's existing setup intents that should be cancelled.
|
||||
// Find the customer's existing setup intents that should be canceled.
|
||||
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
|
||||
.Where(si =>
|
||||
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
|
||||
@@ -637,7 +639,8 @@ public class SubscriberService(
|
||||
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInformation.Country,
|
||||
taxInformation.TaxId);
|
||||
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
|
||||
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,53 +657,84 @@ public class SubscriberService(
|
||||
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Country);
|
||||
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
|
||||
|
||||
throw new BadRequestException("billingInvalidTaxIdError");
|
||||
|
||||
default:
|
||||
logger.LogError(e,
|
||||
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Country,
|
||||
customer.Id);
|
||||
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
|
||||
|
||||
throw new BadRequestException("billingTaxIdCreationError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
var subscription =
|
||||
customer.Subscriptions.First(subscription => subscription.Id == subscriber.GatewaySubscriptionId);
|
||||
|
||||
var isBusinessUseSubscriber = subscriber switch
|
||||
{
|
||||
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||
Organization organization => organization.PlanType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families,
|
||||
Provider => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber)
|
||||
{
|
||||
switch (customer)
|
||||
{
|
||||
var subscriptionGetOptions = new SubscriptionGetOptions
|
||||
case
|
||||
{
|
||||
Expand = ["customer.tax", "customer.tax_ids"]
|
||||
};
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
|
||||
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
|
||||
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||
var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||
if (automaticTaxOptions?.AutomaticTax?.Enabled != null)
|
||||
Address.Country: not "US",
|
||||
TaxExempt: not StripeConstants.TaxExempt.Reverse
|
||||
}:
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
|
||||
break;
|
||||
case
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions);
|
||||
}
|
||||
Address.Country: "US",
|
||||
TaxExempt: StripeConstants.TaxExempt.Reverse
|
||||
}:
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None });
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
|
||||
|
||||
if (!subscription.AutomaticTax.Enabled)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var automaticTaxShouldBeEnabled = subscriber switch
|
||||
{
|
||||
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),
|
||||
_ => false
|
||||
};
|
||||
|
||||
return;
|
||||
|
||||
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
|
||||
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
|
||||
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
|
||||
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
||||
if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user