1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1247 additions and 32 deletions

View File

@ -0,0 +1,25 @@
using System.Globalization;
using Bit.Core.Billing.Entities;
namespace Bit.Commercial.Core.Billing.Models;
public class ProviderClientInvoiceReportRow
{
public string Client { get; set; }
public int Assigned { get; set; }
public int Used { get; set; }
public int Remaining { get; set; }
public string Plan { get; set; }
public string Total { get; set; }
public static ProviderClientInvoiceReportRow From(ProviderInvoiceItem providerInvoiceItem)
=> new()
{
Client = providerInvoiceItem.ClientName,
Assigned = providerInvoiceItem.AssignedSeats,
Used = providerInvoiceItem.UsedSeats,
Remaining = providerInvoiceItem.AssignedSeats - providerInvoiceItem.UsedSeats,
Plan = providerInvoiceItem.PlanName,
Total = string.Format(new CultureInfo("en-US", false), "{0:C}", providerInvoiceItem.Total)
};
}

View File

@ -1,4 +1,6 @@
using Bit.Core; using System.Globalization;
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;
@ -16,6 +18,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using CsvHelper;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using static Bit.Core.Billing.Utilities; using static Bit.Core.Billing.Utilities;
@ -23,16 +26,17 @@ using static Bit.Core.Billing.Utilities;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPaymentService paymentService,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService) : IProviderBillingService
IFeatureService featureService) : IProviderBillingService
{ {
public async Task AssignSeatsToClientOrganization( public async Task AssignSeatsToClientOrganization(
Provider provider, Provider provider,
@ -197,6 +201,38 @@ public class ProviderBillingService(
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
} }
public async Task<byte[]> GenerateClientInvoiceReport(
string invoiceId)
{
if (string.IsNullOrEmpty(invoiceId))
{
throw new ArgumentNullException(nameof(invoiceId));
}
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoiceId);
if (invoiceItems.Count == 0)
{
return null;
}
var csvRows = invoiceItems.Select(ProviderClientInvoiceReportRow.From);
using var memoryStream = new MemoryStream();
await using var streamWriter = new StreamWriter(memoryStream);
await using var csvWriter = new CsvWriter(streamWriter, CultureInfo.CurrentCulture);
await csvWriter.WriteRecordsAsync(csvRows);
await streamWriter.FlushAsync();
memoryStream.Seek(0, SeekOrigin.Begin);
return memoryStream.ToArray();
}
public async Task<int> GetAssignedSeatTotalForPlanOrThrow( public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId, Guid providerId,
PlanType planType) PlanType planType)

View File

@ -4,4 +4,8 @@
<ProjectReference Include="..\..\..\src\Core\Core.csproj" /> <ProjectReference Include="..\..\..\src\Core\Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
</ItemGroup>
</Project> </Project>

View File

@ -1,5 +1,7 @@
using System.Net; using System.Globalization;
using System.Net;
using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing;
using Bit.Commercial.Core.Billing.Models;
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;
@ -20,6 +22,7 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using CsvHelper;
using NSubstitute; using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
@ -635,6 +638,68 @@ public class ProviderBillingServiceTests
#endregion #endregion
#region GenerateClientInvoiceReport
[Theory, BitAutoData]
public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException(
SutProvider<ProviderBillingService> sutProvider) =>
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.GenerateClientInvoiceReport(null));
[Theory, BitAutoData]
public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull(
string invoiceId,
SutProvider<ProviderBillingService> sutProvider)
{
sutProvider.GetDependency<IProviderInvoiceItemRepository>().GetByInvoiceId(invoiceId).Returns([]);
var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId);
Assert.Null(reportContent);
}
[Theory, BitAutoData]
public async Task GenerateClientInvoiceReport_Succeeds(
string invoiceId,
SutProvider<ProviderBillingService> sutProvider)
{
var invoiceItems = new List<ProviderInvoiceItem>
{
new ()
{
ClientName = "Client 1",
AssignedSeats = 50,
UsedSeats = 30,
PlanName = "Teams (Monthly)",
Total = 500
}
};
sutProvider.GetDependency<IProviderInvoiceItemRepository>().GetByInvoiceId(invoiceId).Returns(invoiceItems);
var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId);
using var memoryStream = new MemoryStream(reportContent);
using var streamReader = new StreamReader(memoryStream);
using var csvReader = new CsvReader(streamReader, CultureInfo.InvariantCulture);
var records = csvReader.GetRecords<ProviderClientInvoiceReportRow>().ToList();
Assert.Single(records);
var record = records.First();
Assert.Equal("Client 1", record.Client);
Assert.Equal(50, record.Assigned);
Assert.Equal(30, record.Used);
Assert.Equal(20, record.Remaining);
Assert.Equal("Teams (Monthly)", record.Plan);
Assert.Equal("$500.00", record.Total);
}
#endregion
#region GetAssignedSeatTotalForPlanOrThrow #region GetAssignedSeatTotalForPlanOrThrow
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@ -42,6 +42,28 @@ public class ProviderBillingController(
return TypedResults.Ok(response); 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")] [HttpGet("payment-information")]
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId) public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
{ {

View File

@ -13,6 +13,7 @@ public record InvoicesResponse(
} }
public record InvoiceDTO( public record InvoiceDTO(
string Id,
DateTime Date, DateTime Date,
string Number, string Number,
decimal Total, decimal Total,
@ -21,6 +22,7 @@ public record InvoiceDTO(
string PdfUrl) string PdfUrl)
{ {
public static InvoiceDTO From(Invoice invoice) => new( public static InvoiceDTO From(Invoice invoice) => new(
invoice.Id,
invoice.Created, invoice.Created,
invoice.Number, invoice.Number,
invoice.Total / 100M, invoice.Total / 100M,

View File

@ -12,4 +12,5 @@ public static class HandledStripeWebhook
public const string InvoiceCreated = "invoice.created"; public const string InvoiceCreated = "invoice.created";
public const string PaymentMethodAttached = "payment_method.attached"; public const string PaymentMethodAttached = "payment_method.attached";
public const string CustomerUpdated = "customer.updated"; 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 IStripeFacade _stripeFacade;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IProviderEventService _providerEventService;
public StripeController( public StripeController(
GlobalSettings globalSettings, GlobalSettings globalSettings,
@ -77,7 +78,8 @@ public class StripeController : Controller
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeFacade stripeFacade, IStripeFacade stripeFacade,
IFeatureService featureService, IFeatureService featureService,
IProviderRepository providerRepository) IProviderRepository providerRepository,
IProviderEventService providerEventService)
{ {
_billingSettings = billingSettings?.Value; _billingSettings = billingSettings?.Value;
_hostingEnvironment = hostingEnvironment; _hostingEnvironment = hostingEnvironment;
@ -106,6 +108,7 @@ public class StripeController : Controller
_stripeFacade = stripeFacade; _stripeFacade = stripeFacade;
_featureService = featureService; _featureService = featureService;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerEventService = providerEventService;
} }
[HttpPost("webhook")] [HttpPost("webhook")]
@ -203,6 +206,11 @@ public class StripeController : Controller
await HandleCustomerUpdatedEventAsync(parsedEvent); await HandleCustomerUpdatedEventAsync(parsedEvent);
return Ok(); return Ok();
} }
case HandledStripeWebhook.InvoiceFinalized:
{
await HandleInvoiceFinalizedEventAsync(parsedEvent);
return Ok();
}
default: default:
{ {
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
@ -397,12 +405,18 @@ public class StripeController : Controller
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent) private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
{ {
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); 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> /// <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 => HandledStripeWebhook.UpcomingInvoice =>
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), 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, (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached => HandledStripeWebhook.PaymentMethodAttached =>

View File

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

View File

@ -14,7 +14,7 @@ public class ProviderInvoiceItem : ITableObject<Guid>
public int AssignedSeats { get; set; } public int AssignedSeats { get; set; }
public int UsedSeats { get; set; } public int UsedSeats { get; set; }
public decimal Total { get; set; } public decimal Total { get; set; }
public DateTime Created { get; set; } public DateTime Created { get; set; } = DateTime.UtcNow;
public void SetNewId() public void SetNewId()
{ {

View File

@ -5,6 +5,6 @@ namespace Bit.Core.Billing.Repositories;
public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid> public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid>
{ {
Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId); Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId);
Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId); Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId);
} }

View File

@ -43,6 +43,9 @@ public interface IProviderBillingService
Provider provider, Provider provider,
Organization organization); Organization organization);
Task<byte[]> GenerateClientInvoiceReport(
string invoiceId);
/// <summary> /// <summary>
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>. /// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
/// </summary> /// </summary>

View File

@ -14,7 +14,7 @@ public class ProviderInvoiceItemRepository(
globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ConnectionString,
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository
{ {
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId) public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
{ {
var sqlConnection = new SqlConnection(ConnectionString); var sqlConnection = new SqlConnection(ConnectionString);
@ -23,7 +23,7 @@ public class ProviderInvoiceItemRepository(
new { InvoiceId = invoiceId }, new { InvoiceId = invoiceId },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return results.FirstOrDefault(); return results.ToArray();
} }
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId) public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)

View File

@ -12,10 +12,6 @@ public class ProviderInvoiceItemEntityTypeConfiguration : IEntityTypeConfigurati
.Property(t => t.Id) .Property(t => t.Id)
.ValueGeneratedNever(); .ValueGeneratedNever();
builder
.HasIndex(providerInvoiceItem => new { providerInvoiceItem.Id, providerInvoiceItem.InvoiceId })
.IsUnique();
builder.ToTable(nameof(ProviderInvoiceItem)); builder.ToTable(nameof(ProviderInvoiceItem));
} }
} }

View File

@ -16,7 +16,7 @@ public class ProviderInvoiceItemRepository(
mapper, mapper,
context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository
{ {
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId) public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
{ {
using var serviceScope = ServiceScopeFactory.CreateScope(); using var serviceScope = ServiceScopeFactory.CreateScope();
@ -27,7 +27,7 @@ public class ProviderInvoiceItemRepository(
where providerInvoiceItem.InvoiceId == invoiceId where providerInvoiceItem.InvoiceId == invoiceId
select providerInvoiceItem; select providerInvoiceItem;
return await query.FirstOrDefaultAsync(); return await query.ToArrayAsync();
} }
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId) public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)

View File

@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Create]
@PlanName NVARCHAR (50), @PlanName NVARCHAR (50),
@AssignedSeats INT, @AssignedSeats INT,
@UsedSeats INT, @UsedSeats INT,
@Total MONEY @Total MONEY,
@Created DATETIME2 (7) = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
SET @Created = COALESCE(@Created, GETUTCDATE())
INSERT INTO [dbo].[ProviderInvoiceItem] INSERT INTO [dbo].[ProviderInvoiceItem]
( (
[Id], [Id],
@ -36,6 +39,6 @@ BEGIN
@AssignedSeats, @AssignedSeats,
@UsedSeats, @UsedSeats,
@Total, @Total,
GETUTCDATE() @Created
) )
END END

View File

@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Update]
@PlanName NVARCHAR (50), @PlanName NVARCHAR (50),
@AssignedSeats INT, @AssignedSeats INT,
@UsedSeats INT, @UsedSeats INT,
@Total MONEY @Total MONEY,
@Created DATETIME2 (7) = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
SET @Created = COALESCE(@Created, GETUTCDATE())
UPDATE UPDATE
[dbo].[ProviderInvoiceItem] [dbo].[ProviderInvoiceItem]
SET SET
@ -22,7 +25,8 @@ BEGIN
[PlanName] = @PlanName, [PlanName] = @PlanName,
[AssignedSeats] = @AssignedSeats, [AssignedSeats] = @AssignedSeats,
[UsedSeats] = @UsedSeats, [UsedSeats] = @UsedSeats,
[Total] = @Total [Total] = @Total,
[Created] = @Created
WHERE WHERE
[Id] = @Id [Id] = @Id
END END

View File

@ -2,7 +2,7 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
[Id] UNIQUEIDENTIFIER NOT NULL, [Id] UNIQUEIDENTIFIER NOT NULL,
[ProviderId] UNIQUEIDENTIFIER NOT NULL, [ProviderId] UNIQUEIDENTIFIER NOT NULL,
[InvoiceId] VARCHAR (50) NOT NULL, [InvoiceId] VARCHAR (50) NOT NULL,
[InvoiceNumber] VARCHAR (50) NOT NULL, [InvoiceNumber] VARCHAR (50) NULL,
[ClientName] NVARCHAR (50) NOT NULL, [ClientName] NVARCHAR (50) NOT NULL,
[PlanName] NVARCHAR (50) NOT NULL, [PlanName] NVARCHAR (50) NOT NULL,
[AssignedSeats] INT NOT NULL, [AssignedSeats] INT NOT NULL,
@ -10,6 +10,5 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
[Total] MONEY NOT NULL, [Total] MONEY NOT NULL,
[Created] DATETIME2 (7) NOT NULL, [Created] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]), CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE
CONSTRAINT [PK_ProviderIdInvoiceId] UNIQUE ([ProviderId], [InvoiceId])
); );

View File

@ -26,7 +26,7 @@ namespace Bit.Api.Test.Billing.Controllers;
[SutProviderCustomize] [SutProviderCustomize]
public class ProviderBillingControllerTests public class ProviderBillingControllerTests
{ {
#region GetInvoices #region GetInvoicesAsync
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task GetInvoices_Ok( public async Task GetInvoices_Ok(
@ -39,6 +39,7 @@ public class ProviderBillingControllerTests
{ {
new () new ()
{ {
Id = "3",
Created = new DateTime(2024, 7, 1), Created = new DateTime(2024, 7, 1),
Status = "draft", Status = "draft",
Total = 100000, Total = 100000,
@ -47,8 +48,9 @@ public class ProviderBillingControllerTests
}, },
new () new ()
{ {
Id = "2",
Created = new DateTime(2024, 6, 1), Created = new DateTime(2024, 6, 1),
Number = "2", Number = "B",
Status = "open", Status = "open",
Total = 100000, Total = 100000,
HostedInvoiceUrl = "https://example.com/invoice/2", HostedInvoiceUrl = "https://example.com/invoice/2",
@ -56,8 +58,9 @@ public class ProviderBillingControllerTests
}, },
new () new ()
{ {
Id = "1",
Created = new DateTime(2024, 5, 1), Created = new DateTime(2024, 5, 1),
Number = "1", Number = "A",
Status = "paid", Status = "paid",
Total = 100000, Total = 100000,
HostedInvoiceUrl = "https://example.com/invoice/1", HostedInvoiceUrl = "https://example.com/invoice/1",
@ -78,16 +81,19 @@ public class ProviderBillingControllerTests
var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open"); var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open");
Assert.NotNull(openInvoice); Assert.NotNull(openInvoice);
Assert.Equal("2", openInvoice.Id);
Assert.Equal(new DateTime(2024, 6, 1), openInvoice.Date); Assert.Equal(new DateTime(2024, 6, 1), openInvoice.Date);
Assert.Equal("2", openInvoice.Number); Assert.Equal("B", openInvoice.Number);
Assert.Equal(1000, openInvoice.Total); Assert.Equal(1000, openInvoice.Total);
Assert.Equal("https://example.com/invoice/2", openInvoice.Url); Assert.Equal("https://example.com/invoice/2", openInvoice.Url);
Assert.Equal("https://example.com/invoice/2/pdf", openInvoice.PdfUrl); Assert.Equal("https://example.com/invoice/2/pdf", openInvoice.PdfUrl);
var paidInvoice = response.Invoices.FirstOrDefault(i => i.Status == "paid"); var paidInvoice = response.Invoices.FirstOrDefault(i => i.Status == "paid");
Assert.NotNull(paidInvoice); Assert.NotNull(paidInvoice);
Assert.Equal("1", paidInvoice.Id);
Assert.Equal(new DateTime(2024, 5, 1), paidInvoice.Date); Assert.Equal(new DateTime(2024, 5, 1), paidInvoice.Date);
Assert.Equal("1", paidInvoice.Number); Assert.Equal("A", paidInvoice.Number);
Assert.Equal(1000, paidInvoice.Total); Assert.Equal(1000, paidInvoice.Total);
Assert.Equal("https://example.com/invoice/1", paidInvoice.Url); Assert.Equal("https://example.com/invoice/1", paidInvoice.Url);
Assert.Equal("https://example.com/invoice/1/pdf", paidInvoice.PdfUrl); Assert.Equal("https://example.com/invoice/1/pdf", paidInvoice.PdfUrl);
@ -95,6 +101,33 @@ public class ProviderBillingControllerTests
#endregion #endregion
#region GenerateClientInvoiceReportAsync
[Theory, BitAutoData]
public async Task GenerateClientInvoiceReportAsync_Ok(
Provider provider,
string invoiceId,
SutProvider<ProviderBillingController> sutProvider)
{
ConfigureStableInputs(provider, sutProvider);
var reportContent = "Report"u8.ToArray();
sutProvider.GetDependency<IProviderBillingService>().GenerateClientInvoiceReport(invoiceId)
.Returns(reportContent);
var result = await sutProvider.Sut.GenerateClientInvoiceReportAsync(provider.Id, invoiceId);
Assert.IsType<FileContentHttpResult>(result);
var response = (FileContentHttpResult)result;
Assert.Equal("text/csv", response.ContentType);
Assert.Equal(reportContent, response.FileContents);
}
#endregion
#region GetPaymentInformationAsync #region GetPaymentInformationAsync
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@ -73,6 +73,10 @@
<EmbeddedResource Include="Resources\IPN\unsupported-transaction-type.txt"> <EmbeddedResource Include="Resources\IPN\unsupported-transaction-type.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource> </EmbeddedResource>
<None Remove="Resources\Events\invoice.finalized.json" />
<EmbeddedResource Include="Resources\Events\invoice.finalized.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,400 @@
{
"id": "evt_1PQaABIGBnsLynRrhoJjGnyz",
"object": "event",
"account": "acct_19smIXIGBnsLynRr",
"api_version": "2023-10-16",
"created": 1718133319,
"data": {
"object": {
"id": "in_1PQa9fIGBnsLynRraYIqTdBs",
"object": "invoice",
"account_country": "US",
"account_name": "Bitwarden Inc.",
"account_tax_ids": null,
"amount_due": 84240,
"amount_paid": 0,
"amount_remaining": 84240,
"amount_shipping": 0,
"application": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"enabled": true,
"liability": {
"type": "self"
},
"status": "complete"
},
"billing_reason": "subscription_update",
"charge": null,
"collection_method": "send_invoice",
"created": 1718133291,
"currency": "usd",
"custom_fields": [
{
"name": "Provider",
"value": "MSP"
}
],
"customer": "cus_QH8QVKyTh2lfcG",
"customer_address": {
"city": null,
"country": "US",
"line1": null,
"line2": null,
"postal_code": "12345",
"state": null
},
"customer_email": "billing@msp.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": {
"id": "di_1PQa9eIGBnsLynRrwwYr2bGD",
"object": "discount",
"checkout_session": null,
"coupon": {
"id": "msp-discount-35",
"object": "coupon",
"amount_off": null,
"created": 1678805729,
"currency": null,
"duration": "forever",
"duration_in_months": null,
"livemode": false,
"max_redemptions": null,
"metadata": {
},
"name": "MSP Discount - 35%",
"percent_off": 35,
"redeem_by": null,
"times_redeemed": 515,
"valid": true,
"percent_off_precise": 35
},
"customer": "cus_QH8QVKyTh2lfcG",
"end": null,
"invoice": null,
"invoice_item": null,
"promotion_code": null,
"start": 1718133290,
"subscription": null,
"subscription_item": null
},
"discounts": [
"di_1PQa9eIGBnsLynRrwwYr2bGD"
],
"due_date": 1720725291,
"effective_at": 1718136893,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw?s=ap",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"object": "list",
"data": [
{
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"object": "line_item",
"amount": 50000,
"amount_excluding_tax": 50000,
"currency": "usd",
"description": null,
"discount_amounts": [
{
"amount": 17500,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"discountable": true,
"discounts": [
],
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
"livemode": false,
"metadata": {
},
"period": {
"end": 1720725291,
"start": 1718133291
},
"plan": {
"id": "2023-teams-org-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 500,
"amount_decimal": "500",
"billing_scheme": "per_unit",
"created": 1695839010,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"meter": null,
"nickname": "Teams Organization Seat (Monthly)",
"product": "prod_HgOooYXDr2DDAA",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed",
"name": "Password Manager - Teams Plan",
"statement_description": null,
"statement_descriptor": null,
"tiers": null
},
"price": {
"id": "2023-teams-org-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1695839010,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Teams Organization Seat (Monthly)",
"product": "prod_HgOooYXDr2DDAA",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "exclusive",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 500,
"unit_amount_decimal": "500"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 100,
"subscription": null,
"subscription_item": "si_QH8Qo4WEJxOVwx",
"tax_amounts": [
{
"amount": 2600,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 32500
}
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "500",
"unique_id": "il_1PQa9fIGBnsLynRrSJ3cxrdU",
"unique_line_item_id": "sli_1acb3eIGBnsLynRr4b9c2f48"
},
{
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"object": "line_item",
"amount": 70000,
"amount_excluding_tax": 70000,
"currency": "usd",
"description": null,
"discount_amounts": [
{
"amount": 24500,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"discountable": true,
"discounts": [
],
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
"livemode": false,
"metadata": {
},
"period": {
"end": 1720725291,
"start": 1718133291
},
"plan": {
"id": "2023-enterprise-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 700,
"amount_decimal": "700",
"billing_scheme": "per_unit",
"created": 1695152194,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"meter": null,
"nickname": "Enterprise Organization (Monthly)",
"product": "prod_HgSOgzUlYDFOzf",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed",
"name": "Password Manager - Enterprise Plan",
"statement_description": null,
"statement_descriptor": null,
"tiers": null
},
"price": {
"id": "2023-enterprise-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1695152194,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Enterprise Organization (Monthly)",
"product": "prod_HgSOgzUlYDFOzf",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "exclusive",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 700,
"unit_amount_decimal": "700"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 100,
"subscription": null,
"subscription_item": "si_QH8QUjtceXvcis",
"tax_amounts": [
{
"amount": 3640,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 45500
}
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "700",
"unique_id": "il_1PQa9fIGBnsLynRrVviet37m",
"unique_line_item_id": "sli_11b229IGBnsLynRr837b79d0"
}
],
"has_more": false,
"total_count": 2,
"url": "/v1/invoices/in_1PQa9fIGBnsLynRraYIqTdBs/lines"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": null,
"number": "525EB050-0001",
"on_behalf_of": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_3PQaA7IGBnsLynRr1swr9XJE",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1718133291,
"period_start": 1718133291,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": null,
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": null,
"status": "open",
"status_transitions": {
"finalized_at": 1718136893,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"subscription_details": {
"metadata": {
"providerId": "655bc5a3-2332-4201-a9a6-b18c013d0572"
}
},
"subtotal": 120000,
"subtotal_excluding_tax": 120000,
"tax": 6240,
"test_clock": "clock_1PQaA4IGBnsLynRrptkZjgxc",
"total": 84240,
"total_discount_amounts": [
{
"amount": 42000,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"total_excluding_tax": 78000,
"total_tax_amounts": [
{
"amount": 6240,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 78000
}
],
"transfer_data": null,
"webhooks_delivered_at": 1718133293,
"application_fee": null,
"billing": "send_invoice",
"closed": false,
"date": 1718133291,
"finalized_at": 1718136893,
"forgiven": false,
"payment": null,
"statement_description": null,
"tax_percent": 8
}
},
"livemode": false,
"pending_webhooks": 5,
"request": null,
"type": "invoice.finalized",
"user_id": "acct_19smIXIGBnsLynRr"
}

View File

@ -0,0 +1,318 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Billing.Test.Utilities;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Billing.Test.Services;
public class ProviderEventServiceTests
{
private readonly IProviderInvoiceItemRepository _providerInvoiceItemRepository =
Substitute.For<IProviderInvoiceItemRepository>();
private readonly IProviderOrganizationRepository _providerOrganizationRepository =
Substitute.For<IProviderOrganizationRepository>();
private readonly IProviderPlanRepository _providerPlanRepository =
Substitute.For<IProviderPlanRepository>();
private readonly IStripeEventService _stripeEventService =
Substitute.For<IStripeEventService>();
private readonly IStripeFacade _stripeFacade =
Substitute.For<IStripeFacade>();
private readonly ProviderEventService _providerEventService;
public ProviderEventServiceTests()
{
_providerEventService = new ProviderEventService(
Substitute.For<ILogger<ProviderEventService>>(),
_providerInvoiceItemRepository,
_providerOrganizationRepository,
_providerPlanRepository,
_stripeEventService,
_stripeFacade);
}
#region TryRecordInvoiceLineItems
[Fact]
public async Task TryRecordInvoiceLineItems_EventTypeNotInvoiceCreatedOrInvoiceFinalized_NoOp()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any<Event>());
}
[Fact]
public async Task TryRecordInvoiceLineItems_EventNotProviderRelated_NoOp()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
const string subscriptionId = "sub_1";
var invoice = new Invoice
{
SubscriptionId = subscriptionId
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
var subscription = new Subscription
{
Metadata = new Dictionary<string, string> { { "organizationId", Guid.NewGuid().ToString() } }
};
_stripeFacade.GetSubscription(subscriptionId).Returns(subscription);
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
}
[Fact]
public async Task TryRecordInvoiceLineItems_InvoiceCreated_MisconfiguredProviderPlans_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
const string subscriptionId = "sub_1";
var providerId = Guid.NewGuid();
var invoice = new Invoice
{
SubscriptionId = subscriptionId
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
var subscription = new Subscription
{
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeFacade.GetSubscription(subscriptionId).Returns(subscription);
var providerPlans = new List<ProviderPlan>
{
new ()
{
Id = Guid.NewGuid(),
ProviderId = providerId,
PlanType = PlanType.TeamsMonthly,
AllocatedSeats = 0,
PurchasedSeats = 0,
SeatMinimum = 100
}
};
_providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans);
// Act
var function = async () => await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
}
[Fact]
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
const string subscriptionId = "sub_1";
var providerId = Guid.NewGuid();
var invoice = new Invoice
{
Id = "invoice_1",
Number = "A",
SubscriptionId = subscriptionId,
Discount = new Discount
{
Coupon = new Coupon
{
PercentOff = 35
}
}
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
var subscription = new Subscription
{
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeFacade.GetSubscription(subscriptionId).Returns(subscription);
var clients = new List<ProviderOrganizationOrganizationDetails>
{
new ()
{
OrganizationName = "Client 1",
Plan = "Teams (Monthly)",
Seats = 50,
UserCount = 30,
Status = OrganizationStatusType.Managed
},
new ()
{
OrganizationName = "Client 2",
Plan = "Enterprise (Monthly)",
Seats = 50,
UserCount = 30,
Status = OrganizationStatusType.Managed
}
};
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId).Returns(clients);
var providerPlans = new List<ProviderPlan>
{
new ()
{
Id = Guid.NewGuid(),
ProviderId = providerId,
PlanType = PlanType.TeamsMonthly,
AllocatedSeats = 50,
PurchasedSeats = 0,
SeatMinimum = 100
},
new ()
{
Id = Guid.NewGuid(),
ProviderId = providerId,
PlanType = PlanType.EnterpriseMonthly,
AllocatedSeats = 50,
PurchasedSeats = 0,
SeatMinimum = 100
}
};
_providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans);
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
await _providerInvoiceItemRepository.Received(1).CreateAsync(Arg.Is<ProviderInvoiceItem>(
options =>
options.ProviderId == providerId &&
options.InvoiceId == invoice.Id &&
options.InvoiceNumber == invoice.Number &&
options.ClientName == "Client 1" &&
options.PlanName == "Teams (Monthly)" &&
options.AssignedSeats == 50 &&
options.UsedSeats == 30 &&
options.Total == options.AssignedSeats * teamsPlan.PasswordManager.SeatPrice * 0.65M));
await _providerInvoiceItemRepository.Received(1).CreateAsync(Arg.Is<ProviderInvoiceItem>(
options =>
options.ProviderId == providerId &&
options.InvoiceId == invoice.Id &&
options.InvoiceNumber == invoice.Number &&
options.ClientName == "Client 2" &&
options.PlanName == "Enterprise (Monthly)" &&
options.AssignedSeats == 50 &&
options.UsedSeats == 30 &&
options.Total == options.AssignedSeats * enterprisePlan.PasswordManager.SeatPrice * 0.65M));
await _providerInvoiceItemRepository.Received(1).CreateAsync(Arg.Is<ProviderInvoiceItem>(
options =>
options.ProviderId == providerId &&
options.InvoiceId == invoice.Id &&
options.InvoiceNumber == invoice.Number &&
options.ClientName == "Unassigned seats" &&
options.PlanName == "Teams (Monthly)" &&
options.AssignedSeats == 50 &&
options.UsedSeats == 0 &&
options.Total == options.AssignedSeats * teamsPlan.PasswordManager.SeatPrice * 0.65M));
await _providerInvoiceItemRepository.Received(1).CreateAsync(Arg.Is<ProviderInvoiceItem>(
options =>
options.ProviderId == providerId &&
options.InvoiceId == invoice.Id &&
options.InvoiceNumber == invoice.Number &&
options.ClientName == "Unassigned seats" &&
options.PlanName == "Enterprise (Monthly)" &&
options.AssignedSeats == 50 &&
options.UsedSeats == 0 &&
options.Total == options.AssignedSeats * enterprisePlan.PasswordManager.SeatPrice * 0.65M));
}
[Fact]
public async Task TryRecordInvoiceLineItems_InvoiceFinalized_Succeeds()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceFinalized);
const string subscriptionId = "sub_1";
var providerId = Guid.NewGuid();
var invoice = new Invoice
{
Id = "invoice_1",
Number = "A",
SubscriptionId = subscriptionId
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
var subscription = new Subscription
{
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeFacade.GetSubscription(subscriptionId).Returns(subscription);
var invoiceItems = new List<ProviderInvoiceItem>
{
new ()
{
Id = Guid.NewGuid(),
ClientName = "Client 1"
},
new ()
{
Id = Guid.NewGuid(),
ClientName = "Client 2"
}
};
_providerInvoiceItemRepository.GetByInvoiceId(invoice.Id).Returns(invoiceItems);
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await _providerInvoiceItemRepository.Received(2).ReplaceAsync(Arg.Is<ProviderInvoiceItem>(
options => options.InvoiceNumber == "A"));
}
#endregion
}

View File

@ -13,7 +13,7 @@ namespace Bit.Billing.Test.Services;
public class StripeEventServiceTests public class StripeEventServiceTests
{ {
private readonly IStripeFacade _stripeFacade; private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventService _stripeEventService; private readonly StripeEventService _stripeEventService;
public StripeEventServiceTests() public StripeEventServiceTests()
{ {

View File

@ -8,6 +8,7 @@ public enum StripeEventType
CustomerSubscriptionUpdated, CustomerSubscriptionUpdated,
CustomerUpdated, CustomerUpdated,
InvoiceCreated, InvoiceCreated,
InvoiceFinalized,
InvoiceUpcoming, InvoiceUpcoming,
PaymentMethodAttached PaymentMethodAttached
} }
@ -22,6 +23,7 @@ public static class StripeTestEvents
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json", StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json",
StripeEventType.CustomerUpdated => "customer.updated.json", StripeEventType.CustomerUpdated => "customer.updated.json",
StripeEventType.InvoiceCreated => "invoice.created.json", StripeEventType.InvoiceCreated => "invoice.created.json",
StripeEventType.InvoiceFinalized => "invoice.finalized.json",
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json", StripeEventType.InvoiceUpcoming => "invoice.upcoming.json",
StripeEventType.PaymentMethodAttached => "payment_method.attached.json" StripeEventType.PaymentMethodAttached => "payment_method.attached.json"
}; };

View File

@ -0,0 +1,119 @@
-- This index was incorrect business logic and should be removed.
IF OBJECT_ID('[dbo].[PK_ProviderIdInvoiceId]', 'UQ') IS NOT NULL
BEGIN
ALTER TABLE [dbo].[ProviderInvoiceItem]
DROP CONSTRAINT [PK_ProviderIdInvoiceId]
END
GO
-- This foreign key needs a cascade to ensure providers can be deleted when ProviderInvoiceItems still exist.
IF OBJECT_ID('[dbo].[FK_ProviderInvoiceItem_Provider]', 'F') IS NOT NULL
BEGIN
ALTER TABLE [dbo].[ProviderInvoiceItem]
DROP CONSTRAINT [FK_ProviderInvoiceItem_Provider]
END
GO
ALTER TABLE [dbo].[ProviderInvoiceItem]
ADD CONSTRAINT [FK_ProviderInvoiceItem_Provider]
FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE
GO
-- Because we need to insert this when a "draft" invoice is created, the [InvoiceNumber] column needs to be nullable.
ALTER TABLE [dbo].[ProviderInvoiceItem]
ALTER COLUMN [InvoiceNumber] VARCHAR (50) NULL
GO
-- The "Create" stored procedure needs to take the @Created parameter.
IF OBJECT_ID('[dbo].[ProviderInvoiceItem_Create]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[ProviderInvoiceItem_Create]
END
GO
CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@ProviderId UNIQUEIDENTIFIER,
@InvoiceId VARCHAR (50),
@InvoiceNumber VARCHAR (50),
@ClientName NVARCHAR (50),
@PlanName NVARCHAR (50),
@AssignedSeats INT,
@UsedSeats INT,
@Total MONEY,
@Created DATETIME2 (7) = NULL
AS
BEGIN
SET NOCOUNT ON
SET @Created = COALESCE(@Created, GETUTCDATE())
INSERT INTO [dbo].[ProviderInvoiceItem]
(
[Id],
[ProviderId],
[InvoiceId],
[InvoiceNumber],
[ClientName],
[PlanName],
[AssignedSeats],
[UsedSeats],
[Total],
[Created]
)
VALUES
(
@Id,
@ProviderId,
@InvoiceId,
@InvoiceNumber,
@ClientName,
@PlanName,
@AssignedSeats,
@UsedSeats,
@Total,
@Created
)
END
GO
-- Because we pass whole entities to the SPROC, The "Update" stored procedure needs to take the @Created parameter too.
IF OBJECT_ID('[dbo].[ProviderInvoiceItem_Update]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[ProviderInvoiceItem_Update]
END
GO
CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Update]
@Id UNIQUEIDENTIFIER,
@ProviderId UNIQUEIDENTIFIER,
@InvoiceId VARCHAR (50),
@InvoiceNumber VARCHAR (50),
@ClientName NVARCHAR (50),
@PlanName NVARCHAR (50),
@AssignedSeats INT,
@UsedSeats INT,
@Total MONEY,
@Created DATETIME2 (7) = NULL
AS
BEGIN
SET NOCOUNT ON
SET @Created = COALESCE(@Created, GETUTCDATE())
UPDATE
[dbo].[ProviderInvoiceItem]
SET
[ProviderId] = @ProviderId,
[InvoiceId] = @InvoiceId,
[InvoiceNumber] = @InvoiceNumber,
[ClientName] = @ClientName,
[PlanName] = @PlanName,
[AssignedSeats] = @AssignedSeats,
[UsedSeats] = @UsedSeats,
[Total] = @Total,
[Created] = @Created
WHERE
[Id] = @Id
END
GO