diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs index 466c32f42d4b..461a6dca6583 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; +using Bit.Core.Enums; namespace Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -20,6 +21,8 @@ public class OrganizationPasswordManagerRequestModel { public PlanType Plan { get; set; } + public PlanSponsorshipType? SponsoredPlan { get; set; } + [Range(0, int.MaxValue)] public int Seats { get; set; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index d82a4d60a7ad..51be3695276b 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1265,7 +1265,7 @@ public async Task PreviewInvoiceAsync( { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; @@ -1300,6 +1300,7 @@ public async Task PreviewInvoiceAsync( string gatewaySubscriptionId) { var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); + var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue; var options = new InvoiceCreatePreviewOptions { @@ -1325,45 +1326,47 @@ public async Task PreviewInvoiceAsync( }, }; - if (plan.PasswordManager.HasAdditionalSeatsOption) + if (isSponsored) { + var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); options.SubscriptionDetails.Items.Add( - new() - { - Quantity = parameters.PasswordManager.Seats, - Plan = plan.PasswordManager.StripeSeatPlanId - } + new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } ); } else { - options.SubscriptionDetails.Items.Add( - new() - { - Quantity = 1, - Plan = plan.PasswordManager.StripePlanId - } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) + if (plan.PasswordManager.HasAdditionalSeatsOption) { - options.SubscriptionDetails.Items.Add(new() - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); + options.SubscriptionDetails.Items.Add( + new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } + ); + } + else + { + options.SubscriptionDetails.Items.Add( + new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } + ); } - if (plan.SecretsManager.HasAdditionalServiceAccountOption) + if (plan.SupportsSecretsManager) { - options.SubscriptionDetails.Items.Add(new() + if (plan.SecretsManager.HasAdditionalSeatsOption) { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.Seats ?? 0, + Plan = plan.SecretsManager.StripeSeatPlanId + }); + } + + if (plan.SecretsManager.HasAdditionalServiceAccountOption) + { + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, + Plan = plan.SecretsManager.StripeServiceAccountPlanId + }); + } } } @@ -1420,7 +1423,7 @@ public async Task PreviewInvoiceAsync( { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs new file mode 100644 index 000000000000..835f69b21440 --- /dev/null +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -0,0 +1,227 @@ +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.Enums; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Stubs; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class StripePaymentServiceTests +{ + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 0, + Tax = 0, + Total = 0 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0M, actual.TaxAmount); + Assert.Equal(0M, actual.TotalAmount); + Assert.Equal(0M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0.08M, actual.TaxAmount); + Assert.Equal(4.08M, actual.TotalAmount); + Assert.Equal(4M, actual.TaxableBaseAmount); + } +}