mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 08:32:50 -05: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:
@ -42,6 +42,28 @@ public class ProviderBillingController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("invoices/{invoiceId}")]
|
||||
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var reportContent = await providerBillingService.GenerateClientInvoiceReport(invoiceId);
|
||||
|
||||
if (reportContent == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.File(
|
||||
reportContent,
|
||||
"text/csv");
|
||||
}
|
||||
|
||||
[HttpGet("payment-information")]
|
||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
|
@ -13,6 +13,7 @@ public record InvoicesResponse(
|
||||
}
|
||||
|
||||
public record InvoiceDTO(
|
||||
string Id,
|
||||
DateTime Date,
|
||||
string Number,
|
||||
decimal Total,
|
||||
@ -21,6 +22,7 @@ public record InvoiceDTO(
|
||||
string PdfUrl)
|
||||
{
|
||||
public static InvoiceDTO From(Invoice invoice) => new(
|
||||
invoice.Id,
|
||||
invoice.Created,
|
||||
invoice.Number,
|
||||
invoice.Total / 100M,
|
||||
|
@ -12,4 +12,5 @@ public static class HandledStripeWebhook
|
||||
public const string InvoiceCreated = "invoice.created";
|
||||
public const string PaymentMethodAttached = "payment_method.attached";
|
||||
public const string CustomerUpdated = "customer.updated";
|
||||
public const string InvoiceFinalized = "invoice.finalized";
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ public class StripeController : Controller
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public StripeController(
|
||||
GlobalSettings globalSettings,
|
||||
@ -77,7 +78,8 @@ public class StripeController : Controller
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository)
|
||||
IProviderRepository providerRepository,
|
||||
IProviderEventService providerEventService)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
@ -106,6 +108,7 @@ public class StripeController : Controller
|
||||
_stripeFacade = stripeFacade;
|
||||
_featureService = featureService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
@ -203,6 +206,11 @@ public class StripeController : Controller
|
||||
await HandleCustomerUpdatedEventAsync(parsedEvent);
|
||||
return Ok();
|
||||
}
|
||||
case HandledStripeWebhook.InvoiceFinalized:
|
||||
{
|
||||
await HandleInvoiceFinalizedEventAsync(parsedEvent);
|
||||
return Ok();
|
||||
}
|
||||
default:
|
||||
{
|
||||
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
||||
@ -397,12 +405,18 @@ public class StripeController : Controller
|
||||
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice))
|
||||
|
||||
if (ShouldAttemptToPayInvoice(invoice))
|
||||
{
|
||||
return;
|
||||
await AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
|
||||
await AttemptToPayInvoiceAsync(invoice);
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
|
||||
private async Task HandleInvoiceFinalizedEventAsync(Event parsedEvent)
|
||||
{
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
8
src/Billing/Services/IProviderEventService.cs
Normal file
8
src/Billing/Services/IProviderEventService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Services;
|
||||
|
||||
public interface IProviderEventService
|
||||
{
|
||||
Task TryRecordInvoiceLineItems(Event parsedEvent);
|
||||
}
|
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -167,7 +167,7 @@ public class StripeEventService : IStripeEventService
|
||||
HandledStripeWebhook.UpcomingInvoice =>
|
||||
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
|
||||
|
||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated =>
|
||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
|
||||
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
|
||||
|
||||
HandledStripeWebhook.PaymentMethodAttached =>
|
||||
|
@ -81,6 +81,7 @@ public class Startup
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItem : ITableObject<Guid>
|
||||
public int AssignedSeats { get; set; }
|
||||
public int UsedSeats { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
|
@ -5,6 +5,6 @@ namespace Bit.Core.Billing.Repositories;
|
||||
|
||||
public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid>
|
||||
{
|
||||
Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId);
|
||||
Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId);
|
||||
Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId);
|
||||
}
|
||||
|
@ -43,6 +43,9 @@ public interface IProviderBillingService
|
||||
Provider provider,
|
||||
Organization organization);
|
||||
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItemRepository(
|
||||
globalSettings.SqlServer.ConnectionString,
|
||||
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository
|
||||
{
|
||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||
{
|
||||
var sqlConnection = new SqlConnection(ConnectionString);
|
||||
|
||||
@ -23,7 +23,7 @@ public class ProviderInvoiceItemRepository(
|
||||
new { InvoiceId = invoiceId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.FirstOrDefault();
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||
|
@ -12,10 +12,6 @@ public class ProviderInvoiceItemEntityTypeConfiguration : IEntityTypeConfigurati
|
||||
.Property(t => t.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(providerInvoiceItem => new { providerInvoiceItem.Id, providerInvoiceItem.InvoiceId })
|
||||
.IsUnique();
|
||||
|
||||
builder.ToTable(nameof(ProviderInvoiceItem));
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ public class ProviderInvoiceItemRepository(
|
||||
mapper,
|
||||
context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository
|
||||
{
|
||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||
{
|
||||
using var serviceScope = ServiceScopeFactory.CreateScope();
|
||||
|
||||
@ -27,7 +27,7 @@ public class ProviderInvoiceItemRepository(
|
||||
where providerInvoiceItem.InvoiceId == invoiceId
|
||||
select providerInvoiceItem;
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
return await query.ToArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Create]
|
||||
@PlanName NVARCHAR (50),
|
||||
@AssignedSeats INT,
|
||||
@UsedSeats INT,
|
||||
@Total MONEY
|
||||
@Total MONEY,
|
||||
@Created DATETIME2 (7) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||
|
||||
INSERT INTO [dbo].[ProviderInvoiceItem]
|
||||
(
|
||||
[Id],
|
||||
@ -36,6 +39,6 @@ BEGIN
|
||||
@AssignedSeats,
|
||||
@UsedSeats,
|
||||
@Total,
|
||||
GETUTCDATE()
|
||||
@Created
|
||||
)
|
||||
END
|
||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Update]
|
||||
@PlanName NVARCHAR (50),
|
||||
@AssignedSeats INT,
|
||||
@UsedSeats INT,
|
||||
@Total MONEY
|
||||
@Total MONEY,
|
||||
@Created DATETIME2 (7) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||
|
||||
UPDATE
|
||||
[dbo].[ProviderInvoiceItem]
|
||||
SET
|
||||
@ -22,7 +25,8 @@ BEGIN
|
||||
[PlanName] = @PlanName,
|
||||
[AssignedSeats] = @AssignedSeats,
|
||||
[UsedSeats] = @UsedSeats,
|
||||
[Total] = @Total
|
||||
[Total] = @Total,
|
||||
[Created] = @Created
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
|
@ -2,7 +2,7 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[InvoiceId] VARCHAR (50) NOT NULL,
|
||||
[InvoiceNumber] VARCHAR (50) NOT NULL,
|
||||
[InvoiceNumber] VARCHAR (50) NULL,
|
||||
[ClientName] NVARCHAR (50) NOT NULL,
|
||||
[PlanName] NVARCHAR (50) NOT NULL,
|
||||
[AssignedSeats] INT NOT NULL,
|
||||
@ -10,6 +10,5 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
||||
[Total] MONEY NOT NULL,
|
||||
[Created] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]),
|
||||
CONSTRAINT [PK_ProviderIdInvoiceId] UNIQUE ([ProviderId], [InvoiceId])
|
||||
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
|
Reference in New Issue
Block a user