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:
parent
60e7db7dbb
commit
bd90c34af2
@ -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; }
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
227
test/Core.Test/Services/StripePaymentServiceTests.cs
Normal file
227
test/Core.Test/Services/StripePaymentServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user