diff --git a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs new file mode 100644 index 000000000..5256d11a6 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs @@ -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) + }; +} diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index e32cb4081..f06f67690 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -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.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -16,6 +18,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using CsvHelper; using Microsoft.Extensions.Logging; using Stripe; using static Bit.Core.Billing.Utilities; @@ -23,16 +26,17 @@ using static Bit.Core.Billing.Utilities; namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( + IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, IPaymentService paymentService, + IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - IFeatureService featureService) : IProviderBillingService + ISubscriberService subscriberService) : IProviderBillingService { public async Task AssignSeatsToClientOrganization( Provider provider, @@ -197,6 +201,38 @@ public class ProviderBillingService( await organizationRepository.ReplaceAsync(organization); } + public async Task 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 GetAssignedSeatTotalForPlanOrThrow( Guid providerId, PlanType planType) diff --git a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj index dfc63666d..0b9723293 100644 --- a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj +++ b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj @@ -4,4 +4,8 @@ + + + + diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index c432be51a..479f6f4dd 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -1,5 +1,7 @@ -using System.Net; +using System.Globalization; +using System.Net; using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -20,6 +22,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using CsvHelper; using NSubstitute; using Stripe; using Xunit; @@ -635,6 +638,68 @@ public class ProviderBillingServiceTests #endregion + #region GenerateClientInvoiceReport + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GenerateClientInvoiceReport(null)); + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull( + string invoiceId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns([]); + + var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); + + Assert.Null(reportContent); + } + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_Succeeds( + string invoiceId, + SutProvider sutProvider) + { + var invoiceItems = new List + { + new () + { + ClientName = "Client 1", + AssignedSeats = 50, + UsedSeats = 30, + PlanName = "Teams (Monthly)", + Total = 500 + } + }; + + sutProvider.GetDependency().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().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 [Theory, BitAutoData] diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 06e169048..246bf7360 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -42,6 +42,28 @@ public class ProviderBillingController( return TypedResults.Ok(response); } + [HttpGet("invoices/{invoiceId}")] + public async Task 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 GetPaymentInformationAsync([FromRoute] Guid providerId) { diff --git a/src/Api/Billing/Models/Responses/InvoicesResponse.cs b/src/Api/Billing/Models/Responses/InvoicesResponse.cs index 55f52768d..f5266947d 100644 --- a/src/Api/Billing/Models/Responses/InvoicesResponse.cs +++ b/src/Api/Billing/Models/Responses/InvoicesResponse.cs @@ -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, diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index 707a5dd5d..cbcc2065c 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -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"; } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index bde61e809..52923f06a 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -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); } /// diff --git a/src/Billing/Services/IProviderEventService.cs b/src/Billing/Services/IProviderEventService.cs new file mode 100644 index 000000000..a0d25673b --- /dev/null +++ b/src/Billing/Services/IProviderEventService.cs @@ -0,0 +1,8 @@ +using Stripe; + +namespace Bit.Billing.Services; + +public interface IProviderEventService +{ + Task TryRecordInvoiceLineItems(Event parsedEvent); +} diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs new file mode 100644 index 000000000..e24701c6f --- /dev/null +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -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 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(); + + 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; + } + } + } +} diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index ce7ab311f..8d947e0cc 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -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 => diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 31291700e..1bc2789a4 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -81,6 +81,7 @@ public class Startup services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public void Configure( diff --git a/src/Core/Billing/Entities/ProviderInvoiceItem.cs b/src/Core/Billing/Entities/ProviderInvoiceItem.cs index 1229c7aa6..568010123 100644 --- a/src/Core/Billing/Entities/ProviderInvoiceItem.cs +++ b/src/Core/Billing/Entities/ProviderInvoiceItem.cs @@ -14,7 +14,7 @@ public class ProviderInvoiceItem : ITableObject 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() { diff --git a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs b/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs index 5277cd56b..a722d4cf9 100644 --- a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs +++ b/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs @@ -5,6 +5,6 @@ namespace Bit.Core.Billing.Repositories; public interface IProviderInvoiceItemRepository : IRepository { - Task GetByInvoiceId(string invoiceId); + Task> GetByInvoiceId(string invoiceId); Task> GetByProviderId(Guid providerId); } diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 76c08241b..bc1aa0042 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -43,6 +43,9 @@ public interface IProviderBillingService Provider provider, Organization organization); + Task GenerateClientInvoiceReport( + string invoiceId); + /// /// Retrieves the number of seats an MSP has assigned to its client organizations with a specified . /// diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs index dc26fde7e..69a4be1ef 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -14,7 +14,7 @@ public class ProviderInvoiceItemRepository( globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository { - public async Task GetByInvoiceId(string invoiceId) + public async Task> 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> GetByProviderId(Guid providerId) diff --git a/src/Infrastructure.EntityFramework/Billing/Configurations/ProviderInvoiceItemEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Billing/Configurations/ProviderInvoiceItemEntityTypeConfiguration.cs index f417d895f..654dd0f67 100644 --- a/src/Infrastructure.EntityFramework/Billing/Configurations/ProviderInvoiceItemEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/Billing/Configurations/ProviderInvoiceItemEntityTypeConfiguration.cs @@ -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)); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs index 0214aee3c..87e960e12 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -16,7 +16,7 @@ public class ProviderInvoiceItemRepository( mapper, context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository { - public async Task GetByInvoiceId(string invoiceId) + public async Task> 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> GetByProviderId(Guid providerId) diff --git a/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Create.sql b/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Create.sql index 08b150aef..2bf88364f 100644 --- a/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Create.sql +++ b/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Create.sql @@ -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 diff --git a/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Update.sql b/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Update.sql index 24444dd09..944317e71 100644 --- a/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Update.sql +++ b/src/Sql/Billing/Stored Procedures/ProviderInvoiceItem_Update.sql @@ -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 diff --git a/src/Sql/Billing/Tables/ProviderInvoiceItem.sql b/src/Sql/Billing/Tables/ProviderInvoiceItem.sql index bc4e95612..793aae3cc 100644 --- a/src/Sql/Billing/Tables/ProviderInvoiceItem.sql +++ b/src/Sql/Billing/Tables/ProviderInvoiceItem.sql @@ -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 ); diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 90f993878..20e0fa51c 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -26,7 +26,7 @@ namespace Bit.Api.Test.Billing.Controllers; [SutProviderCustomize] public class ProviderBillingControllerTests { - #region GetInvoices + #region GetInvoicesAsync [Theory, BitAutoData] public async Task GetInvoices_Ok( @@ -39,6 +39,7 @@ public class ProviderBillingControllerTests { new () { + Id = "3", Created = new DateTime(2024, 7, 1), Status = "draft", Total = 100000, @@ -47,8 +48,9 @@ public class ProviderBillingControllerTests }, new () { + Id = "2", Created = new DateTime(2024, 6, 1), - Number = "2", + Number = "B", Status = "open", Total = 100000, HostedInvoiceUrl = "https://example.com/invoice/2", @@ -56,8 +58,9 @@ public class ProviderBillingControllerTests }, new () { + Id = "1", Created = new DateTime(2024, 5, 1), - Number = "1", + Number = "A", Status = "paid", Total = 100000, HostedInvoiceUrl = "https://example.com/invoice/1", @@ -78,16 +81,19 @@ public class ProviderBillingControllerTests var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open"); Assert.NotNull(openInvoice); + Assert.Equal("2", openInvoice.Id); 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("https://example.com/invoice/2", openInvoice.Url); Assert.Equal("https://example.com/invoice/2/pdf", openInvoice.PdfUrl); var paidInvoice = response.Invoices.FirstOrDefault(i => i.Status == "paid"); + Assert.NotNull(paidInvoice); + Assert.Equal("1", paidInvoice.Id); 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("https://example.com/invoice/1", paidInvoice.Url); Assert.Equal("https://example.com/invoice/1/pdf", paidInvoice.PdfUrl); @@ -95,6 +101,33 @@ public class ProviderBillingControllerTests #endregion + #region GenerateClientInvoiceReportAsync + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReportAsync_Ok( + Provider provider, + string invoiceId, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var reportContent = "Report"u8.ToArray(); + + sutProvider.GetDependency().GenerateClientInvoiceReport(invoiceId) + .Returns(reportContent); + + var result = await sutProvider.Sut.GenerateClientInvoiceReportAsync(provider.Id, invoiceId); + + Assert.IsType(result); + + var response = (FileContentHttpResult)result; + + Assert.Equal("text/csv", response.ContentType); + Assert.Equal(reportContent, response.FileContents); + } + + #endregion + #region GetPaymentInformationAsync [Theory, BitAutoData] diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index 0bd8368f4..a30425e8f 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -73,6 +73,10 @@ PreserveNewest + + + PreserveNewest + diff --git a/test/Billing.Test/Resources/Events/invoice.finalized.json b/test/Billing.Test/Resources/Events/invoice.finalized.json new file mode 100644 index 000000000..e71cb2b4c --- /dev/null +++ b/test/Billing.Test/Resources/Events/invoice.finalized.json @@ -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" +} diff --git a/test/Billing.Test/Services/ProviderEventServiceTests.cs b/test/Billing.Test/Services/ProviderEventServiceTests.cs new file mode 100644 index 000000000..f2499a404 --- /dev/null +++ b/test/Billing.Test/Services/ProviderEventServiceTests.cs @@ -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(); + + private readonly IProviderOrganizationRepository _providerOrganizationRepository = + Substitute.For(); + + private readonly IProviderPlanRepository _providerPlanRepository = + Substitute.For(); + + private readonly IStripeEventService _stripeEventService = + Substitute.For(); + + private readonly IStripeFacade _stripeFacade = + Substitute.For(); + + private readonly ProviderEventService _providerEventService; + + public ProviderEventServiceTests() + { + _providerEventService = new ProviderEventService( + Substitute.For>(), + _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()); + } + + [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 { { "organizationId", Guid.NewGuid().ToString() } } + }; + + _stripeFacade.GetSubscription(subscriptionId).Returns(subscription); + + // Act + await _providerEventService.TryRecordInvoiceLineItems(stripeEvent); + + // Assert + await _providerOrganizationRepository.DidNotReceiveWithAnyArgs().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [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 { { "providerId", providerId.ToString() } } + }; + + _stripeFacade.GetSubscription(subscriptionId).Returns(subscription); + + var providerPlans = new List + { + 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() + .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 { { "providerId", providerId.ToString() } } + }; + + _stripeFacade.GetSubscription(subscriptionId).Returns(subscription); + + var clients = new List + { + 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 + { + 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( + 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( + 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( + 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( + 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 { { "providerId", providerId.ToString() } } + }; + + _stripeFacade.GetSubscription(subscriptionId).Returns(subscription); + + var invoiceItems = new List + { + 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( + options => options.InvoiceNumber == "A")); + } + #endregion +} diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index 1e4d6c264..15aa5c723 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -13,7 +13,7 @@ namespace Bit.Billing.Test.Services; public class StripeEventServiceTests { private readonly IStripeFacade _stripeFacade; - private readonly IStripeEventService _stripeEventService; + private readonly StripeEventService _stripeEventService; public StripeEventServiceTests() { diff --git a/test/Billing.Test/Utilities/StripeTestEvents.cs b/test/Billing.Test/Utilities/StripeTestEvents.cs index eb1095bc2..86792af81 100644 --- a/test/Billing.Test/Utilities/StripeTestEvents.cs +++ b/test/Billing.Test/Utilities/StripeTestEvents.cs @@ -8,6 +8,7 @@ public enum StripeEventType CustomerSubscriptionUpdated, CustomerUpdated, InvoiceCreated, + InvoiceFinalized, InvoiceUpcoming, PaymentMethodAttached } @@ -22,6 +23,7 @@ public static class StripeTestEvents StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json", StripeEventType.CustomerUpdated => "customer.updated.json", StripeEventType.InvoiceCreated => "invoice.created.json", + StripeEventType.InvoiceFinalized => "invoice.finalized.json", StripeEventType.InvoiceUpcoming => "invoice.upcoming.json", StripeEventType.PaymentMethodAttached => "payment_method.attached.json" }; diff --git a/util/Migrator/DbScripts/2024-06-11_00_FixProviderInvoiceItem.sql b/util/Migrator/DbScripts/2024-06-11_00_FixProviderInvoiceItem.sql new file mode 100644 index 000000000..38ef4994a --- /dev/null +++ b/util/Migrator/DbScripts/2024-06-11_00_FixProviderInvoiceItem.sql @@ -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