1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-30 13:33:24 +01:00

[AC-2795] Add account credit & tax information to provider subscription (#4276)

* Add account credit, suspension and tax information to subscription response

* Run dotnet format'
This commit is contained in:
Alex Morask 2024-06-26 09:08:18 -04:00 committed by GitHub
parent 6646d11074
commit e8e725c389
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 220 additions and 61 deletions

View File

@ -1,6 +1,5 @@
using System.Globalization; using System.Globalization;
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -29,7 +28,6 @@ namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -272,13 +270,6 @@ public class ProviderBillingService(
{ {
ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(provider);
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Consolidated billing subscription cannot be retrieved for reseller-type provider ({ID})", provider.Id);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
var subscription = await subscriberService.GetSubscription(provider, new SubscriptionGetOptions var subscription = await subscriberService.GetSubscription(provider, new SubscriptionGetOptions
{ {
Expand = ["customer", "test_clock"] Expand = ["customer", "test_clock"]
@ -289,18 +280,6 @@ public class ProviderBillingService(
return null; return null;
} }
DateTime? subscriptionSuspensionDate = null;
DateTime? subscriptionUnpaidPeriodEndDate = null;
if (featureService.IsEnabled(FeatureFlagKeys.AC1795_UpdatedSubscriptionStatusSection))
{
var (suspensionDate, unpaidPeriodEndDate) = await paymentService.GetSuspensionDateAsync(subscription);
if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue)
{
subscriptionSuspensionDate = suspensionDate;
subscriptionUnpaidPeriodEndDate = unpaidPeriodEndDate;
}
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var configuredProviderPlans = providerPlans var configuredProviderPlans = providerPlans
@ -308,11 +287,15 @@ public class ProviderBillingService(
.Select(ConfiguredProviderPlanDTO.From) .Select(ConfiguredProviderPlanDTO.From)
.ToList(); .ToList();
var taxInformation = await subscriberService.GetTaxInformation(provider);
var suspension = await GetSuspensionAsync(stripeAdapter, subscription);
return new ConsolidatedBillingSubscriptionDTO( return new ConsolidatedBillingSubscriptionDTO(
configuredProviderPlans, configuredProviderPlans,
subscription, subscription,
subscriptionSuspensionDate, taxInformation,
subscriptionUnpaidPeriodEndDate); suspension);
} }
public async Task ScaleSeats( public async Task ScaleSeats(

View File

@ -857,13 +857,16 @@ public class ProviderBillingServiceTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task GetConsolidatedBillingSubscription_Success( public async Task GetConsolidatedBillingSubscription_Active_NoSuspension_Success(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider) Provider provider)
{ {
var subscriberService = sutProvider.GetDependency<ISubscriberService>(); var subscriberService = sutProvider.GetDependency<ISubscriberService>();
var subscription = new Subscription(); var subscription = new Subscription
{
Status = "active"
};
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>( subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription); options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
@ -894,26 +897,33 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); var taxInformation =
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
Assert.NotNull(consolidatedBillingSubscription); subscriberService.GetTaxInformation(provider).Returns(taxInformation);
Assert.Equivalent(consolidatedBillingSubscription.Subscription, subscription); var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
Assert.Equal(2, consolidatedBillingSubscription.ProviderPlans.Count); Assert.Equal(2, gotProviderPlans.Count);
var configuredEnterprisePlan = var configuredEnterprisePlan =
consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => gotProviderPlans.FirstOrDefault(configuredPlan =>
configuredPlan.PlanType == PlanType.EnterpriseMonthly); configuredPlan.PlanType == PlanType.EnterpriseMonthly);
var configuredTeamsPlan = var configuredTeamsPlan =
consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => gotProviderPlans.FirstOrDefault(configuredPlan =>
configuredPlan.PlanType == PlanType.TeamsMonthly); configuredPlan.PlanType == PlanType.TeamsMonthly);
Compare(enterprisePlan, configuredEnterprisePlan); Compare(enterprisePlan, configuredEnterprisePlan);
Compare(teamsPlan, configuredTeamsPlan); Compare(teamsPlan, configuredTeamsPlan);
Assert.Equivalent(subscription, gotSubscription);
Assert.Equivalent(taxInformation, gotTaxInformation);
Assert.Null(gotSuspension);
return; return;
void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan) void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan)
@ -927,6 +937,83 @@ public class ProviderBillingServiceTests
} }
} }
[Theory, BitAutoData]
public async Task GetConsolidatedBillingSubscription_PastDue_HasSuspension_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
var subscription = new Subscription
{
Id = "subscription_id",
Status = "past_due",
CollectionMethod = "send_invoice"
};
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var enterprisePlan = new ProviderPlan
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 0
};
var teamsPlan = new ProviderPlan
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = 50,
PurchasedSeats = 10,
AllocatedSeats = 60
};
var providerPlans = new List<ProviderPlan> { enterprisePlan, teamsPlan, };
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var taxInformation =
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
subscriberService.GetTaxInformation(provider).Returns(taxInformation);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var openInvoice = new Invoice
{
Id = "invoice_id",
Status = "open",
DueDate = new DateTime(2024, 6, 1),
Created = new DateTime(2024, 5, 1),
PeriodEnd = new DateTime(2024, 6, 1)
};
stripeAdapter.InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
options.Query == $"subscription:'{subscription.Id}' status:'open'"))
.Returns([openInvoice]);
var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
Assert.Equal(2, gotProviderPlans.Count);
Assert.Equivalent(subscription, gotSubscription);
Assert.Equivalent(taxInformation, gotTaxInformation);
Assert.NotNull(gotSuspension);
Assert.Equal(openInvoice.DueDate.Value.AddDays(30), gotSuspension.SuspensionDate);
Assert.Equal(openInvoice.PeriodEnd, gotSuspension.UnpaidPeriodEndDate);
Assert.Equal(30, gotSuspension.GracePeriod);
}
#endregion #endregion
#region StartSubscription #region StartSubscription

View File

@ -8,11 +8,11 @@ public record ConsolidatedBillingSubscriptionResponse(
DateTime CurrentPeriodEndDate, DateTime CurrentPeriodEndDate,
decimal? DiscountPercentage, decimal? DiscountPercentage,
string CollectionMethod, string CollectionMethod,
DateTime? UnpaidPeriodEndDate, IEnumerable<ProviderPlanResponse> Plans,
int? GracePeriod, long AccountCredit,
DateTime? SuspensionDate, TaxInformationDTO TaxInformation,
DateTime? CancelAt, DateTime? CancelAt,
IEnumerable<ProviderPlanResponse> Plans) SubscriptionSuspensionDTO Suspension)
{ {
private const string _annualCadence = "Annual"; private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly"; private const string _monthlyCadence = "Monthly";
@ -20,9 +20,9 @@ public record ConsolidatedBillingSubscriptionResponse(
public static ConsolidatedBillingSubscriptionResponse From( public static ConsolidatedBillingSubscriptionResponse From(
ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription) ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription)
{ {
var (providerPlans, subscription, suspensionDate, unpaidPeriodEndDate) = consolidatedBillingSubscription; var (providerPlans, subscription, taxInformation, suspension) = consolidatedBillingSubscription;
var providerPlansDTO = providerPlans var providerPlanResponses = providerPlans
.Select(providerPlan => .Select(providerPlan =>
{ {
var plan = StaticStore.GetPlan(providerPlan.PlanType); var plan = StaticStore.GetPlan(providerPlan.PlanType);
@ -37,18 +37,16 @@ public record ConsolidatedBillingSubscriptionResponse(
cadence); cadence);
}); });
var gracePeriod = subscription.CollectionMethod == "charge_automatically" ? 14 : 30;
return new ConsolidatedBillingSubscriptionResponse( return new ConsolidatedBillingSubscriptionResponse(
subscription.Status, subscription.Status,
subscription.CurrentPeriodEnd, subscription.CurrentPeriodEnd,
subscription.Customer?.Discount?.Coupon?.PercentOff, subscription.Customer?.Discount?.Coupon?.PercentOff,
subscription.CollectionMethod, subscription.CollectionMethod,
unpaidPeriodEndDate, providerPlanResponses,
gracePeriod, subscription.Customer?.Balance ?? 0,
suspensionDate, taxInformation,
subscription.CancelAt, subscription.CancelAt,
providerPlansDTO); suspension);
} }
} }

View File

@ -5,5 +5,5 @@ namespace Bit.Core.Billing.Models;
public record ConsolidatedBillingSubscriptionDTO( public record ConsolidatedBillingSubscriptionDTO(
List<ConfiguredProviderPlanDTO> ProviderPlans, List<ConfiguredProviderPlanDTO> ProviderPlans,
Subscription Subscription, Subscription Subscription,
DateTime? SuspensionDate, TaxInformationDTO TaxInformation,
DateTime? UnpaidPeriodEndDate); SubscriptionSuspensionDTO Suspension);

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Billing.Models;
public record SubscriptionSuspensionDTO(
DateTime SuspensionDate,
DateTime UnpaidPeriodEndDate,
int GracePeriod);

View File

@ -1,4 +1,8 @@
namespace Bit.Core.Billing; using Bit.Core.Billing.Models;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing;
public static class Utilities public static class Utilities
{ {
@ -8,4 +12,67 @@ public static class Utilities
string internalMessage = null, string internalMessage = null,
Exception innerException = null) => new("Something went wrong with your request. Please contact support.", Exception innerException = null) => new("Something went wrong with your request. Please contact support.",
internalMessage, innerException); internalMessage, innerException);
public static async Task<SubscriptionSuspensionDTO> GetSuspensionAsync(
IStripeAdapter stripeAdapter,
Subscription subscription)
{
if (subscription.Status is not "past_due" && subscription.Status is not "unpaid")
{
return null;
}
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
{
Query = $"subscription:'{subscription.Id}' status:'open'"
});
if (openInvoices.Count == 0)
{
return null;
}
var currentDate = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
switch (subscription.CollectionMethod)
{
case "charge_automatically":
{
var firstOverdueInvoice = openInvoices
.Where(invoice => invoice.PeriodEnd < currentDate && invoice.Attempted)
.MinBy(invoice => invoice.Created);
if (firstOverdueInvoice == null)
{
return null;
}
const int gracePeriod = 14;
return new SubscriptionSuspensionDTO(
firstOverdueInvoice.Created.AddDays(gracePeriod),
firstOverdueInvoice.PeriodEnd,
gracePeriod);
}
case "send_invoice":
{
var firstOverdueInvoice = openInvoices
.Where(invoice => invoice.DueDate < currentDate)
.MinBy(invoice => invoice.Created);
if (firstOverdueInvoice?.DueDate == null)
{
return null;
}
const int gracePeriod = 30;
return new SubscriptionSuspensionDTO(
firstOverdueInvoice.DueDate.Value.AddDays(gracePeriod),
firstOverdueInvoice.PeriodEnd,
gracePeriod);
}
default: return null;
}
}
} }

View File

@ -385,19 +385,34 @@ public class ProviderBillingControllerTests
var subscription = new Subscription var subscription = new Subscription
{ {
Status = "active", Status = "unpaid",
CurrentPeriodEnd = new DateTime(2025, 1, 1), CurrentPeriodEnd = new DateTime(2024, 6, 30),
Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } } Customer = new Customer
{
Balance = 100000,
Discount = new Discount
{
Coupon = new Coupon
{
PercentOff = 10
}
}
}
}; };
DateTime? SuspensionDate = new DateTime(); var taxInformation =
DateTime? UnpaidPeriodEndDate = new DateTime(); new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
var gracePeriod = 30;
var suspension = new SubscriptionSuspensionDTO(
new DateTime(2024, 7, 30),
new DateTime(2024, 5, 30),
30);
var consolidatedBillingSubscription = new ConsolidatedBillingSubscriptionDTO( var consolidatedBillingSubscription = new ConsolidatedBillingSubscriptionDTO(
configuredProviderPlans, configuredProviderPlans,
subscription, subscription,
SuspensionDate, taxInformation,
UnpaidPeriodEndDate); suspension);
sutProvider.GetDependency<IProviderBillingService>().GetConsolidatedBillingSubscription(provider) sutProvider.GetDependency<IProviderBillingService>().GetConsolidatedBillingSubscription(provider)
.Returns(consolidatedBillingSubscription); .Returns(consolidatedBillingSubscription);
@ -408,13 +423,10 @@ public class ProviderBillingControllerTests
var response = ((Ok<ConsolidatedBillingSubscriptionResponse>)result).Value; var response = ((Ok<ConsolidatedBillingSubscriptionResponse>)result).Value;
Assert.Equal(response.Status, subscription.Status); Assert.Equal(subscription.Status, response.Status);
Assert.Equal(response.CurrentPeriodEndDate, subscription.CurrentPeriodEnd); Assert.Equal(subscription.CurrentPeriodEnd, response.CurrentPeriodEndDate);
Assert.Equal(response.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff); Assert.Equal(subscription.Customer!.Discount!.Coupon!.PercentOff, response.DiscountPercentage);
Assert.Equal(response.CollectionMethod, subscription.CollectionMethod); Assert.Equal(subscription.CollectionMethod, response.CollectionMethod);
Assert.Equal(response.UnpaidPeriodEndDate, UnpaidPeriodEndDate);
Assert.Equal(response.GracePeriod, gracePeriod);
Assert.Equal(response.SuspensionDate, SuspensionDate);
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var providerTeamsPlan = response.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name); var providerTeamsPlan = response.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name);
@ -433,7 +445,13 @@ public class ProviderBillingControllerTests
Assert.Equal(90, providerEnterprisePlan.AssignedSeats); Assert.Equal(90, providerEnterprisePlan.AssignedSeats);
Assert.Equal(100 * enterprisePlan.PasswordManager.ProviderPortalSeatPrice, providerEnterprisePlan.Cost); Assert.Equal(100 * enterprisePlan.PasswordManager.ProviderPortalSeatPrice, providerEnterprisePlan.Cost);
Assert.Equal("Monthly", providerEnterprisePlan.Cadence); Assert.Equal("Monthly", providerEnterprisePlan.Cadence);
Assert.Equal(100000, response.AccountCredit);
Assert.Equal(taxInformation, response.TaxInformation);
Assert.Null(response.CancelAt);
Assert.Equal(suspension, response.Suspension);
} }
#endregion #endregion
#region GetTaxInformationAsync #region GetTaxInformationAsync