mirror of
https://github.com/bitwarden/server
synced 2026-01-08 03:23:20 +00:00
[AC-1943] Implement provider client invoice report (#4178)
* Update ProviderInvoiceItem SQL configuration * Implement provider client invoice export * Add tests * Run dotnet format * Fixed SPROC backwards compatibility issue
This commit is contained in:
156
src/Billing/Services/Implementations/ProviderEventService.cs
Normal file
156
src/Billing/Services/Implementations/ProviderEventService.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class ProviderEventService(
|
||||
ILogger<ProviderEventService> logger,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade) : IProviderEventService
|
||||
{
|
||||
public async Task TryRecordInvoiceLineItems(Event parsedEvent)
|
||||
{
|
||||
if (parsedEvent.Type is not HandledStripeWebhook.InvoiceCreated and not HandledStripeWebhook.InvoiceFinalized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
||||
|
||||
var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
var hasProviderId = metadata.TryGetValue("providerId", out var providerId);
|
||||
|
||||
if (!hasProviderId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedProviderId = Guid.Parse(providerId);
|
||||
|
||||
switch (parsedEvent.Type)
|
||||
{
|
||||
case HandledStripeWebhook.InvoiceCreated:
|
||||
{
|
||||
var clients =
|
||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
||||
|
||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
||||
}
|
||||
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.SeatPrice * discountedPercentage;
|
||||
|
||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.SeatPrice * discountedPercentage;
|
||||
|
||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.UserCount,
|
||||
Total = client.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
}).ToList();
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var enterpriseClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
||||
|
||||
if (unassignedEnterpriseSeats > 0)
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = enterprisePlan.Name,
|
||||
AssignedSeats = unassignedEnterpriseSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var teamsClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == teamsPlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
||||
|
||||
if (unassignedTeamsSeats > 0)
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = teamsPlan.Name,
|
||||
AssignedSeats = unassignedTeamsSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||
|
||||
break;
|
||||
}
|
||||
case HandledStripeWebhook.InvoiceFinalized:
|
||||
{
|
||||
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoice.Id);
|
||||
|
||||
if (invoiceItems.Count != 0)
|
||||
{
|
||||
await Task.WhenAll(invoiceItems.Select(invoiceItem =>
|
||||
{
|
||||
invoiceItem.InvoiceNumber = invoice.Number;
|
||||
return providerInvoiceItemRepository.ReplaceAsync(invoiceItem);
|
||||
}));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user