1
0
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:
Alex Morask
2024-06-14 12:26:49 -04:00
committed by GitHub
parent b392cc962d
commit 83604cceb1
28 changed files with 1247 additions and 32 deletions

View File

@ -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)
{

View File

@ -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,

View File

@ -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";
}

View File

@ -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>

View File

@ -0,0 +1,8 @@
using Stripe;
namespace Bit.Billing.Services;
public interface IProviderEventService
{
Task TryRecordInvoiceLineItems(Event parsedEvent);
}

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

View File

@ -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 =>

View File

@ -81,6 +81,7 @@ public class Startup
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();
}
public void Configure(

View File

@ -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()
{

View File

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

View File

@ -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>

View File

@ -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)

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

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