1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-03 09:40:31 -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 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; }

View File

@ -1265,7 +1265,7 @@ public class StripePaymentService : IPaymentService
{
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 class StripePaymentService : IPaymentService
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 class StripePaymentService : IPaymentService
},
};
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 class StripePaymentService : IPaymentService
{
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;

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);
}
}