1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-22 20:11:04 -05: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:
Alex Morask 2025-05-19 14:53:48 -04:00 committed by GitHub
parent a07cce26f3
commit 7b3e2a80f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 846 additions and 601 deletions

View File

@ -8,13 +8,10 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -24,7 +21,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
@ -32,26 +28,22 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IFeatureService featureService, IFeatureService featureService,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient, IPricingClient pricingClient)
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_featureService = featureService; _featureService = featureService;
@ -59,7 +51,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -77,7 +68,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false)) includeProvider: false))
{ {
throw new BadRequestException("Organization must have at least one confirmed owner."); throw new BadRequestException("Organization must have at least one confirmed owner.");
@ -102,7 +93,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
/// <summary> /// <summary>
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled /// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because /// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly, /// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly,
/// we email the organization owners letting them know they need to add a new payment method. /// we email the organization owners letting them know they need to add a new payment method.
/// </summary> /// </summary>
private async Task ResetOrganizationBillingAsync( private async Task ResetOrganizationBillingAsync(
@ -142,15 +133,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{ {
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
} }
else else if (customer.HasRecognizedTaxLocation())
{ {
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{ {
Enabled = true Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
}; };
} }
@ -187,7 +181,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _mailService.SendProviderUpdatePaymentMethod( await _mailService.SendProviderUpdatePaymentMethod(
organization.Id, organization.Id,
organization.Name, organization.Name,
provider.Name, provider.Name!,
organizationOwnerEmails); organizationOwnerEmails);
} }
} }

View File

@ -18,7 +18,6 @@ using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -27,7 +26,6 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Braintree; using Braintree;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
@ -52,8 +50,7 @@ public class ProviderBillingService(
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService, ITaxService taxService)
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService : IProviderBillingService
{ {
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@ -128,7 +125,7 @@ public class ProviderBillingService(
/* /*
* We have to scale the provider's seats before the ProviderOrganization * We have to scale the provider's seats before the ProviderOrganization
* row is inserted so the added organization's seats don't get double counted. * row is inserted so the added organization's seats don't get double-counted.
*/ */
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value); await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
@ -236,7 +233,7 @@ public class ProviderBillingService(
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{ {
Expand = ["tax_ids"] Expand = ["tax", "tax_ids"]
}); });
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@ -284,6 +281,13 @@ public class ProviderBillingService(
] ]
}; };
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id; organization.GatewayCustomerId = customer.Id;
@ -520,6 +524,13 @@ public class ProviderBillingService(
} }
}; };
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{ {
var taxIdType = taxService.GetStripeTaxCode( var taxIdType = taxService.GetStripeTaxCode(
@ -531,6 +542,7 @@ public class ProviderBillingService(
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry, taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber); taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError"); throw new BadRequestException("billingTaxIdTypeInferenceError");
} }
@ -718,14 +730,21 @@ public class ProviderBillingService(
TrialPeriodDays = trialPeriodDays TrialPeriodDays = trialPeriodDays
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var setNonUSBusinessUseToReverseCharge =
{ featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
} if (setNonUSBusinessUseToReverseCharge)
else
{ {
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
} }
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
try try
{ {

View File

@ -1,4 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -8,7 +9,6 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -224,31 +224,115 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{ {
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IAutomaticTaxStrategy>() await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId && options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 && options.DaysUntilDue == 30 &&
options.Metadata["organizationId"] == organization.Id.ToString() && options.AutomaticTax.Enabled == true &&
options.OffSession == true && options.Metadata["organizationId"] == organization.Id.ToString() &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && options.OffSession == true &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Quantity == organization.Seats) options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
, Arg.Any<Customer>())) options.Items.First().Quantity == organization.Seats));
.Do(x =>
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_ReverseCharge_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
provider.Status = ProviderStatusType.Billable;
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{ {
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions Id = "customer_id",
Address = new Address
{ {
Enabled = true Country = "US"
}; }
}); });
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -262,7 +262,7 @@ public class ProviderBillingServiceTests
}; };
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>( sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.FirstOrDefault() == "tax_ids")) options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer); .Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
@ -312,6 +312,91 @@ public class ProviderBillingServiceTests
org => org.GatewayCustomerId == "customer_id")); org => org.GatewayCustomerId == "customer_id"));
} }
[Theory, BitAutoData]
public async Task CreateCustomer_ForClientOrg_ReverseCharge_Succeeds(
Provider provider,
Organization organization,
SutProvider<ProviderBillingService> sutProvider)
{
organization.GatewayCustomerId = null;
organization.Name = "Name";
organization.BusinessName = "BusinessName";
var providerCustomer = new Customer
{
Address = new Address
{
Country = "CA",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Unit 4",
City = "Fake Town",
State = "Fake State"
},
TaxIds = new StripeList<TaxId>
{
Data =
[
new TaxId { Type = "TYPE", Value = "VALUE" }
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings())
{
CloudRegion = "US"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value &&
options.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(new Customer { Id = "customer_id" });
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value));
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.GatewayCustomerId == "customer_id"));
}
#endregion #endregion
#region GenerateClientInvoiceReport #region GenerateClientInvoiceReport
@ -1182,6 +1267,62 @@ public class ProviderBillingServiceTests
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
} }
[Theory, BitAutoData]
public async Task SetupCustomer_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
@ -1307,7 +1448,7 @@ public class ProviderBillingServiceTests
.Returns(new Customer .Returns(new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Address = new Address { Country = "US" }
}); });
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
@ -1359,7 +1500,7 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Address = new Address { Country = "US" }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow( .GetCustomerOrThrow(
@ -1399,19 +1540,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&
@ -1443,11 +1571,11 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings InvoiceSettings = new CustomerInvoiceSettings
{ {
DefaultPaymentMethodId = "pm_123" DefaultPaymentMethodId = "pm_123"
}, }
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1488,19 +1616,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
@ -1536,9 +1651,9 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings(), InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>(), Metadata = new Dictionary<string, string>()
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1579,19 +1694,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
@ -1646,12 +1748,15 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address
{
Country = "US"
},
InvoiceSettings = new CustomerInvoiceSettings(), InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
["btCustomerId"] = "braintree_customer_id" ["btCustomerId"] = "braintree_customer_id"
}, }
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1692,22 +1797,92 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>() sutProvider.GetDependency<IFeatureService>()
.When(x => x.SetCreateOptions( .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id") sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
, Arg.Is<Customer>(p => p == customer))) sub =>
.Do(x => sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
sub.Customer == "customer_id" &&
sub.DaysUntilDue == null &&
sub.Items.Count == 2 &&
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
sub.Items.ElementAt(0).Quantity == 100 &&
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
sub.Items.ElementAt(1).Quantity == 100 &&
sub.Metadata["providerId"] == provider.Id.ToString() &&
sub.OffSession == true &&
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
sub.TrialPeriodDays == 14)).Returns(expected);
var actual = await sutProvider.Sut.SetupSubscription(provider);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupSubscription_ReverseCharge_Succeeds(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address { Country = "CA" },
InvoiceSettings = new CustomerInvoiceSettings
{ {
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions DefaultPaymentMethodId = "pm_123"
{ }
Enabled = true };
};
}); sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 0
},
new()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 0
}
};
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&

View File

@ -109,28 +109,6 @@ public class OrganizationsController(
return license; return license;
} }
[HttpPost("{id:guid}/payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model)
{
if (!await currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken,
model.PaymentMethodType.Value, new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressState = model.State,
BillingAddressCity = model.City,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
TaxIdNumber = model.TaxId,
});
}
[HttpPost("{id:guid}/upgrade")] [HttpPost("{id:guid}/upgrade")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model)

View File

@ -1,11 +1,11 @@
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -25,8 +25,7 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository, IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand, IValidateSponsorshipCommand validateSponsorshipCommand)
IAutomaticTaxFactory automaticTaxFactory)
: IUpcomingInvoiceHandler : IUpcomingInvoiceHandler
{ {
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
@ -46,6 +45,8 @@ public class UpcomingInvoiceHandler(
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
var organization = await organizationRepository.GetByIdAsync(organizationId.Value); var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
@ -55,7 +56,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@ -100,7 +101,25 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
if (user.Premium) if (user.Premium)
{ {
@ -116,7 +135,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice); await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
} }
@ -139,50 +158,123 @@ public class UpcomingInvoiceHandler(
} }
} }
private async Task TryEnableAutomaticTaxAsync(Subscription subscription) private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{ {
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var nonUSBusinessUse =
{ organization.PlanType.GetProductTier() != ProductTierType.Families &&
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id)); subscription.Customer.Address.Country != "US";
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (updateOptions == null) bool setAutomaticTaxToEnabled;
if (setNonUSBusinessUseToReverseCharge)
{
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{ {
return; try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
} }
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); setAutomaticTaxToEnabled = true;
return;
} }
else
if (subscription.AutomaticTax.Enabled ||
!subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
{ {
return; setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
(nonUSBusinessUse && subscription.Customer.TaxIds.Any()));
} }
await stripeFacade.UpdateSubscription(subscription.Id, if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
new SubscriptionUpdateOptions {
try
{ {
DefaultTaxRates = [], await stripeFacade.UpdateSubscription(subscription.Id,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } new SubscriptionUpdateOptions
}); {
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
}
return; private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{
bool setAutomaticTaxToEnabled;
async Task<bool> IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) if (setNonUSBusinessUseToReverseCharge)
{ {
var familyPriceIds = (await Task.WhenAll( if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), {
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) try
.Select(plan => plan.PasswordManager.StripePlanId); {
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
return localSubscription.Customer.Address.Country != "US" && setAutomaticTaxToEnabled = true;
localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && }
!localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() && else
!localSubscription.Customer.TaxIds.Any(); {
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
subscription.Customer.TaxIds.Any());
}
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
} }
} }
} }

View File

@ -11,8 +11,6 @@ namespace Bit.Core.Services;
public interface IOrganizationService public interface IOrganizationService
{ {
Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, PaymentMethodType paymentMethodType,
TaxInfo taxInfo);
Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null);
Task ReinstateSubscriptionAsync(Guid organizationId); Task ReinstateSubscriptionAsync(Guid organizationId);
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb);

View File

@ -144,27 +144,6 @@ public class OrganizationService : IOrganizationService
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
} }
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
PaymentMethodType paymentMethodType, TaxInfo taxInfo)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _paymentService.SaveTaxInfoAsync(organization, taxInfo);
var updated = await _paymentService.UpdatePaymentMethodAsync(
organization,
paymentMethodType,
paymentToken,
taxInfo);
if (updated)
{
await ReplaceAndUpdateCacheAsync(organization);
}
}
public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null) public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null)
{ {
var organization = await GetOrgById(organizationId); var organization = await GetOrgById(organizationId);

View File

@ -2,10 +2,6 @@
public static class StripeConstants public static class StripeConstants
{ {
public static class Prices
{
public const string StoragePlanPersonal = "personal-storage-gb-annually";
}
public static class AutomaticTaxStatus public static class AutomaticTaxStatus
{ {
public const string Failed = "failed"; public const string Failed = "failed";
@ -69,6 +65,11 @@ public static class StripeConstants
public const string USBankAccount = "us_bank_account"; public const string USBankAccount = "us_bank_account";
} }
public static class Prices
{
public const string StoragePlanPersonal = "personal-storage-gb-annually";
}
public static class ProrationBehavior public static class ProrationBehavior
{ {
public const string AlwaysInvoice = "always_invoice"; public const string AlwaysInvoice = "always_invoice";
@ -88,6 +89,13 @@ public static class StripeConstants
public const string Paused = "paused"; public const string Paused = "paused";
} }
public static class TaxExempt
{
public const string Exempt = "exempt";
public const string None = "none";
public const string Reverse = "reverse";
}
public static class ValidateTaxLocationTiming public static class ValidateTaxLocationTiming
{ {
public const string Deferred = "deferred"; public const string Deferred = "deferred";

View File

@ -15,12 +15,7 @@ public static class CustomerExtensions
} }
}; };
/// <summary> public static bool HasRecognizedTaxLocation(this Customer customer) =>
/// Determines if a Stripe customer supports automatic tax
/// </summary>
/// <param name="customer"></param>
/// <returns></returns>
public static bool HasTaxLocationVerified(this Customer customer) =>
customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation; customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation;
public static decimal GetBillingBalance(this Customer customer) public static decimal GetBillingBalance(this Customer customer)

View File

@ -22,7 +22,7 @@ public static class SubscriptionUpdateOptionsExtensions
} }
// We might only need to check the automatic tax status. // We might only need to check the automatic tax status.
if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country))
{ {
return false; return false;
} }

View File

@ -22,7 +22,7 @@ public static class UpcomingInvoiceOptionsExtensions
} }
// We might only need to check the automatic tax status. // We might only need to check the automatic tax status.
if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country))
{ {
return false; return false;
} }

View File

@ -1,11 +1,11 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -35,16 +35,15 @@ public class OrganizationBillingService(
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService, ITaxService taxService) : IOrganizationBillingService
IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService
{ {
public async Task Finalize(OrganizationSale sale) public async Task Finalize(OrganizationSale sale)
{ {
var (organization, customerSetup, subscriptionSetup) = sale; var (organization, customerSetup, subscriptionSetup) = sale;
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
? await CreateCustomerAsync(organization, customerSetup) ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
@ -121,7 +120,8 @@ public class OrganizationBillingService(
subscription.CurrentPeriodEnd); subscription.CurrentPeriodEnd);
} }
public async Task UpdatePaymentMethod( public async Task
UpdatePaymentMethod(
Organization organization, Organization organization,
TokenizedPaymentSource tokenizedPaymentSource, TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation) TaxInformation taxInformation)
@ -151,8 +151,11 @@ public class OrganizationBillingService(
private async Task<Customer> CreateCustomerAsync( private async Task<Customer> CreateCustomerAsync(
Organization organization, Organization organization,
CustomerSetup customerSetup) CustomerSetup customerSetup,
PlanType? updatedPlanType = null)
{ {
var planType = updatedPlanType ?? organization.PlanType;
var displayName = organization.DisplayName(); var displayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
@ -212,13 +215,24 @@ public class OrganizationBillingService(
City = customerSetup.TaxInformation.City, City = customerSetup.TaxInformation.City,
PostalCode = customerSetup.TaxInformation.PostalCode, PostalCode = customerSetup.TaxInformation.PostalCode,
State = customerSetup.TaxInformation.State, State = customerSetup.TaxInformation.State,
Country = customerSetup.TaxInformation.Country, Country = customerSetup.TaxInformation.Country
}; };
customerCreateOptions.Tax = new CustomerTaxOptions customerCreateOptions.Tax = new CustomerTaxOptions
{ {
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately 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)) if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId))
{ {
var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country,
@ -399,21 +413,68 @@ public class OrganizationBillingService(
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays 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); subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
} }
else else if (customer.HasRecognizedTaxLocation())
{ {
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions(); subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation(); {
Enabled =
subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families ||
customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
} }
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); 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( private async Task<bool> IsEligibleForSelfHostAsync(
Organization organization) Organization organization)
{ {

View File

@ -3,8 +3,6 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models; 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.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -12,7 +10,6 @@ using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Braintree; using Braintree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using Customer = Stripe.Customer; using Customer = Stripe.Customer;
@ -24,20 +21,18 @@ using static Utilities;
public class PremiumUserBillingService( public class PremiumUserBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<PremiumUserBillingService> logger, ILogger<PremiumUserBillingService> logger,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserRepository userRepository, IUserRepository userRepository) : IPremiumUserBillingService
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService
{ {
public async Task Credit(User user, decimal amount) public async Task Credit(User user, decimal amount)
{ {
var customer = await subscriberService.GetCustomer(user); 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); var credit = (long)(amount * -100);
if (customer == null) if (customer == null)
@ -184,7 +179,7 @@ public class PremiumUserBillingService(
City = customerSetup.TaxInformation.City, City = customerSetup.TaxInformation.City,
PostalCode = customerSetup.TaxInformation.PostalCode, PostalCode = customerSetup.TaxInformation.PostalCode,
State = customerSetup.TaxInformation.State, State = customerSetup.TaxInformation.State,
Country = customerSetup.TaxInformation.Country, Country = customerSetup.TaxInformation.Country
}, },
Description = user.Name, Description = user.Name,
Email = user.Email, Email = user.Email,
@ -324,6 +319,10 @@ public class PremiumUserBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id, Customer = customer.Id,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
@ -337,18 +336,6 @@ public class PremiumUserBillingService(
OffSession = true 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); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (usingPayPal) if (usingPayPal)
@ -380,7 +367,7 @@ public class PremiumUserBillingService(
City = taxInformation.City, City = taxInformation.City,
PostalCode = taxInformation.PostalCode, PostalCode = taxInformation.PostalCode,
State = taxInformation.State, State = taxInformation.State,
Country = taxInformation.Country, Country = taxInformation.Country
}, },
Expand = ["tax"], Expand = ["tax"],
Tax = new CustomerTaxOptions Tax = new CustomerTaxOptions

View File

@ -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.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -28,8 +31,7 @@ public class SubscriberService(
ILogger<SubscriberService> logger, ILogger<SubscriberService> logger,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ITaxService taxService, ITaxService taxService) : ISubscriberService
IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService
{ {
public async Task CancelSubscription( public async Task CancelSubscription(
ISubscriber subscriber, ISubscriber subscriber,
@ -128,7 +130,7 @@ public class SubscriberService(
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
}, },
Email = subscriber.BillingEmailAddress(), Email = subscriber.BillingEmailAddress(),
PaymentMethodNonce = paymentMethodNonce, PaymentMethodNonce = paymentMethodNonce
}); });
if (customerResult.IsSuccess()) if (customerResult.IsSuccess())
@ -482,7 +484,7 @@ public class SubscriberService(
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); 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) var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
.Where(si => .Where(si =>
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
@ -519,7 +521,7 @@ public class SubscriberService(
await stripeAdapter.PaymentMethodAttachAsync(token, await stripeAdapter.PaymentMethodAttachAsync(token,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); 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) var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
.Where(si => .Where(si =>
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); 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}'.", logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInformation.Country, taxInformation.Country,
taxInformation.TaxId); 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}'.", logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInformation.TaxId, taxInformation.TaxId,
taxInformation.Country); taxInformation.Country);
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
throw new BadRequestException("billingInvalidTaxIdError");
default: default:
logger.LogError(e, logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInformation.TaxId, taxInformation.TaxId,
taxInformation.Country, taxInformation.Country,
customer.Id); 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"] Address.Country: not "US",
}; TaxExempt: not StripeConstants.TaxExempt.Reverse
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); }:
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id)); await stripeAdapter.CustomerUpdateAsync(customer.Id,
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription); break;
if (automaticTaxOptions?.AutomaticTax?.Enabled != null) 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 (!subscription.AutomaticTax.Enabled)
{
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
{ {
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
new SubscriptionUpdateOptions new SubscriptionUpdateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } 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; if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled)
{
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer) await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) && new SubscriptionUpdateOptions
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) && {
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
} }
} }

View File

@ -76,7 +76,7 @@ public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : I
private bool ShouldBeEnabled(Customer customer) private bool ShouldBeEnabled(Customer customer)
{ {
if (!customer.HasTaxLocationVerified()) if (!customer.HasRecognizedTaxLocation())
{ {
return false; return false;
} }

View File

@ -59,6 +59,6 @@ public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : I
private static bool ShouldBeEnabled(Customer customer) private static bool ShouldBeEnabled(Customer customer)
{ {
return customer.HasTaxLocationVerified(); return customer.HasRecognizedTaxLocation();
} }
} }

View File

@ -143,13 +143,13 @@ public static class FeatureFlagKeys
public const string UsePricingService = "use-pricing-service"; public const string UsePricingService = "use-pricing-service";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string UseOrganizationWarningsService = "use-organization-warnings-service";
public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0";
public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge";
/* Data Insights and Reporting Team */ /* Data Insights and Reporting Team */
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";

View File

@ -4,7 +4,6 @@ using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Responses;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore; using Bit.Core.Models.StaticStore;
@ -30,8 +29,6 @@ public interface IPaymentService
Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts); Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts);
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false);
Task ReinstateSubscriptionAsync(ISubscriber subscriber); Task ReinstateSubscriptionAsync(ISubscriber subscriber);
Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
string paymentToken, TaxInfo taxInfo = null);
Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount); Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount);
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber); Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
Task<BillingHistoryInfo> GetBillingHistoryAsync(ISubscriber subscriber); Task<BillingHistoryInfo> GetBillingHistoryAsync(ISubscriber subscriber);

View File

@ -1,13 +1,13 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Responses;
using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services;
@ -38,7 +38,6 @@ public class StripePaymentService : IPaymentService
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ITaxService _taxService; private readonly ITaxService _taxService;
private readonly ISubscriberService _subscriberService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxFactory _automaticTaxFactory; private readonly IAutomaticTaxFactory _automaticTaxFactory;
private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; private readonly IAutomaticTaxStrategy _personalUseTaxStrategy;
@ -51,7 +50,6 @@ public class StripePaymentService : IPaymentService
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IFeatureService featureService, IFeatureService featureService,
ITaxService taxService, ITaxService taxService,
ISubscriberService subscriberService,
IPricingClient pricingClient, IPricingClient pricingClient,
IAutomaticTaxFactory automaticTaxFactory, IAutomaticTaxFactory automaticTaxFactory,
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy)
@ -63,7 +61,6 @@ public class StripePaymentService : IPaymentService
_globalSettings = globalSettings; _globalSettings = globalSettings;
_featureService = featureService; _featureService = featureService;
_taxService = taxService; _taxService = taxService;
_subscriberService = subscriberService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxFactory = automaticTaxFactory; _automaticTaxFactory = automaticTaxFactory;
_personalUseTaxStrategy = personalUseTaxStrategy; _personalUseTaxStrategy = personalUseTaxStrategy;
@ -136,15 +133,68 @@ public class StripePaymentService : IPaymentService
if (subscriptionUpdate is CompleteSubscriptionUpdate) if (subscriptionUpdate is CompleteSubscriptionUpdate)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var setNonUSBusinessUseToReverseCharge =
_featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{ {
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, updatedItemOptions.Select(x => x.Plan ?? x.Price)); if (sub.Customer is
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters); {
automaticTaxStrategy.SetUpdateOptions(subUpdateOptions, sub); Address.Country: not "US",
TaxExempt: not StripeConstants.TaxExempt.Reverse
})
{
await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
} }
else else if (sub.Customer.HasRecognizedTaxLocation())
{ {
subUpdateOptions.EnableAutomaticTax(sub.Customer, sub); switch (subscriber)
{
case User:
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
break;
}
case Organization:
{
if (sub.Customer.Address.Country == "US")
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else
{
var familyPriceIds = (await Task.WhenAll(
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)))
.Select(plan => plan.PasswordManager.StripePlanId);
var updateIsForPersonalUse = updatedItemOptions
.Select(option => option.Price)
.Intersect(familyPriceIds)
.Any();
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = updateIsForPersonalUse || sub.Customer.TaxIds.Any()
};
}
break;
}
case Provider:
{
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = sub.Customer.Address.Country == "US" ||
sub.Customer.TaxIds.Any()
};
break;
}
}
} }
} }
@ -202,7 +252,7 @@ public class StripePaymentService : IPaymentService
} }
else if (!invoice.Paid) else if (!invoice.Paid)
{ {
// Pay invoice with no charge to customer this completes the invoice immediately without waiting the scheduled 1h // Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h
invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId);
paymentIntentClientSecret = null; paymentIntentClientSecret = null;
} }
@ -585,309 +635,6 @@ public class StripePaymentService : IPaymentService
} }
} }
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
string paymentToken, TaxInfo taxInfo = null)
{
if (subscriber == null)
{
throw new ArgumentNullException(nameof(subscriber));
}
if (subscriber.Gateway.HasValue && subscriber.Gateway.Value != GatewayType.Stripe)
{
throw new GatewayException("Switching from one payment type to another is not supported. " +
"Contact us for assistance.");
}
var createdCustomer = false;
Braintree.Customer braintreeCustomer = null;
string stipeCustomerSourceToken = null;
string stipeCustomerPaymentMethodId = null;
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount;
Customer customer = null;
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
var options = new CustomerGetOptions { Expand = ["sources", "tax", "subscriptions"] };
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, options);
if (customer.Metadata?.Any() ?? false)
{
stripeCustomerMetadata = customer.Metadata;
}
}
var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId");
if (stripePaymentMethod)
{
if (paymentToken.StartsWith("pm_"))
{
stipeCustomerPaymentMethodId = paymentToken;
}
else
{
stipeCustomerSourceToken = paymentToken;
}
}
else if (paymentMethodType == PaymentMethodType.PayPal)
{
if (hadBtCustomer)
{
var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest
{
CustomerId = stripeCustomerMetadata["btCustomerId"],
PaymentMethodNonce = paymentToken
});
if (pmResult.IsSuccess())
{
var customerResult = await _btGateway.Customer.UpdateAsync(
stripeCustomerMetadata["btCustomerId"], new Braintree.CustomerRequest
{
DefaultPaymentMethodToken = pmResult.Target.Token
});
if (customerResult.IsSuccess() && customerResult.Target.PaymentMethods.Length > 0)
{
braintreeCustomer = customerResult.Target;
}
else
{
await _btGateway.PaymentMethod.DeleteAsync(pmResult.Target.Token);
hadBtCustomer = false;
}
}
else
{
hadBtCustomer = false;
}
}
if (!hadBtCustomer)
{
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
{
PaymentMethodNonce = paymentToken,
Email = subscriber.BillingEmailAddress(),
Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() +
Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false),
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion
}
});
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
{
throw new GatewayException("Failed to create PayPal customer record.");
}
braintreeCustomer = customerResult.Target;
}
}
else
{
throw new GatewayException("Payment method is not supported at this time.");
}
if (stripeCustomerMetadata.ContainsKey("btCustomerId"))
{
if (braintreeCustomer?.Id != stripeCustomerMetadata["btCustomerId"])
{
stripeCustomerMetadata["btCustomerId_old"] = stripeCustomerMetadata["btCustomerId"];
}
stripeCustomerMetadata["btCustomerId"] = braintreeCustomer?.Id;
}
else if (!string.IsNullOrWhiteSpace(braintreeCustomer?.Id))
{
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
}
try
{
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
{
taxInfo.TaxIdType = taxInfo.TaxIdType ??
_taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
}
if (customer == null)
{
customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions
{
Description = subscriber.BillingName(),
Email = subscriber.BillingEmailAddress(),
Metadata = stripeCustomerMetadata,
Source = stipeCustomerSourceToken,
PaymentMethod = stipeCustomerPaymentMethodId,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = subscriber.GetFormattedInvoiceName()
}
]
},
Address = taxInfo == null ? null : new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
},
TaxIdData = string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)
? []
: [
new CustomerTaxIdDataOptions
{
Type = taxInfo.TaxIdType,
Value = taxInfo.TaxIdNumber
}
],
Expand = ["sources", "tax", "subscriptions"],
});
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = customer.Id;
createdCustomer = true;
}
if (!createdCustomer)
{
string defaultSourceId = null;
string defaultPaymentMethodId = null;
if (stripePaymentMethod)
{
if (!string.IsNullOrWhiteSpace(stipeCustomerSourceToken) && paymentToken.StartsWith("btok_"))
{
var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new BankAccountCreateOptions
{
Source = paymentToken
});
defaultSourceId = bankAccount.Id;
}
else if (!string.IsNullOrWhiteSpace(stipeCustomerPaymentMethodId))
{
await _stripeAdapter.PaymentMethodAttachAsync(stipeCustomerPaymentMethodId,
new PaymentMethodAttachOptions { Customer = customer.Id });
defaultPaymentMethodId = stipeCustomerPaymentMethodId;
}
}
if (customer.Sources != null)
{
foreach (var source in customer.Sources.Where(s => s.Id != defaultSourceId))
{
if (source is BankAccount)
{
await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
}
else if (source is Card)
{
await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
}
}
}
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new PaymentMethodListOptions
{
Customer = customer.Id,
Type = "card"
});
foreach (var cardMethod in cardPaymentMethods.Where(m => m.Id != defaultPaymentMethodId))
{
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions());
}
await _subscriberService.UpdateTaxInformation(subscriber, TaxInformation.From(taxInfo));
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = stripeCustomerMetadata,
DefaultSource = defaultSourceId,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = defaultPaymentMethodId,
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = subscriber.GetFormattedInvoiceName()
}
]
},
Expand = ["tax", "subscriptions"]
});
}
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
var subscriptionGetOptions = new SubscriptionGetOptions
{
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 subscriptionUpdateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (subscriptionUpdateOptions != null)
{
_ = await _stripeAdapter.SubscriptionUpdateAsync(
subscriber.GatewaySubscriptionId,
subscriptionUpdateOptions);
}
}
}
else
{
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) &&
customer.Subscriptions.Any(sub =>
sub.Id == subscriber.GatewaySubscriptionId &&
!sub.AutomaticTax.Enabled) &&
customer.HasTaxLocationVerified())
{
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
DefaultTaxRates = []
};
_ = await _stripeAdapter.SubscriptionUpdateAsync(
subscriber.GatewaySubscriptionId,
subscriptionUpdateOptions);
}
}
}
catch
{
if (braintreeCustomer != null && !hadBtCustomer)
{
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
}
throw;
}
return createdCustomer;
}
public async Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount) public async Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount)
{ {
Customer customer = null; Customer customer = null;
@ -1018,7 +765,7 @@ public class StripePaymentService : IPaymentService
var address = customer.Address; var address = customer.Address;
var taxId = customer.TaxIds?.FirstOrDefault(); var taxId = customer.TaxIds?.FirstOrDefault();
// Line1 is required, so if missing we're using the subscriber name // Line1 is required, so if missing we're using the subscriber name,
// see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1
if (address != null && string.IsNullOrWhiteSpace(address.Line1)) if (address != null && string.IsNullOrWhiteSpace(address.Line1))
{ {

View File

@ -3,14 +3,11 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Test.Billing.Tax.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Braintree; using Braintree;
@ -195,7 +192,7 @@ public class SubscriberServiceTests
await stripeAdapter await stripeAdapter
.DidNotReceiveWithAnyArgs() .DidNotReceiveWithAnyArgs()
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>()); ; .SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
} }
#endregion #endregion
@ -1029,7 +1026,7 @@ public class SubscriberServiceTests
stripeAdapter stripeAdapter
.PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>()) .PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>())
.Returns(GetPaymentMethodsAsync(new List<Stripe.PaymentMethod>())); .Returns(GetPaymentMethodsAsync(new List<PaymentMethod>()));
await sutProvider.Sut.RemovePaymentSource(organization); await sutProvider.Sut.RemovePaymentSource(organization);
@ -1061,7 +1058,7 @@ public class SubscriberServiceTests
stripeAdapter stripeAdapter
.PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>()) .PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>())
.Returns(GetPaymentMethodsAsync(new List<Stripe.PaymentMethod> .Returns(GetPaymentMethodsAsync(new List<PaymentMethod>
{ {
new () new ()
{ {
@ -1086,8 +1083,8 @@ public class SubscriberServiceTests
.PaymentMethodDetachAsync(cardId); .PaymentMethodDetachAsync(cardId);
} }
private static async IAsyncEnumerable<Stripe.PaymentMethod> GetPaymentMethodsAsync( private static async IAsyncEnumerable<PaymentMethod> GetPaymentMethodsAsync(
IEnumerable<Stripe.PaymentMethod> paymentMethods) IEnumerable<PaymentMethod> paymentMethods)
{ {
foreach (var paymentMethod in paymentMethods) foreach (var paymentMethod in paymentMethods)
{ {
@ -1598,14 +1595,22 @@ public class SubscriberServiceTests
City = "Example Town", City = "Example Town",
State = "NY" State = "NY"
}, },
TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] },
Subscriptions = new StripeList<Subscription>
{
Data = [
new Subscription
{
Id = provider.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
}); });
var subscription = new Subscription { Items = new StripeList<SubscriptionItem>() }; var subscription = new Subscription { Items = new StripeList<SubscriptionItem>() };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(Arg.Any<string>()) sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(Arg.Any<string>())
.Returns(subscription); .Returns(subscription);
sutProvider.GetDependency<IAutomaticTaxFactory>().CreateAsync(Arg.Any<AutomaticTaxFactoryParameters>())
.Returns(new FakeAutomaticTaxStrategy(true));
await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation);
@ -1623,6 +1628,98 @@ public class SubscriberServiceTests
await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is<TaxIdCreateOptions>( await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options => options.Type == "us_ein" &&
options.Value == taxInformation.TaxId)); options.Value == taxInformation.TaxId));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Theory, BitAutoData]
public async Task UpdateTaxInformation_NonUser_ReverseCharge_MakesCorrectInvocations(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } };
stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is<CustomerGetOptions>(
options => options.Expand.Contains("tax_ids"))).Returns(customer);
var taxInformation = new TaxInformation(
"CA",
"12345",
"123456789",
"us_ein",
"123 Example St.",
null,
"Example Town",
"NY");
sutProvider.GetDependency<IStripeAdapter>()
.CustomerUpdateAsync(
Arg.Is<string>(p => p == provider.GatewayCustomerId),
Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Country == "CA" &&
options.Address.PostalCode == "12345" &&
options.Address.Line1 == "123 Example St." &&
options.Address.Line2 == null &&
options.Address.City == "Example Town" &&
options.Address.State == "NY"))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Address = new Address
{
Country = "CA",
PostalCode = "12345",
Line1 = "123 Example St.",
Line2 = null,
City = "Example Town",
State = "NY"
},
TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] },
Subscriptions = new StripeList<Subscription>
{
Data = [
new Subscription
{
Id = provider.GatewaySubscriptionId,
CustomerId = provider.GatewayCustomerId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
});
var subscription = new Subscription { Items = new StripeList<SubscriptionItem>() };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(Arg.Any<string>())
.Returns(subscription);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation);
await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options =>
options.Address.Country == taxInformation.Country &&
options.Address.PostalCode == taxInformation.PostalCode &&
options.Address.Line1 == taxInformation.Line1 &&
options.Address.Line2 == taxInformation.Line2 &&
options.Address.City == taxInformation.City &&
options.Address.State == taxInformation.State));
await stripeAdapter.Received(1).TaxIdDeleteAsync(provider.GatewayCustomerId, "tax_id_1");
await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" &&
options.Value == taxInformation.TaxId));
await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == StripeConstants.TaxExempt.Reverse));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
} }
#endregion #endregion