1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-07 03:30:32 -05:00

[PM-19180] Calculate sales tax correctly for sponsored plans (#5611)

* [PM-19180] Calculate sales tax correctly for sponsored plans

* Cannot divide by zero if total amount excluding tax is zero.

* Unit tests for families & families for enterprise

---------

Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
This commit is contained in:
Jonas Hendrickx 2025-04-17 17:33:16 +02:00 committed by GitHub
parent 60e7db7dbb
commit bd90c34af2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 263 additions and 30 deletions

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Models.Api.Requests.Organizations; namespace Bit.Core.Billing.Models.Api.Requests.Organizations;
@ -20,6 +21,8 @@ public class OrganizationPasswordManagerRequestModel
{ {
public PlanType Plan { get; set; } public PlanType Plan { get; set; }
public PlanSponsorshipType? SponsoredPlan { get; set; }
[Range(0, int.MaxValue)] [Range(0, int.MaxValue)]
public int Seats { get; set; } public int Seats { get; set; }

View File

@ -1265,7 +1265,7 @@ public class StripePaymentService : IPaymentService
{ {
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); 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() ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M; : 0M;
@ -1300,6 +1300,7 @@ public class StripePaymentService : IPaymentService
string gatewaySubscriptionId) string gatewaySubscriptionId)
{ {
var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan);
var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue;
var options = new InvoiceCreatePreviewOptions var options = new InvoiceCreatePreviewOptions
{ {
@ -1325,24 +1326,25 @@ public class StripePaymentService : IPaymentService
}, },
}; };
if (isSponsored)
{
var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value);
options.SubscriptionDetails.Items.Add(
new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId }
);
}
else
{
if (plan.PasswordManager.HasAdditionalSeatsOption) if (plan.PasswordManager.HasAdditionalSeatsOption)
{ {
options.SubscriptionDetails.Items.Add( options.SubscriptionDetails.Items.Add(
new() new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId }
{
Quantity = parameters.PasswordManager.Seats,
Plan = plan.PasswordManager.StripeSeatPlanId
}
); );
} }
else else
{ {
options.SubscriptionDetails.Items.Add( options.SubscriptionDetails.Items.Add(
new() new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId }
{
Quantity = 1,
Plan = plan.PasswordManager.StripePlanId
}
); );
} }
@ -1366,6 +1368,7 @@ public class StripePaymentService : IPaymentService
}); });
} }
} }
}
if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId)) if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId))
{ {
@ -1420,7 +1423,7 @@ public class StripePaymentService : IPaymentService
{ {
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); 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() ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M; : 0M;

View File

@ -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<StripePaymentService> sutProvider)
{
sutProvider.GetDependency<IAutomaticTaxFactory>()
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
.Returns(new FakeAutomaticTaxStrategy(true));
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(Arg.Is<PlanType>(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<IStripeAdapter>()
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<StripePaymentService> sutProvider)
{
sutProvider.GetDependency<IAutomaticTaxFactory>()
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
.Returns(new FakeAutomaticTaxStrategy(true));
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(Arg.Is<PlanType>(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<IStripeAdapter>()
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<StripePaymentService> sutProvider)
{
sutProvider.GetDependency<IAutomaticTaxFactory>()
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
.Returns(new FakeAutomaticTaxStrategy(true));
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(Arg.Is<PlanType>(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<IStripeAdapter>()
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<StripePaymentService> sutProvider)
{
sutProvider.GetDependency<IAutomaticTaxFactory>()
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
.Returns(new FakeAutomaticTaxStrategy(true));
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(Arg.Is<PlanType>(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<IStripeAdapter>()
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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);
}
}