1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-14 08:02:17 -05:00

[PM-20087] [PM-21104] Preview tax amount for organization trial initiation (#5787)

* [NO LOGIC] [PM-21104] Organize Core.Billing tax code

* Add PreviewTaxAmountCommand and expose through TaxController

* Add PreviewTaxAmountCommandTests

* Run dotnet format
This commit is contained in:
Alex Morask 2025-05-13 09:28:31 -04:00 committed by GitHub
parent 082bfa3c6a
commit 53f7d9655e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 672 additions and 50 deletions

View File

@ -8,7 +8,8 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -16,7 +16,9 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -17,6 +17,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;

View File

@ -1,7 +1,7 @@
#nullable enable
using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;

View File

@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;

View File

@ -9,6 +9,7 @@ using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;

View File

@ -6,6 +6,7 @@ using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Models.BitStripe;
using Bit.Core.Services;

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;

View File

@ -0,0 +1,36 @@
using Bit.Api.Billing.Models.Requests;
using Bit.Core.Billing.Tax.Commands;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
[Route("tax")]
public class TaxController(
IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController
{
[HttpPost("preview-amount/organization-trial")]
public async Task<IResult> PreviewTaxAmountForOrganizationTrialAsync(
[FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody)
{
var parameters = new OrganizationTrialParameters
{
PlanType = requestBody.PlanType,
ProductType = requestBody.ProductType,
TaxInformation = new OrganizationTrialParameters.TaxInformationDTO
{
Country = requestBody.TaxInformation.Country,
PostalCode = requestBody.TaxInformation.PostalCode,
TaxId = requestBody.TaxInformation.TaxId
}
};
var result = await previewTaxAmountCommand.Run(parameters);
return result.Match<IResult>(
taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }),
badRequest => Error.BadRequest(badRequest.TranslationKey),
unhandled => Error.ServerError(unhandled.TranslationKey));
}
}

View File

@ -0,0 +1,27 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests;
public class PreviewTaxAmountForOrganizationTrialRequestBody
{
[Required]
public PlanType PlanType { get; set; }
[Required]
public ProductType ProductType { get; set; }
[Required] public TaxInformationDTO TaxInformation { get; set; } = null!;
public class TaxInformationDTO
{
[Required]
public string Country { get; set; } = null!;
[Required]
public string PostalCode { get; set; } = null!;
public string? TaxId { get; set; }
}
}

View File

@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Requests;

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Stripe;
namespace Bit.Api.Billing.Models.Responses;

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;

View File

@ -4,8 +4,8 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;

View File

@ -4,7 +4,9 @@ using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Commands;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
namespace Bit.Core.Billing.Extensions;
@ -24,5 +26,6 @@ public static class ServiceCollectionExtensions
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
services.AddLicenseServices();
services.AddPricingClient();
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
}
}

View File

@ -0,0 +1,36 @@
using OneOf;
namespace Bit.Core.Billing.Models;
public record BadRequest(string TranslationKey)
{
public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid);
public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid);
public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType);
}
public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError);
public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Unhandled>
{
private BillingCommandResult(OneOf<T, BadRequest, Unhandled> input) : base(input) { }
public static implicit operator BillingCommandResult<T>(T output) => new(output);
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
}
public static class BillingErrorTranslationKeys
{
// "The tax ID number you provided was invalid. Please try again or contact support."
public const string TaxIdInvalid = "taxIdInvalid";
// "Your location wasn't recognized. Please ensure your country and postal code are valid and try again."
public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid";
// "Something went wrong with your request. Please contact support."
public const string UnhandledError = "unhandledBillingError";
// "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support."
public const string UnknownTaxIdType = "unknownTaxIdType";
}

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Models;
public record PaymentMethod(
long AccountCredit,

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models.Sales;

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Services;

View File

@ -1,5 +1,6 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services;

View File

@ -4,6 +4,7 @@ using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Models.Business;
using Stripe;

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;

View File

@ -6,6 +6,8 @@ using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -2,7 +2,9 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -2,6 +2,8 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -0,0 +1,147 @@
#nullable enable
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Tax.Commands;
public interface IPreviewTaxAmountCommand
{
Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters);
}
public class PreviewTaxAmountCommand(
ILogger<PreviewTaxAmountCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
ITaxService taxService) : IPreviewTaxAmountCommand
{
public async Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
{
var (planType, productType, taxInformation) = parameters;
var plan = await pricingClient.GetPlanOrThrow(planType);
var options = new InvoiceCreatePreviewOptions
{
Currency = "usd",
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions
{
Country = taxInformation.Country,
PostalCode = taxInformation.PostalCode
}
},
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = [
new InvoiceSubscriptionDetailsItemOptions
{
Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId,
Quantity = 1
}
]
}
};
if (productType == ProductType.SecretsManager)
{
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = 1
});
options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone;
}
if (!string.IsNullOrEmpty(taxInformation.TaxId))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInformation.Country,
taxInformation.TaxId);
if (string.IsNullOrEmpty(taxIdType))
{
return BadRequest.UnknownTaxIdType;
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
{
Type = taxIdType,
Value = taxInformation.TaxId
}
];
}
if (planType.GetProductTier() == ProductTierType.Families)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
else
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = options.CustomerDetails.Address.Country == "US" ||
options.CustomerDetails.TaxIds is [_, ..]
};
}
try
{
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
return Convert.ToDecimal(invoice.Tax) / 100;
}
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
return BadRequest.TaxLocationInvalid;
}
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
return BadRequest.TaxIdNumberInvalid;
}
catch (StripeException stripeException)
{
logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code);
return new Unhandled();
}
}
}
#region Command Parameters
public record OrganizationTrialParameters
{
public required PlanType PlanType { get; set; }
public required ProductType ProductType { get; set; }
public required TaxInformationDTO TaxInformation { get; set; }
public void Deconstruct(
out PlanType planType,
out ProductType productType,
out TaxInformationDTO taxInformation)
{
planType = PlanType;
productType = ProductType;
taxInformation = TaxInformation;
}
public record TaxInformationDTO
{
public required string Country { get; set; }
public required string PostalCode { get; set; }
public string? TaxId { get; set; }
}
}
#endregion

View File

@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace Bit.Core.Billing.Models;
namespace Bit.Core.Billing.Tax.Models;
public class TaxIdType
{

View File

@ -1,6 +1,6 @@
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models;
namespace Bit.Core.Billing.Tax.Models;
public record TaxInformation(
string Country,

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Billing.Models.Api.Requests.Accounts;
namespace Bit.Core.Billing.Tax.Requests;
public class PreviewIndividualInvoiceRequestBody
{

View File

@ -2,7 +2,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Models.Api.Requests.Organizations;
namespace Bit.Core.Billing.Tax.Requests;
public class PreviewOrganizationInvoiceRequestBody
{

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Billing.Models.Api.Requests;
namespace Bit.Core.Billing.Tax.Requests;
public class TaxInformationRequestModel
{

View File

@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Models.Api.Responses;
namespace Bit.Core.Billing.Tax.Responses;
public record PreviewInvoiceResponseModel(
decimal EffectiveTaxRate,

View File

@ -1,6 +1,6 @@
using Bit.Core.Billing.Services.Contracts;
namespace Bit.Core.Billing.Services;
namespace Bit.Core.Billing.Tax.Services;
/// <summary>
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.

View File

@ -1,7 +1,7 @@
#nullable enable
using Stripe;
namespace Bit.Core.Billing.Services;
namespace Bit.Core.Billing.Tax.Services;
public interface IAutomaticTaxStrategy
{

View File

@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Services;
namespace Bit.Core.Billing.Tax.Services;
public interface ITaxService
{

View File

@ -5,7 +5,7 @@ using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Entities;
using Bit.Core.Services;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class AutomaticTaxFactory(
IFeatureService featureService,

View File

@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{

View File

@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
{

View File

@ -1,7 +1,7 @@
using System.Text.RegularExpressions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Services;
namespace Bit.Core.Billing.Tax.Services.Implementations;
public class TaxService : ITaxService
{

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Services;
using Stripe;

View File

@ -1,9 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;

View File

@ -3,14 +3,15 @@ using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -16,6 +16,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;

View File

@ -7,10 +7,10 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Models.Api;
using Bit.Core.Models.BitStripe;

View File

@ -3,13 +3,14 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Stubs;
using Bit.Core.Test.Billing.Tax.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;

View File

@ -0,0 +1,346 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Commands;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters;
namespace Bit.Core.Test.Billing.Tax.Commands;
public class PreviewTaxAmountCommandTests
{
private readonly ILogger<PreviewTaxAmountCommand> _logger = Substitute.For<ILogger<PreviewTaxAmountCommand>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ITaxService _taxService = Substitute.For<ITaxService>();
private readonly PreviewTaxAmountCommand _command;
public PreviewTaxAmountCommandTests()
{
_command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService);
}
[Fact]
public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.AutomaticTax.Enabled == true
))
.Returns(expectedInvoice);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT0);
var taxAmount = result.AsT0;
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
}
[Fact]
public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.FamiliesAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.AutomaticTax.Enabled == true
))
.Returns(expectedInvoice);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT0);
var taxAmount = result.AsT0;
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
}
[Fact]
public async Task Run_WithSecretsManagerPlan_GetsTaxAmount()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.SecretsManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.SubscriptionDetails.Items.Count == 2 &&
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId &&
options.SubscriptionDetails.Items[1].Quantity == 1 &&
options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone &&
options.AutomaticTax.Enabled == true
))
.Returns(expectedInvoice);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT0);
var taxAmount = result.AsT0;
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
}
[Fact]
public async Task Run_NonUSWithoutTaxId_GetsTaxAmount()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "CA",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "CA" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.AutomaticTax.Enabled == false
))
.Returns(expectedInvoice);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT0);
var taxAmount = result.AsT0;
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
}
[Fact]
public async Task Run_NonUSWithTaxId_GetsTaxAmount()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "CA",
PostalCode = "12345",
TaxId = "123456789"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId)
.Returns("ca_st");
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "CA" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.CustomerDetails.TaxIds.Count == 1 &&
options.CustomerDetails.TaxIds[0].Type == "ca_st" &&
options.CustomerDetails.TaxIds[0].Value == "123456789" &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.AutomaticTax.Enabled == true
))
.Returns(expectedInvoice);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT0);
var taxAmount = result.AsT0;
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
}
[Fact]
public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "CA",
PostalCode = "12345",
TaxId = "123456789"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId)
.Returns((string)null);
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.UnknownTaxIdType, badRequest.TranslationKey);
}
[Fact]
public async Task Run_CustomerTaxLocationInvalid_BadRequest()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Throws(new StripeException
{
StripeError = new StripeError { Code = StripeConstants.ErrorCodes.CustomerTaxLocationInvalid }
});
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.CustomerTaxLocationInvalid, badRequest.TranslationKey);
}
[Fact]
public async Task Run_TaxIdInvalid_BadRequest()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Throws(new StripeException
{
StripeError = new StripeError { Code = StripeConstants.ErrorCodes.TaxIdInvalid }
});
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.TaxIdInvalid, badRequest.TranslationKey);
}
}

View File

@ -3,14 +3,14 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Billing.Services.Implementations;
namespace Bit.Core.Test.Billing.Tax.Services;
[SutProviderCustomize]
public class AutomaticTaxFactoryTests

View File

@ -1,5 +1,5 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -7,7 +7,7 @@ using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Test.Billing.Tax.Services;
[SutProviderCustomize]
public class BusinessUseAutomaticTaxStrategyTests

View File

@ -1,7 +1,7 @@
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Stripe;
namespace Bit.Core.Test.Billing.Stubs;
namespace Bit.Core.Test.Billing.Tax.Services;
/// <param name="isAutomaticTaxEnabled">
/// Whether the subscription options will have automatic tax enabled or not.

View File

@ -1,5 +1,5 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -7,7 +7,7 @@ using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax;
namespace Bit.Core.Test.Billing.Tax.Services;
[SutProviderCustomize]
public class PersonalUseAutomaticTaxStrategyTests

View File

@ -1,13 +1,12 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Api.Requests;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Stubs;
using Bit.Core.Test.Billing.Tax.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;