Browse Source

[PM-33061] Tax Id Should Be Added When Upgrading to Teams or Enterprise (#7131)

* refactor(billing): change billing address request type

* feat(billing): add tax id support for international business plans

* feat(billing): add billing address tax id handling

* test: add tests for tax id handling during upgrade

* fix(billing): run dotnet format

* fix(billing): remove extra line

* fix(billing): modify return type of HandleAsync

* test(billing): update tests to reflect updated command signature

* fix(billing): run dotnet format

* tests(billing): fix tests

* test(billing): format
pull/7185/head
Stephon Brown 3 weeks ago committed by GitHub
parent
commit
4732d7fcd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs
  2. 38
      src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs
  3. 53
      test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs
  4. 196
      test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs

2
src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs

@ -26,7 +26,7 @@ public class UpgradePremiumToOrganizationRequest @@ -26,7 +26,7 @@ public class UpgradePremiumToOrganizationRequest
public required ProductTierType TargetProductTierType { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
public required CheckoutBillingAddressRequest BillingAddress { get; set; }
private PlanType PlanType
{

38
src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@ -12,6 +13,8 @@ using Bit.Core.Services; @@ -12,6 +13,8 @@ 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>
@ -40,7 +43,7 @@ public interface IUpgradePremiumToOrganizationCommand @@ -40,7 +43,7 @@ public interface IUpgradePremiumToOrganizationCommand
string encryptedPrivateKey,
string? collectionName,
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress);
BillingAddress billingAddress);
}
public class UpgradePremiumToOrganizationCommand(
@ -65,7 +68,7 @@ public class UpgradePremiumToOrganizationCommand( @@ -65,7 +68,7 @@ public class UpgradePremiumToOrganizationCommand(
string encryptedPrivateKey,
string? collectionName,
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress) => HandleAsync<Guid>(async () =>
BillingAddress billingAddress) => HandleAsync<Guid>(async () =>
{
// Validate that the user has an active Premium subscription
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
@ -198,9 +201,16 @@ public class UpgradePremiumToOrganizationCommand( @@ -198,9 +201,16 @@ public class UpgradePremiumToOrganizationCommand(
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
},
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
});
// Add tax ID to customer for accurate tax calculation if provided
if (billingAddress.TaxId != null)
{
await AddTaxIdToCustomerAsync(user, billingAddress.TaxId);
}
// Update the subscription in Stripe
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
@ -271,4 +281,26 @@ public class UpgradePremiumToOrganizationCommand( @@ -271,4 +281,26 @@ public class UpgradePremiumToOrganizationCommand(
return organization.Id;
});
/// <summary>
/// Adds a tax ID to the Stripe customer for accurate tax calculation.
/// If the tax ID is a Spanish NIF, also adds the corresponding EU VAT ID.
/// </summary>
/// <param name="user"> The user whose Stripe customer will be updated with the tax ID.</param>
/// <param name="taxId"> The tax ID to add, including the type and value.</param>
private async Task AddTaxIdToCustomerAsync(User user, TaxID taxId)
{
await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId,
new TaxIdCreateOptions { Type = taxId.Code, Value = taxId.Value });
if (taxId.Code == StripeConstants.TaxIdType.SpanishNIF)
{
await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId,
new TaxIdCreateOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{taxId.Value}"
});
}
}
}

53
test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs

@ -22,7 +22,7 @@ public class UpgradePremiumToOrganizationRequestTests @@ -22,7 +22,7 @@ public class UpgradePremiumToOrganizationRequestTests
EncryptedPrivateKey = "encrypted-private-key",
CollectionName = "Default Collection",
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
BillingAddress = new CheckoutBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
@ -56,7 +56,7 @@ public class UpgradePremiumToOrganizationRequestTests @@ -56,7 +56,7 @@ public class UpgradePremiumToOrganizationRequestTests
PublicKey = "public-key",
EncryptedPrivateKey = "encrypted-private-key",
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
BillingAddress = new CheckoutBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
@ -67,4 +67,53 @@ public class UpgradePremiumToOrganizationRequestTests @@ -67,4 +67,53 @@ public class UpgradePremiumToOrganizationRequestTests
var exception = Assert.Throws<InvalidOperationException>(() => sut.ToDomain());
Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message);
}
[Theory]
[InlineData(ProductTierType.Teams, PlanType.TeamsAnnually, "DE", "10115", "eu_vat", "DE123456789")]
[InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually, "FR", "75001", "eu_vat", "FR12345678901")]
public void ToDomain_BusinessPlansWithNonUsTaxId_IncludesTaxIdInBillingAddress(
ProductTierType tierType,
PlanType expectedPlanType,
string country,
string postalCode,
string taxIdCode,
string taxIdValue)
{
// Arrange
var sut = new UpgradePremiumToOrganizationRequest
{
OrganizationName = "International Business",
Key = "encrypted-key",
TargetProductTierType = tierType,
PublicKey = "public-key",
EncryptedPrivateKey = "encrypted-private-key",
CollectionName = "Default Collection",
BillingAddress = new CheckoutBillingAddressRequest
{
Country = country,
PostalCode = postalCode,
TaxId = new CheckoutBillingAddressRequest.TaxIdRequest
{
Code = taxIdCode,
Value = taxIdValue
}
}
};
// Act
var (organizationName, key, publicKey, encryptedPrivateKey, collectionName, planType, billingAddress) = sut.ToDomain();
// Assert
Assert.Equal("International Business", organizationName);
Assert.Equal("encrypted-key", key);
Assert.Equal("public-key", publicKey);
Assert.Equal("encrypted-private-key", encryptedPrivateKey);
Assert.Equal("Default Collection", collectionName);
Assert.Equal(expectedPlanType, planType);
Assert.Equal(country, billingAddress.Country);
Assert.Equal(postalCode, billingAddress.PostalCode);
Assert.NotNull(billingAddress.TaxId);
Assert.Equal(taxIdCode, billingAddress.TaxId.Code);
Assert.Equal(taxIdValue, billingAddress.TaxId.Value);
}
}

196
test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs

@ -1202,4 +1202,200 @@ public class UpgradePremiumToOrganizationCommandTests @@ -1202,4 +1202,200 @@ public class UpgradePremiumToOrganizationCommandTests
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>());
}
[Theory, BitAutoData]
public async Task Run_WithNoTaxId_SetsTaxExemptToNone_DoesNotCreateTaxId(User user)
{
// Arrange
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 SubscriptionItem
{
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 = "US",
PostalCode = "12345",
TaxId = null
};
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress);
// Assert
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>());
}
[Theory, BitAutoData]
public async Task Run_WithTaxId_SetsTaxExemptToReverse_CreatesOneTaxId(User user)
{
// Arrange
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 SubscriptionItem
{
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()));
_stripeAdapter.CreateTaxIdAsync(Arg.Any<string>(), Arg.Any<TaxIdCreateOptions>()).Returns(new TaxId());
_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 = "DE",
PostalCode = "10115",
TaxId = new Core.Billing.Payment.Models.TaxID("eu_vat", "DE123456789")
};
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.Reverse));
await _stripeAdapter.Received(1).CreateTaxIdAsync(
"cus_123",
Arg.Is<TaxIdCreateOptions>(options =>
options.Type == "eu_vat" &&
options.Value == "DE123456789"));
}
[Theory, BitAutoData]
public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user)
{
// Arrange
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 SubscriptionItem
{
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()));
_stripeAdapter.CreateTaxIdAsync(Arg.Any<string>(), Arg.Any<TaxIdCreateOptions>()).Returns(new TaxId());
_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 = "ES",
PostalCode = "28001",
TaxId = new Core.Billing.Payment.Models.TaxID(StripeConstants.TaxIdType.SpanishNIF, "A12345678")
};
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(options =>
options.TaxExempt == StripeConstants.TaxExempt.Reverse));
// Verify Spanish NIF was created
await _stripeAdapter.Received(1).CreateTaxIdAsync(
"cus_123",
Arg.Is<TaxIdCreateOptions>(options =>
options.Type == StripeConstants.TaxIdType.SpanishNIF &&
options.Value == "A12345678"));
// Verify EU VAT was created with ES prefix
await _stripeAdapter.Received(1).CreateTaxIdAsync(
"cus_123",
Arg.Is<TaxIdCreateOptions>(options =>
options.Type == StripeConstants.TaxIdType.EUVAT &&
options.Value == "ESA12345678"));
}
}

Loading…
Cancel
Save