[PM-31645] Implement Swiss Tax Logic (#7186)

* feat(tax): introduce direct tax country utilities and Switzerland constant

* refactor(tax): use `TaxHelpers.IsDirectTaxCountry` for country checks

* feat(tax): implement customer tax exempt status alignment

* test(tax): add comprehensive unit tests for tax exempt alignment logic

* tests(billing): clarify tests

* fix(billing): run dotnet format

* fix(billing): run dotnet format

* fix(billing): Prevent NullReferenceException when accessing customer country

* test(billing): Add Stripe adapter mocks for AdjustSubscription scenarios

* refactor(billing): apply null-conditional operator for address country access

* feat(billing): update missing tax exemption determinations

* test(billing): add unit tests for tax exemption updates

* fix(billing) run dotnet format

* fix(billing): add nullability

* style(files): normalize file encoding for billing utilities

* refactor(TaxHelpers): simplify tax exempt status determination

* test(Tax): update tax exempt determination tests

* fix(billing): revert postal code validation

* test(billing): update tax exempt tests

* fix(billing): run dotnet format
This commit is contained in:
Stephon Brown
2026-03-17 14:09:41 -04:00
committed by GitHub
parent 27de29a464
commit 8302509bf9
25 changed files with 1627 additions and 118 deletions

View File

@@ -3,13 +3,13 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Context;
using Stripe;
using Stripe.Tax;
namespace Bit.Commercial.Core.Billing.Providers.Queries;
using static Bit.Core.Constants;
using static StripeConstants;
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
@@ -61,7 +61,7 @@ public class GetProviderWarningsQuery(
Provider provider,
Customer customer)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
if (TaxHelpers.IsDirectTaxCountry(customer.Address?.Country))
{
return null;
}

View File

@@ -3,7 +3,6 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -19,6 +18,7 @@ using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -34,7 +34,6 @@ using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Billing.Providers.Services;
using static Constants;
using static StripeConstants;
public class ProviderBillingService(
@@ -267,10 +266,13 @@ public class ProviderBillingService(
]
};
if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(providerCustomer.Address?.Country, providerCustomer.TaxExempt);
customerCreateOptions.TaxExempt = providerCustomer switch
{
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
}
{ Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus } when
determinedTaxExemptStatus != customerTaxExemptStatus => determinedTaxExemptStatus,
_ => providerCustomer.TaxExempt
};
var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions);
@@ -467,6 +469,7 @@ public class ProviderBillingService(
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(billingAddress.Country);
var options = new CustomerCreateOptions
{
Address = new AddressOptions
@@ -494,7 +497,7 @@ public class ProviderBillingService(
]
},
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
TaxExempt = determinedTaxExemptStatus
};
if (billingAddress.TaxId != null)

View File

@@ -520,6 +520,39 @@ public class GetProviderWarningsQueryTests
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_SwissCustomer_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CH" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CH" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_USCustomer_NoTaxIdWarning(
Provider provider,

View File

@@ -389,6 +389,55 @@ public class ProviderBillingServiceTests
org => org.GatewayCustomerId == "customer_id"));
}
[Theory, BitAutoData]
public async Task CreateCustomer_ForClientOrg_USCustomer_SetsTaxExemptToNone(
Provider provider,
Organization organization,
SutProvider<ProviderBillingService> sutProvider)
{
organization.GatewayCustomerId = null;
organization.Name = "Name";
var providerCustomer = new Customer
{
Address = new Address
{
Country = "US",
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" }
]
},
TaxExempt = null
};
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<IStripeAdapter>().CreateCustomerAsync(Arg.Any<CustomerCreateOptions>())
.Returns(new Customer { Id = "customer_id" });
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CreateCustomerAsync(
Arg.Is<CustomerCreateOptions>(options => options.TaxExempt == StripeConstants.TaxExempt.None));
}
#endregion
#region GenerateClientInvoiceReport

View File

@@ -3,8 +3,8 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
@@ -139,7 +139,7 @@ public class OrganizationCreateRequestModel : IValidatableObject
new string[] { nameof(BillingAddressCountry) });
}
if (PlanType != PlanType.Free && BillingAddressCountry == Constants.CountryAbbreviations.UnitedStates &&
if (PlanType != PlanType.Free && TaxHelpers.IsDirectTaxCountry(BillingAddressCountry) &&
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
{
yield return new ValidationResult("Zip / postal code is required.",

View File

@@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
@@ -157,24 +158,29 @@ public class UpcomingInvoiceHandler(
Customer customer,
string eventId)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
var isBusinessUse = organization.PlanType.GetProductTier() != ProductTierType.Families;
if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse)
if (isBusinessUse)
{
try
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt);
switch (customer)
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = 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);
case { Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus:
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to the required tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
break;
}
}
@@ -449,22 +455,25 @@ public class UpcomingInvoiceHandler(
Customer customer,
string eventId)
{
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != TaxExempt.Reverse)
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt);
switch (customer)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = 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);
}
case { Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus:
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to the required tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
break;
}
if (!subscription.AutomaticTax.Enabled)

View File

@@ -8,6 +8,7 @@ using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.Extensions.Logging;
@@ -16,7 +17,6 @@ using Stripe;
namespace Bit.Core.Billing.Organizations.Commands;
using static Core.Constants;
using static StripeConstants;
public interface IPreviewOrganizationTaxCommand
@@ -385,12 +385,24 @@ public class PreviewOrganizationTaxCommand(
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions { Country = country, PostalCode = postalCode },
TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates
? TaxExempt.Reverse
: TaxExempt.None
}
};
switch (businessUse)
{
case true:
var existingTaxExemptStatus = addressChoice.Match(
customer => customer.TaxExempt,
_ => null!);
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(country, existingTaxExemptStatus);
options.CustomerDetails.TaxExempt = determinedTaxExemptStatus;
break;
default:
options.CustomerDetails.TaxExempt = TaxExempt.None;
break;
}
var taxId = addressChoice.Match(
customer =>
{

View File

@@ -3,13 +3,13 @@ using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Microsoft.Extensions.Logging;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Organizations.Commands;
using static Core.Constants;
using static StripeConstants;
/// <summary>
@@ -163,15 +163,16 @@ public class UpdateOrganizationSubscriptionCommand(
private async Task ReconcileTaxExemptionAsync(Customer customer)
{
if (customer is
{
Address.Country: not CountryAbbreviations.UnitedStates,
TaxExempt: not TaxExempt.Reverse
})
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt);
switch (customer)
{
await stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
case { Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus:
await stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus });
break;
}
}
private static OneOf<SubscriptionItemOptions, BadRequest> ValidateItemAddition(

View File

@@ -8,13 +8,13 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Context;
using Stripe;
using Stripe.Tax;
namespace Bit.Core.Billing.Organizations.Queries;
using static Core.Constants;
using static StripeConstants;
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
@@ -230,7 +230,7 @@ public class GetOrganizationWarningsQuery(
Customer customer,
Provider? provider)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
if (TaxHelpers.IsDirectTaxCountry(customer.Address?.Country))
{
return null;
}

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models.Sales;
@@ -8,6 +7,7 @@ using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -17,8 +17,10 @@ using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
using Customer = Stripe.Customer;
using StripeConstants = Bit.Core.Billing.Constants.StripeConstants;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Organizations.Services;
public class OrganizationBillingService(
@@ -238,7 +240,7 @@ public class OrganizationBillingService(
};
if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates)
!TaxHelpers.IsDirectTaxCountry(customerSetup.TaxInformation.Country))
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
@@ -491,23 +493,13 @@ public class OrganizationBillingService(
}
List<string> expansions = ["tax", "tax_ids"];
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt);
customer = customer switch
{
{ Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await
stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions
{
Expand = expansions,
TaxExempt = StripeConstants.TaxExempt.Reverse
}),
{ Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await
stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions
{
Expand = expansions,
TaxExempt = StripeConstants.TaxExempt.None
}),
{ Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus =>
await stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions { Expand = expansions, TaxExempt = determinedTaxExemptStatus }),
_ => customer
};

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using Stripe;
@@ -69,24 +70,23 @@ public class UpdateBillingAddressCommand(
ISubscriber subscriber,
BillingAddress billingAddress)
{
var customer =
await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
var determinedTaxExemptStatus = await GetDeterminedTaxExemptStatusAsync(subscriber.GatewayCustomerId!, billingAddress.Country);
var customer = await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
Address = new AddressOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
},
Expand = ["subscriptions", "tax_ids"],
TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates
? StripeConstants.TaxExempt.Reverse
: StripeConstants.TaxExempt.None
});
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
},
Expand = ["subscriptions", "tax_ids"],
TaxExempt = determinedTaxExemptStatus
});
await EnableAutomaticTaxAsync(subscriber, customer);
@@ -118,6 +118,13 @@ public class UpdateBillingAddressCommand(
return BillingAddress.From(customer.Address, updatedTaxId);
}
private async Task<string> GetDeterminedTaxExemptStatusAsync(string customerId, string? billingCountry)
{
var existingCustomer = await stripeAdapter.GetCustomerAsync(customerId);
return TaxHelpers.DetermineTaxExemptStatus(billingCountry, existingCustomer.TaxExempt);
}
private async Task EnableAutomaticTaxAsync(
ISubscriber subscriber,
Customer customer)

View File

@@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -13,8 +14,6 @@ using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations;
using TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
@@ -202,7 +201,7 @@ public class UpgradePremiumToOrganizationCommand(
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
},
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
TaxExempt = TaxHelpers.DetermineTaxExemptStatus(billingAddress.Country)
});
// Add tax ID to customer for accurate tax calculation if provided

View File

@@ -9,6 +9,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -122,14 +123,14 @@ public class StripePaymentService : IStripePaymentService
if (subscriptionUpdate is CompleteSubscriptionUpdate)
{
if (sub.Customer is
{
Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates,
TaxExempt: not StripeConstants.TaxExempt.Reverse
})
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(sub.Customer.Address?.Country, sub.Customer.TaxExempt);
switch (sub.Customer)
{
await _stripeAdapter.UpdateCustomerAsync(sub.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
case { Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus:
await _stripeAdapter.UpdateCustomerAsync(sub.Customer.Id,
new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus });
break;
}
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };

View File

@@ -10,6 +10,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -19,7 +20,6 @@ using Bit.Core.Utilities;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
@@ -602,23 +602,13 @@ public class SubscriberService(
if (isBusinessUseSubscriber)
{
var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt);
switch (customer)
{
case
{
Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates,
TaxExempt: not TaxExempt.Reverse
}:
case { Address.Country: not null and not "", TaxExempt: var customerTaxExemptStatus }
when determinedTaxExemptStatus != customerTaxExemptStatus:
await stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
break;
case
{
Address.Country: Core.Constants.CountryAbbreviations.UnitedStates,
TaxExempt: TaxExempt.Reverse
}:
await stripeAdapter.UpdateCustomerAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = TaxExempt.None });
new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus });
break;
}
@@ -637,8 +627,8 @@ public class SubscriberService(
{
User => true,
Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families ||
customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
TaxHelpers.IsDirectTaxCountry(customer.Address?.Country) || (customer.TaxIds?.Any() ?? false),
Provider provider => TaxHelpers.IsDirectTaxCountry(customer.Address?.Country) || (customer.TaxIds?.Any() ?? false),
_ => false
};

View File

@@ -0,0 +1,36 @@
using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations;
using TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt;
namespace Bit.Core.Billing.Tax.Utilities;
public static class TaxHelpers
{
/// <summary>
/// Countries where tax is collected directly from customers, rather than through VAT ID reverse charge.
/// To add a new country, add its ISO 3166 code to <see cref="Bit.Core.Constants.CountryAbbreviations"/>
/// and then add it to this set.
/// </summary>
private static readonly HashSet<string> DirectTaxCountries =
[
CountryAbbreviations.UnitedStates,
CountryAbbreviations.Switzerland
];
/// <summary>
/// Returns <see langword="true"/> if <paramref name="country"/> is in <see cref="DirectTaxCountries"/>,
/// meaning tax is collected directly and Stripe's <c>tax_exempt</c> should default to <c>"none"</c>.
/// Returns <see langword="false"/> for all other countries, where VAT reverse charge applies.
/// </summary>
public static bool IsDirectTaxCountry(string? country) =>
country is not null and not "" && DirectTaxCountries.Contains(country);
/// <summary>
/// Returns the Stripe <c>tax_exempt</c> value appropriate for <paramref name="country"/>.<br/>
/// If <paramref name="currentTaxExempt"/> is already <c>"exempt"</c>, that status is always preserved.<br/>
/// For direct-tax countries, returns <c>"none"</c>.<br/>
/// For all other countries, returns <c>"reverse"</c>.
/// </summary>
public static string DetermineTaxExemptStatus(string? country, string? currentTaxExempt = null) =>
currentTaxExempt == TaxExempt.Exempt
? TaxExempt.Exempt
: IsDirectTaxCountry(country) ? TaxExempt.None : TaxExempt.Reverse;
}

View File

@@ -69,8 +69,13 @@ public static class Constants
/// This value must match what Stripe uses for the `Country` field value for the United States.
/// </summary>
public const string UnitedStates = "US";
}
/// <summary>
/// Abbreviation for Switzerland.
/// This value must match what Stripe uses for the `Country` field value for Switzerland.
/// </summary>
public const string Switzerland = "CH";
}
/// <summary>
/// Constants for our browser extensions IDs

View File

@@ -553,6 +553,193 @@ public class UpcomingInvoiceHandlerTests
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenNonDirectTaxCountryOrganization_SetsReverseCharge()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.None
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new EnterprisePlan(isAnnual: true));
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
}
[Fact]
public async Task HandleAsync_WhenUSOrganizationWithManualReverseCharge_CorrectsTaxExemptToNone()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" },
TaxExempt = TaxExempt.Reverse
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new EnterprisePlan(isAnnual: true));
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.None));
}
[Fact]
public async Task HandleAsync_WhenSwissOrganizationWithReverse_CorrectsTaxExemptToNone()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "CH" },
TaxExempt = TaxExempt.Reverse
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new EnterprisePlan(isAnnual: true));
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateCustomer(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
}
[Fact]
public async Task HandleAsync_WhenOrganizationCustomerIsExempt_DoesNotUpdateTaxExemption()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.Exempt
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new EnterprisePlan(isAnnual: true));
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail()
@@ -606,7 +793,7 @@ public class UpcomingInvoiceHandlerTests
// Assert
await _providerRepository.Received(2).GetByIdAsync(_providerId);
// Verify tax exempt was set to reverse for non-US providers
// Verify tax exempt was set to reverse for non-direct-tax-country providers
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
@@ -627,6 +814,197 @@ public class UpcomingInvoiceHandlerTests
Arg.Is<string>(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}"));
}
[Fact]
public async Task HandleAsync_WhenSwissProviderWithReverse_CorrectsTaxExemptToNone()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
CollectionMethod = "charge_automatically"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "CH" },
TaxExempt = TaxExempt.Reverse
};
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
_providerRepository.GetByIdAsync(_providerId).Returns(provider);
_getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _providerRepository.Received(2).GetByIdAsync(_providerId);
await _stripeFacade.Received(1).UpdateCustomer(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
}
[Fact]
public async Task HandleAsync_WhenProviderCustomerIsExempt_DoesNotUpdateTaxExemption()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
CollectionMethod = "charge_automatically"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.Exempt
};
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
_providerRepository.GetByIdAsync(_providerId).Returns(provider);
_getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task HandleAsync_WhenNonDirectTaxCountryProvider_SetsReverseCharge()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
CollectionMethod = "charge_automatically"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.None
};
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
_providerRepository.GetByIdAsync(_providerId).Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
}
[Fact]
public async Task HandleAsync_WhenUSProviderWithManualReverseCharge_CorrectsTaxExemptToNone()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
CollectionMethod = "charge_automatically"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" },
TaxExempt = TaxExempt.Reverse
};
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
_providerRepository.GetByIdAsync(_providerId).Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.None));
}
[Fact]
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail()
{
@@ -1064,6 +1442,11 @@ public class UpcomingInvoiceHandlerTests
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
// Families plan is excluded from tax exempt alignment
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]
@@ -1154,6 +1537,11 @@ public class UpcomingInvoiceHandlerTests
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
// Families plan is excluded from tax exempt alignment
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]
@@ -1231,6 +1619,11 @@ public class UpcomingInvoiceHandlerTests
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
// Families plan is excluded from tax exempt alignment
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]
@@ -1302,6 +1695,10 @@ public class UpcomingInvoiceHandlerTests
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
// Families plan is excluded from tax exempt alignment
await _stripeFacade.DidNotReceive().UpdateCustomer(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Fact]

View File

@@ -318,6 +318,57 @@ public class PreviewOrganizationTaxCommandTests
options.Discounts == null));
}
[Fact]
public async Task Run_OrganizationSubscriptionPurchase_BusinessUseSwitzerland_UsesTaxExemptNone()
{
var purchase = new OrganizationSubscriptionPurchase
{
Tier = ProductTierType.Teams,
Cadence = PlanCadenceType.Monthly,
PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections
{
Seats = 3,
AdditionalStorage = 0,
Sponsored = false
}
};
var billingAddress = new BillingAddress
{
Country = "CH",
PostalCode = "3001"
};
var plan = new TeamsPlan(false);
_pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan);
var invoice = new Invoice
{
TotalTaxes = [new InvoiceTotalTax { Amount = 220 }],
Total = 2920
};
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, purchase, billingAddress);
Assert.True(result.IsT0);
var (tax, total) = result.AsT0;
Assert.Equal(2.20m, tax);
Assert.Equal(29.20m, total);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "CH" &&
options.CustomerDetails.Address.PostalCode == "3001" &&
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 3 &&
options.Discounts == null));
}
[Fact]
public async Task Run_OrganizationSubscriptionPurchase_SpanishNIFTaxId_AddsEUVATTaxId()
{

View File

@@ -797,6 +797,114 @@ public class UpdateOrganizationSubscriptionCommandTests
Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task Run_SwissCustomer_WithNone_DoesNotUpdateTaxExemption()
{
var customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "CH" },
TaxExempt = TaxExempt.None
};
var organization = CreateOrganization();
var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]);
SetupGetSubscription(organization, subscription);
SetupUpdateSubscription(subscription);
var changeSet = new OrganizationSubscriptionChangeSet
{
Changes = [new UpdateItemQuantity("price_seats", 10)]
};
await _command.Run(organization, changeSet);
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task Run_SwissCustomer_WithReverse_UpdatesTaxExemptToNone()
{
var customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "CH" },
TaxExempt = TaxExempt.Reverse
};
var organization = CreateOrganization();
var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]);
SetupGetSubscription(organization, subscription);
SetupUpdateSubscription(subscription);
var changeSet = new OrganizationSubscriptionChangeSet
{
Changes = [new UpdateItemQuantity("price_seats", 10)]
};
await _command.Run(organization, changeSet);
await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == TaxExempt.None));
}
[Theory]
[InlineData("CH")]
[InlineData("US")]
[InlineData("DE")]
public async Task Run_CustomerWithExemptStatus_DoesNotUpdateTaxExemption(string country)
{
// "exempt" is a manual designation (e.g. non-profit) and must never be overwritten automatically.
var customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = country },
TaxExempt = TaxExempt.Exempt
};
var organization = CreateOrganization();
var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]);
SetupGetSubscription(organization, subscription);
SetupUpdateSubscription(subscription);
var changeSet = new OrganizationSubscriptionChangeSet
{
Changes = [new UpdateItemQuantity("price_seats", 10)]
};
await _command.Run(organization, changeSet);
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task Run_CustomerWithNullAddress_DoesNotUpdateTaxExemption()
{
var customer = new Customer { Id = "cus_123", Address = null };
var organization = CreateOrganization();
var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]);
SetupGetSubscription(organization, subscription);
SetupUpdateSubscription(subscription);
var changeSet = new OrganizationSubscriptionChangeSet
{
Changes = [new UpdateItemQuantity("price_seats", 10)]
};
await _command.Run(organization, changeSet);
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task Run_MultipleChanges_AllValid_CreatesAllItems()
{

View File

@@ -421,6 +421,31 @@ public class GetOrganizationWarningsQueryTests
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_CHCustomer_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CH" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_FreeCustomer_NoTaxIdWarning(
Organization organization,

View File

@@ -191,6 +191,9 @@ public class UpdateBillingAddressCommandTests
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
@@ -259,6 +262,9 @@ public class UpdateBillingAddressCommandTests
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
@@ -321,6 +327,9 @@ public class UpdateBillingAddressCommandTests
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
@@ -337,6 +346,69 @@ public class UpdateBillingAddressCommandTests
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Fact]
public async Task Run_SwissBusinessOrganization_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "CH",
PostalCode = "3001",
Line1 = "Bundesgasse 1",
Line2 = string.Empty,
City = "Bern",
State = "BE"
};
var customer = new Customer
{
Address = new Address
{
Country = "CH",
PostalCode = "3001",
Line1 = "Bundesgasse 1",
Line2 = string.Empty,
City = "Bern",
State = "BE"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Fact]
public async Task Run_BusinessOrganizationWithSpanishCIF_MakesCorrectInvocations_ReturnsBillingAddress()
{
@@ -383,6 +455,9 @@ public class UpdateBillingAddressCommandTests
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
@@ -460,6 +535,9 @@ public class UpdateBillingAddressCommandTests
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.None });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
@@ -488,4 +566,129 @@ public class UpdateBillingAddressCommandTests
await _stripeAdapter.Received(1).CreateTaxIdAsync(customer.Id, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options.Value == "987654321"));
}
[Fact]
public async Task Run_SwissBusinessOrganization_WithReverse_CorrectsTaxExemptToNone()
{
// CH is a direct-tax country — "reverse" is not preserved. A customer moving from a
// non-direct-tax country (where "reverse" was correctly set) to Switzerland should have
// their tax_exempt corrected to "none".
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "CH",
PostalCode = "3001",
Line1 = "Bundesgasse 1",
Line2 = string.Empty,
City = "Bern",
State = "BE"
};
var customer = new Customer
{
Address = new Address
{
Country = "CH",
PostalCode = "3001",
Line1 = "Bundesgasse 1",
Line2 = string.Empty,
City = "Bern",
State = "BE"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }
}
]
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.Reverse });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
}
[Fact]
public async Task Run_BusinessOrganizationWithExemptStatus_PreservesExempt()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
City = "New York",
State = "NY"
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
City = "New York",
State = "NY"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }
}
]
}
};
_stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId)
.Returns(new Customer { TaxExempt = TaxExempt.Exempt });
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.Exempt
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.Exempt));
}
}

View File

@@ -1395,7 +1395,60 @@ public class UpgradePremiumToOrganizationCommandTests
Arg.Is<TaxIdCreateOptions>(options =>
options.Type == StripeConstants.TaxIdType.EUVAT &&
options.Value == "ESA12345678"));
}
[Theory, BitAutoData]
public async Task Run_WithSwissCountry_SetsTaxExemptToNone(User user)
{
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
var billingAddress = new Core.Billing.Payment.Models.BillingAddress
{
Country = "CH",
PostalCode = "8001",
TaxId = null
};
var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, billingAddress);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.None));
await _stripeAdapter.DidNotReceive().CreateTaxIdAsync(Arg.Any<string>(), Arg.Any<TaxIdCreateOptions>());
}
}

View File

@@ -595,8 +595,238 @@ public class OrganizationBillingServiceTests
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
public async Task Finalize_BusinessWithExemptStatus_DoesNotUpdateTaxExemption(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = MockPlans.Get(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported },
Address = new Address { Country = "DE" },
TaxExempt = StripeConstants.TaxExempt.Exempt
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
sutProvider.GetDependency<IStripeAdapter>()
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Active
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceive()
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
#endregion
[Theory, BitAutoData]
public async Task Finalize_SwissBusinessWithReverse_CorrectsTaxExemptToNone(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = MockPlans.Get(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported },
Address = new Address { Country = "CH" },
TaxExempt = StripeConstants.TaxExempt.Reverse
};
var correctedCustomer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported },
Address = new Address { Country = "CH" },
TaxExempt = StripeConstants.TaxExempt.None
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateCustomerAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.None))
.Returns(correctedCustomer);
sutProvider.GetDependency<IStripeAdapter>()
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Active
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.UpdateCustomerAsync("cus_test123",
Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.None));
}
[Theory, BitAutoData]
public async Task Finalize_USBusinessWithReverseExempt_CorrectsTaxExemptToNone(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = MockPlans.Get(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported },
Address = new Address { Country = "US" },
TaxExempt = StripeConstants.TaxExempt.Reverse
};
var correctedCustomer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported },
Address = new Address { Country = "US" },
TaxExempt = StripeConstants.TaxExempt.None
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateCustomerAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.None))
.Returns(correctedCustomer);
sutProvider.GetDependency<IStripeAdapter>()
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Active
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert: UpdateCustomerAsync called with TaxExempt = "none" to correct the erroneous "reverse"
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.UpdateCustomerAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.None));
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer(
Organization organization,

View File

@@ -1,8 +1,12 @@
using Bit.Core.Billing.Constants;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Test.Billing.Mocks.Plans;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -11,6 +15,8 @@ using Xunit;
namespace Bit.Core.Test.Services;
using static StripeConstants;
[SutProviderCustomize]
public class StripePaymentServiceTests
{
@@ -408,4 +414,261 @@ public class StripePaymentServiceTests
.DidNotReceive()
.GetSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
#region AdjustSubscription CompleteSubscriptionUpdate tax exempt alignment
[Theory, BitAutoData]
public async Task AdjustSubscription_WhenNonDirectTaxCountry_SetsReverseCharge(
SutProvider<StripePaymentService> sutProvider,
Organization organization)
{
var plan = new EnterprisePlan(isAnnual: true);
organization.PlanType = PlanType.EnterpriseAnnually;
organization.GatewaySubscriptionId = "sub_123";
organization.Seats = 0;
organization.UseSecretsManager = false;
organization.MaxStorageGb = null;
var subscription = new Subscription
{
Id = "sub_123",
Status = "active",
Customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.None
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
Plan = new Stripe.Plan { Id = plan.PasswordManager.StripeSeatPlanId },
Quantity = 0
}
]
}
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(plan);
sutProvider.GetDependency<IStripeAdapter>()
.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(new Subscription { Id = "sub_123", LatestInvoiceId = "inv_123" });
sutProvider.GetDependency<IStripeAdapter>()
.GetInvoiceAsync("inv_123", Arg.Any<InvoiceGetOptions>())
.Returns(new Invoice { Id = "inv_123", AmountDue = 0, Status = InvoiceStatus.Paid });
sutProvider.GetDependency<IStripeAdapter>()
.GetCustomerAsync("cus_123")
.Returns(new Customer { Id = "cus_123" });
await sutProvider.Sut.AdjustSubscription(organization, plan, 0, false, null, null, 0);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
}
[Theory, BitAutoData]
public async Task AdjustSubscription_WhenUSWithManualReverse_CorrectsTaxExemptToNone(
SutProvider<StripePaymentService> sutProvider,
Organization organization)
{
var plan = new EnterprisePlan(isAnnual: true);
organization.PlanType = PlanType.EnterpriseAnnually;
organization.GatewaySubscriptionId = "sub_123";
organization.Seats = 0;
organization.UseSecretsManager = false;
organization.MaxStorageGb = null;
var subscription = new Subscription
{
Id = "sub_123",
Status = "active",
Customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "US" },
TaxExempt = TaxExempt.Reverse
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
Plan = new Stripe.Plan { Id = plan.PasswordManager.StripeSeatPlanId },
Quantity = 0
}
]
}
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(plan);
sutProvider.GetDependency<IStripeAdapter>()
.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(new Subscription { Id = "sub_123", LatestInvoiceId = "inv_123" });
sutProvider.GetDependency<IStripeAdapter>()
.GetInvoiceAsync("inv_123", Arg.Any<InvoiceGetOptions>())
.Returns(new Invoice { Id = "inv_123", AmountDue = 0, Status = InvoiceStatus.Paid });
sutProvider.GetDependency<IStripeAdapter>()
.GetCustomerAsync("cus_123")
.Returns(new Customer { Id = "cus_123" });
await sutProvider.Sut.AdjustSubscription(organization, plan, 0, false, null, null, 0);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.None));
}
[Theory, BitAutoData]
public async Task AdjustSubscription_WhenSwissWithReverse_CorrectsTaxExemptToNone(
SutProvider<StripePaymentService> sutProvider,
Organization organization)
{
// CH is a direct-tax country — "reverse" is not preserved; it should be corrected to "none".
var plan = new EnterprisePlan(isAnnual: true);
organization.PlanType = PlanType.EnterpriseAnnually;
organization.GatewaySubscriptionId = "sub_123";
organization.Seats = 0;
organization.UseSecretsManager = false;
organization.MaxStorageGb = null;
var subscription = new Subscription
{
Id = "sub_123",
Status = "active",
Customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "CH" },
TaxExempt = TaxExempt.Reverse
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
Plan = new Stripe.Plan { Id = plan.PasswordManager.StripeSeatPlanId },
Quantity = 0
}
]
}
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(plan);
sutProvider.GetDependency<IStripeAdapter>()
.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(new Subscription { Id = "sub_123", LatestInvoiceId = "inv_123" });
sutProvider.GetDependency<IStripeAdapter>()
.GetInvoiceAsync("inv_123", Arg.Any<InvoiceGetOptions>())
.Returns(new Invoice { Id = "inv_123", AmountDue = 0, Status = InvoiceStatus.Paid });
sutProvider.GetDependency<IStripeAdapter>()
.GetCustomerAsync("cus_123")
.Returns(new Customer { Id = "cus_123" });
await sutProvider.Sut.AdjustSubscription(organization, plan, 0, false, null, null, 0);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
}
[Theory, BitAutoData]
public async Task AdjustSubscription_WhenCustomerIsExempt_DoesNotUpdateTaxExemption(
SutProvider<StripePaymentService> sutProvider,
Organization organization)
{
var plan = new EnterprisePlan(isAnnual: true);
organization.PlanType = PlanType.EnterpriseAnnually;
organization.GatewaySubscriptionId = "sub_123";
organization.Seats = 0;
organization.UseSecretsManager = false;
organization.MaxStorageGb = null;
var subscription = new Subscription
{
Id = "sub_123",
Status = "active",
Customer = new Customer
{
Id = "cus_123",
Address = new Address { Country = "DE" },
TaxExempt = TaxExempt.Exempt
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId },
Plan = new Stripe.Plan { Id = plan.PasswordManager.StripeSeatPlanId },
Quantity = 0
}
]
}
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(plan);
sutProvider.GetDependency<IStripeAdapter>()
.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<IStripeAdapter>()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(new Subscription { Id = "sub_123", LatestInvoiceId = "inv_123" });
sutProvider.GetDependency<IStripeAdapter>()
.GetInvoiceAsync("inv_123", Arg.Any<InvoiceGetOptions>())
.Returns(new Invoice { Id = "inv_123", AmountDue = 0, Status = InvoiceStatus.Paid });
sutProvider.GetDependency<IStripeAdapter>()
.GetCustomerAsync("cus_123")
.Returns(new Customer { Id = "cus_123" });
await sutProvider.Sut.AdjustSubscription(organization, plan, 0, false, null, null, 0);
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
#endregion
}

View File

@@ -0,0 +1,42 @@
using Bit.Core.Billing.Tax.Utilities;
using Xunit;
using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations;
using TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt;
namespace Bit.Core.Test.Billing.Tax;
public class TaxHelpersTests
{
[Theory]
[InlineData(CountryAbbreviations.UnitedStates, true)]
[InlineData(CountryAbbreviations.Switzerland, true)]
[InlineData("DE", false)]
[InlineData(null, false)]
[InlineData("", false)]
public void IsDirectTaxCountry_ReturnsExpectedResult(string? country, bool expected)
{
var result = TaxHelpers.IsDirectTaxCountry(country);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("DE", TaxExempt.None, TaxExempt.Reverse)] // non-direct-tax → Reverse
[InlineData(CountryAbbreviations.UnitedStates, TaxExempt.Reverse, TaxExempt.None)] // US Reverse → None (direct-tax)
[InlineData(CountryAbbreviations.Switzerland, null, TaxExempt.None)] // CH no existing status → None
[InlineData(CountryAbbreviations.UnitedStates, TaxExempt.None, TaxExempt.None)] // US already None → None
[InlineData(CountryAbbreviations.Switzerland, TaxExempt.Reverse, TaxExempt.None)] // CH Reverse → None (direct-tax, not preserved)
[InlineData("DE", TaxExempt.Reverse, TaxExempt.Reverse)] // non-direct-tax already Reverse → Reverse
[InlineData(null, TaxExempt.None, TaxExempt.Reverse)] // unknown country → Reverse
[InlineData("DE", TaxExempt.Exempt, TaxExempt.Exempt)] // exempt always preserved — non-direct-tax country
[InlineData(CountryAbbreviations.UnitedStates, TaxExempt.Exempt, TaxExempt.Exempt)] // exempt always preserved — direct-tax country
[InlineData(CountryAbbreviations.Switzerland, TaxExempt.Exempt, TaxExempt.Exempt)] // exempt always preserved — CH
public void DetermineTaxExemptStatus_ReturnsExpectedResult(
string? country,
string? currentTaxExempt,
string expected)
{
var result = TaxHelpers.DetermineTaxExemptStatus(country, currentTaxExempt);
Assert.Equal(expected, result);
}
}