mirror of
https://github.com/bitwarden/server.git
synced 2026-04-28 09:11:06 -05:00
[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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
36
src/Core/Billing/Tax/Utilities/TaxHelpers.cs
Normal file
36
src/Core/Billing/Tax/Utilities/TaxHelpers.cs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
42
test/Core.Test/Billing/Tax/TaxHelpersTests.cs
Normal file
42
test/Core.Test/Billing/Tax/TaxHelpersTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user