From 53f7d9655e84318f42a2b03ac5f5cb490d639d37 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 13 May 2025 09:28:31 -0400 Subject: [PATCH] [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 --- .../RemoveOrganizationFromProviderCommand.cs | 3 +- .../Billing/ProviderBillingService.cs | 4 +- ...oveOrganizationFromProviderCommandTests.cs | 1 + .../Billing/ProviderBillingServiceTests.cs | 1 + .../Billing/TaxServiceTests.cs | 2 +- .../Controllers/AccountsBillingController.cs | 2 +- .../Billing/Controllers/InvoicesController.cs | 2 +- .../OrganizationBillingController.cs | 1 + .../Controllers/ProviderBillingController.cs | 1 + .../Billing/Controllers/StripeController.cs | 2 +- src/Api/Billing/Controllers/TaxController.cs | 36 ++ ...axAmountForOrganizationTrialRequestBody.cs | 27 ++ .../Requests/TaxInformationRequestBody.cs | 2 +- .../Models/Responses/PaymentMethodResponse.cs | 1 + .../Responses/ProviderSubscriptionResponse.cs | 1 + .../Responses/TaxInformationResponse.cs | 2 +- .../Implementations/UpcomingInvoiceHandler.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Billing/Models/BillingCommandResult.cs | 36 ++ src/Core/Billing/Models/PaymentMethod.cs | 4 +- .../Billing/Models/Sales/CustomerSetup.cs | 4 +- .../Billing/Models/Sales/OrganizationSale.cs | 1 + .../Billing/Models/Sales/PremiumUserSale.cs | 3 +- .../Services/IOrganizationBillingService.cs | 1 + .../Services/IPremiumUserBillingService.cs | 1 + .../Services/IProviderBillingService.cs | 1 + .../Billing/Services/ISubscriberService.cs | 1 + .../OrganizationBillingService.cs | 2 + .../PremiumUserBillingService.cs | 4 +- .../Implementations/SubscriberService.cs | 2 + .../Tax/Commands/PreviewTaxAmountCommand.cs | 147 ++++++++ .../Billing/{ => Tax}/Models/TaxIdType.cs | 2 +- .../{ => Tax}/Models/TaxInformation.cs | 2 +- .../PreviewIndividualInvoiceRequestModel.cs | 2 +- .../PreviewOrganizationInvoiceRequestModel.cs | 2 +- .../Requests/TaxInformationRequestModel.cs | 2 +- .../Responses/PreviewInvoiceResponseModel.cs | 2 +- .../Services/IAutomaticTaxFactory.cs | 2 +- .../Services/IAutomaticTaxStrategy.cs | 2 +- .../Billing/{ => Tax}/Services/ITaxService.cs | 2 +- .../Implementations}/AutomaticTaxFactory.cs | 2 +- .../BusinessUseAutomaticTaxStrategy.cs | 2 +- .../PersonalUseAutomaticTaxStrategy.cs | 2 +- .../Services/Implementations}/TaxService.cs | 4 +- src/Core/Billing/Utilities.cs | 1 + src/Core/Services/IPaymentService.cs | 5 +- .../Implementations/StripePaymentService.cs | 9 +- .../Services/Implementations/UserService.cs | 1 + .../ProviderBillingControllerTests.cs | 2 +- .../Services/SubscriberServiceTests.cs | 5 +- .../Commands/PreviewTaxAmountCommandTests.cs | 346 ++++++++++++++++++ .../Services}/AutomaticTaxFactoryTests.cs | 4 +- .../BusinessUseAutomaticTaxStrategyTests.cs | 4 +- .../Services}/FakeAutomaticTaxStrategy.cs | 4 +- .../PersonalUseAutomaticTaxStrategyTests.cs | 4 +- .../Services/StripePaymentServiceTests.cs | 7 +- 56 files changed, 672 insertions(+), 50 deletions(-) create mode 100644 src/Api/Billing/Controllers/TaxController.cs create mode 100644 src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs create mode 100644 src/Core/Billing/Models/BillingCommandResult.cs create mode 100644 src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs rename src/Core/Billing/{ => Tax}/Models/TaxIdType.cs (92%) rename src/Core/Billing/{ => Tax}/Models/TaxInformation.cs (93%) rename src/Core/Billing/{Models/Api/Requests/Accounts => Tax/Requests}/PreviewIndividualInvoiceRequestModel.cs (87%) rename src/Core/Billing/{Models/Api/Requests/Organizations => Tax/Requests}/PreviewOrganizationInvoiceRequestModel.cs (93%) rename src/Core/Billing/{Models/Api => Tax}/Requests/TaxInformationRequestModel.cs (84%) rename src/Core/Billing/{Models/Api => Tax}/Responses/PreviewInvoiceResponseModel.cs (74%) rename src/Core/Billing/{ => Tax}/Services/IAutomaticTaxFactory.cs (88%) rename src/Core/Billing/{ => Tax}/Services/IAutomaticTaxStrategy.cs (96%) rename src/Core/Billing/{ => Tax}/Services/ITaxService.cs (94%) rename src/Core/Billing/{Services/Implementations/AutomaticTax => Tax/Services/Implementations}/AutomaticTaxFactory.cs (96%) rename src/Core/Billing/{Services/Implementations/AutomaticTax => Tax/Services/Implementations}/BusinessUseAutomaticTaxStrategy.cs (97%) rename src/Core/Billing/{Services/Implementations/AutomaticTax => Tax/Services/Implementations}/PersonalUseAutomaticTaxStrategy.cs (96%) rename src/Core/Billing/{Services => Tax/Services/Implementations}/TaxService.cs (99%) create mode 100644 test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs rename test/Core.Test/Billing/{Services/Implementations => Tax/Services}/AutomaticTaxFactoryTests.cs (96%) rename test/Core.Test/Billing/{Services/Implementations/AutomaticTax => Tax/Services}/BusinessUseAutomaticTaxStrategyTests.cs (99%) rename test/Core.Test/Billing/{Stubs => Tax/Services}/FakeAutomaticTaxStrategy.cs (92%) rename test/Core.Test/Billing/{Services/Implementations/AutomaticTax => Tax/Services}/PersonalUseAutomaticTaxStrategyTests.cs (98%) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 9a62be8dd5..22a2e93642 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -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; diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index f049d6c8df..99831aa3f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -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; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 48eda094e8..b450bf5d7f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -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; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 1862692087..2199bc4bfe 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -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; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs index 3995fb9de6..0a20b34818 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs @@ -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; diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index fcb89226e7..7abcf8c357 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -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; diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs index 686d9b9643..5a1d732f42 100644 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -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; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index b82c627ee0..3ebae433d8 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -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; diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index bb1fd7bb25..78e361e8b3 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -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; diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index f5e8253bfa..15fccd16f4 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -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; diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs new file mode 100644 index 0000000000..7b8b9d960f --- /dev/null +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -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 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( + taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }), + badRequest => Error.BadRequest(badRequest.TranslationKey), + unhandled => Error.ServerError(unhandled.TranslationKey)); + } +} diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs new file mode 100644 index 0000000000..a3fda0fd6c --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs @@ -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; } + } +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index 32ba2effb2..edc45ce483 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -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; diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index b89c1e9db9..fd248a0a00 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index ea1479c9df..a2c6827314 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -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; diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs index 02349d74f7..59e4934751 100644 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f75cbf8a8b..4c27098f38 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -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; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 17285e0676..5c7a42e9b8 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -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(); services.AddLicenseServices(); services.AddPricingClient(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Models/BillingCommandResult.cs b/src/Core/Billing/Models/BillingCommandResult.cs new file mode 100644 index 0000000000..1b8eefe8df --- /dev/null +++ b/src/Core/Billing/Models/BillingCommandResult.cs @@ -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 : OneOfBase +{ + private BillingCommandResult(OneOf input) : base(input) { } + + public static implicit operator BillingCommandResult(T output) => new(output); + public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); + public static implicit operator BillingCommandResult(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"; +} diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index b07fe82e46..2b8c59fa05 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -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, diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs index bb4f2352e3..aa67c712b5 100644 --- a/src/Core/Billing/Models/Sales/CustomerSetup.cs +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; + +namespace Bit.Core.Billing.Models.Sales; #nullable enable diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 0602cf1dd9..87d17551eb 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -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; diff --git a/src/Core/Billing/Models/Sales/PremiumUserSale.cs b/src/Core/Billing/Models/Sales/PremiumUserSale.cs index 6bc054eac5..8c9b696aa3 100644 --- a/src/Core/Billing/Models/Sales/PremiumUserSale.cs +++ b/src/Core/Billing/Models/Sales/PremiumUserSale.cs @@ -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; diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index db62d545e3..5f7d33f118 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs index b3bb580e2d..ed7a003599 100644 --- a/src/Core/Billing/Services/IPremiumUserBillingService.cs +++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 0171a7e1c3..b6ddbdd642 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index bb0a23020c..6910948436 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -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; diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 2e902ca028..20f6105c2a 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index cbd4dbbdff..1b845e93f1 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 1b0e5b665b..10247cdf92 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -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; diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs new file mode 100644 index 0000000000..304abbaae0 --- /dev/null +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -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> Run(OrganizationTrialParameters parameters); +} + +public class PreviewTaxAmountCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter, + ITaxService taxService) : IPreviewTaxAmountCommand +{ + public async Task> 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 diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Tax/Models/TaxIdType.cs similarity index 92% rename from src/Core/Billing/Models/TaxIdType.cs rename to src/Core/Billing/Tax/Models/TaxIdType.cs index 3fc246d68b..6f8cfdde99 100644 --- a/src/Core/Billing/Models/TaxIdType.cs +++ b/src/Core/Billing/Tax/Models/TaxIdType.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Tax.Models; public class TaxIdType { diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Tax/Models/TaxInformation.cs similarity index 93% rename from src/Core/Billing/Models/TaxInformation.cs rename to src/Core/Billing/Tax/Models/TaxInformation.cs index 23ed3e5faa..2408ee0ecd 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Tax/Models/TaxInformation.cs @@ -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, diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs similarity index 87% rename from src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs index 8597cea09b..340f07b56c 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs @@ -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 { diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs similarity index 93% rename from src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs index 461a6dca65..bfb47e7b2c 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs @@ -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 { diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs similarity index 84% rename from src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs rename to src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs index 9cb43645c6..13d4870ac5 100644 --- a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Core.Billing.Models.Api.Requests; +namespace Bit.Core.Billing.Tax.Requests; public class TaxInformationRequestModel { diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs similarity index 74% rename from src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs rename to src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs index fdde7dae1e..2753487e2f 100644 --- a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs +++ b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models.Api.Responses; +namespace Bit.Core.Billing.Tax.Responses; public record PreviewInvoiceResponseModel( decimal EffectiveTaxRate, diff --git a/src/Core/Billing/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs similarity index 88% rename from src/Core/Billing/Services/IAutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs index c52a8f2671..90a3bc08ad 100644 --- a/src/Core/Billing/Services/IAutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs @@ -1,6 +1,6 @@ using Bit.Core.Billing.Services.Contracts; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; /// /// Responsible for defining the correct automatic tax strategy for either personal use of business use. diff --git a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs similarity index 96% rename from src/Core/Billing/Services/IAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs index 292f2d0939..557bb1d30c 100644 --- a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs @@ -1,7 +1,7 @@ #nullable enable using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface IAutomaticTaxStrategy { diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Tax/Services/ITaxService.cs similarity index 94% rename from src/Core/Billing/Services/ITaxService.cs rename to src/Core/Billing/Tax/Services/ITaxService.cs index beee113d17..00cbf56a9b 100644 --- a/src/Core/Billing/Services/ITaxService.cs +++ b/src/Core/Billing/Tax/Services/ITaxService.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface ITaxService { diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs similarity index 96% rename from src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs index 133cd2c7a7..fa110f79d5 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs @@ -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, diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs similarity index 97% rename from src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs index 40eb6e4540..310aced130 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs @@ -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 { diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs similarity index 96% rename from src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs index 15ee1adf8f..e89fc6a3b3 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs @@ -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 { diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs similarity index 99% rename from src/Core/Billing/Services/TaxService.cs rename to src/Core/Billing/Tax/Services/Implementations/TaxService.cs index 3066be92d1..204c997335 100644 --- a/src/Core/Billing/Services/TaxService.cs +++ b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs @@ -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 { diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 695a3b1bb4..ebb7b0e525 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Services; using Stripe; diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index ded9f4cfd3..3fdb829cf4 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -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; diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 85ad7d64d7..65c0525535 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -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; diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 71661493ec..151ff38aa5 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -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; diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index df84f74d11..36990c7f9a 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -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; diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 9e4be78787..b1f78ed987 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -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; diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs new file mode 100644 index 0000000000..c35dc275e6 --- /dev/null +++ b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs @@ -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 _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ITaxService _taxService = Substitute.For(); + + 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(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(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(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(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(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()) + .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()) + .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); + } +} diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs similarity index 96% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs rename to test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs index 7d5c9c3a26..8de51b1745 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTaxFactoryTests.cs +++ b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs @@ -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 diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs similarity index 99% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs rename to test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs index dc40656275..dc10d222f1 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategyTests.cs +++ b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs @@ -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 diff --git a/test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs similarity index 92% rename from test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs rename to test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs index 253aead5c7..2f3cbc98ee 100644 --- a/test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs +++ b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs @@ -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; /// /// Whether the subscription options will have automatic tax enabled or not. diff --git a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs similarity index 98% rename from test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs rename to test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs index 2d50c9f75a..30614b94ba 100644 --- a/test/Core.Test/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategyTests.cs +++ b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs @@ -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 diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 835f69b214..fa1dd60617 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -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;