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:
parent
b392cc962d
commit
83604cceb1
@ -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)
|
||||
};
|
||||
}
|
@ -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<ProviderBillingService> 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<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(
|
||||
Guid providerId,
|
||||
PlanType planType)
|
||||
|
@ -4,4 +4,8 @@
|
||||
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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<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
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
@ -42,6 +42,28 @@ public class ProviderBillingController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("invoices/{invoiceId}")]
|
||||
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var reportContent = await providerBillingService.GenerateClientInvoiceReport(invoiceId);
|
||||
|
||||
if (reportContent == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.File(
|
||||
reportContent,
|
||||
"text/csv");
|
||||
}
|
||||
|
||||
[HttpGet("payment-information")]
|
||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
|
@ -13,6 +13,7 @@ public record InvoicesResponse(
|
||||
}
|
||||
|
||||
public record InvoiceDTO(
|
||||
string Id,
|
||||
DateTime Date,
|
||||
string Number,
|
||||
decimal Total,
|
||||
@ -21,6 +22,7 @@ public record InvoiceDTO(
|
||||
string PdfUrl)
|
||||
{
|
||||
public static InvoiceDTO From(Invoice invoice) => new(
|
||||
invoice.Id,
|
||||
invoice.Created,
|
||||
invoice.Number,
|
||||
invoice.Total / 100M,
|
||||
|
@ -12,4 +12,5 @@ public static class HandledStripeWebhook
|
||||
public const string InvoiceCreated = "invoice.created";
|
||||
public const string PaymentMethodAttached = "payment_method.attached";
|
||||
public const string CustomerUpdated = "customer.updated";
|
||||
public const string InvoiceFinalized = "invoice.finalized";
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ public class StripeController : Controller
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public StripeController(
|
||||
GlobalSettings globalSettings,
|
||||
@ -77,7 +78,8 @@ public class StripeController : Controller
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository)
|
||||
IProviderRepository providerRepository,
|
||||
IProviderEventService providerEventService)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
@ -106,6 +108,7 @@ public class StripeController : Controller
|
||||
_stripeFacade = stripeFacade;
|
||||
_featureService = featureService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
@ -203,6 +206,11 @@ public class StripeController : Controller
|
||||
await HandleCustomerUpdatedEventAsync(parsedEvent);
|
||||
return Ok();
|
||||
}
|
||||
case HandledStripeWebhook.InvoiceFinalized:
|
||||
{
|
||||
await HandleInvoiceFinalizedEventAsync(parsedEvent);
|
||||
return Ok();
|
||||
}
|
||||
default:
|
||||
{
|
||||
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
||||
@ -397,12 +405,18 @@ public class StripeController : Controller
|
||||
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice))
|
||||
|
||||
if (ShouldAttemptToPayInvoice(invoice))
|
||||
{
|
||||
return;
|
||||
await AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
|
||||
await AttemptToPayInvoiceAsync(invoice);
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
|
||||
private async Task HandleInvoiceFinalizedEventAsync(Event parsedEvent)
|
||||
{
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
8
src/Billing/Services/IProviderEventService.cs
Normal file
8
src/Billing/Services/IProviderEventService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Services;
|
||||
|
||||
public interface IProviderEventService
|
||||
{
|
||||
Task TryRecordInvoiceLineItems(Event parsedEvent);
|
||||
}
|
156
src/Billing/Services/Implementations/ProviderEventService.cs
Normal file
156
src/Billing/Services/Implementations/ProviderEventService.cs
Normal file
@ -0,0 +1,156 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class ProviderEventService(
|
||||
ILogger<ProviderEventService> logger,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade) : IProviderEventService
|
||||
{
|
||||
public async Task TryRecordInvoiceLineItems(Event parsedEvent)
|
||||
{
|
||||
if (parsedEvent.Type is not HandledStripeWebhook.InvoiceCreated and not HandledStripeWebhook.InvoiceFinalized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
||||
|
||||
var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
var hasProviderId = metadata.TryGetValue("providerId", out var providerId);
|
||||
|
||||
if (!hasProviderId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedProviderId = Guid.Parse(providerId);
|
||||
|
||||
switch (parsedEvent.Type)
|
||||
{
|
||||
case HandledStripeWebhook.InvoiceCreated:
|
||||
{
|
||||
var clients =
|
||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
||||
|
||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
||||
}
|
||||
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.SeatPrice * discountedPercentage;
|
||||
|
||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.SeatPrice * discountedPercentage;
|
||||
|
||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.UserCount,
|
||||
Total = client.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
}).ToList();
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var enterpriseClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
||||
|
||||
if (unassignedEnterpriseSeats > 0)
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = enterprisePlan.Name,
|
||||
AssignedSeats = unassignedEnterpriseSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var teamsClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == teamsPlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
||||
|
||||
if (unassignedTeamsSeats > 0)
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = teamsPlan.Name,
|
||||
AssignedSeats = unassignedTeamsSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||
|
||||
break;
|
||||
}
|
||||
case HandledStripeWebhook.InvoiceFinalized:
|
||||
{
|
||||
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoice.Id);
|
||||
|
||||
if (invoiceItems.Count != 0)
|
||||
{
|
||||
await Task.WhenAll(invoiceItems.Select(invoiceItem =>
|
||||
{
|
||||
invoiceItem.InvoiceNumber = invoice.Number;
|
||||
return providerInvoiceItemRepository.ReplaceAsync(invoiceItem);
|
||||
}));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -167,7 +167,7 @@ public class StripeEventService : IStripeEventService
|
||||
HandledStripeWebhook.UpcomingInvoice =>
|
||||
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
|
||||
|
||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated =>
|
||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
|
||||
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
|
||||
|
||||
HandledStripeWebhook.PaymentMethodAttached =>
|
||||
|
@ -81,6 +81,7 @@ public class Startup
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItem : ITableObject<Guid>
|
||||
public int AssignedSeats { get; set; }
|
||||
public int UsedSeats { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
|
@ -5,6 +5,6 @@ namespace Bit.Core.Billing.Repositories;
|
||||
|
||||
public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid>
|
||||
{
|
||||
Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId);
|
||||
Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId);
|
||||
Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId);
|
||||
}
|
||||
|
@ -43,6 +43,9 @@ public interface IProviderBillingService
|
||||
Provider provider,
|
||||
Organization organization);
|
||||
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItemRepository(
|
||||
globalSettings.SqlServer.ConnectionString,
|
||||
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository
|
||||
{
|
||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||
{
|
||||
var sqlConnection = new SqlConnection(ConnectionString);
|
||||
|
||||
@ -23,7 +23,7 @@ public class ProviderInvoiceItemRepository(
|
||||
new { InvoiceId = invoiceId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.FirstOrDefault();
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||
|
@ -12,10 +12,6 @@ public class ProviderInvoiceItemEntityTypeConfiguration : IEntityTypeConfigurati
|
||||
.Property(t => t.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(providerInvoiceItem => new { providerInvoiceItem.Id, providerInvoiceItem.InvoiceId })
|
||||
.IsUnique();
|
||||
|
||||
builder.ToTable(nameof(ProviderInvoiceItem));
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ public class ProviderInvoiceItemRepository(
|
||||
mapper,
|
||||
context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository
|
||||
{
|
||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||
{
|
||||
using var serviceScope = ServiceScopeFactory.CreateScope();
|
||||
|
||||
@ -27,7 +27,7 @@ public class ProviderInvoiceItemRepository(
|
||||
where providerInvoiceItem.InvoiceId == invoiceId
|
||||
select providerInvoiceItem;
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
return await query.ToArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Create]
|
||||
@PlanName NVARCHAR (50),
|
||||
@AssignedSeats INT,
|
||||
@UsedSeats INT,
|
||||
@Total MONEY
|
||||
@Total MONEY,
|
||||
@Created DATETIME2 (7) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||
|
||||
INSERT INTO [dbo].[ProviderInvoiceItem]
|
||||
(
|
||||
[Id],
|
||||
@ -36,6 +39,6 @@ BEGIN
|
||||
@AssignedSeats,
|
||||
@UsedSeats,
|
||||
@Total,
|
||||
GETUTCDATE()
|
||||
@Created
|
||||
)
|
||||
END
|
||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Update]
|
||||
@PlanName NVARCHAR (50),
|
||||
@AssignedSeats INT,
|
||||
@UsedSeats INT,
|
||||
@Total MONEY
|
||||
@Total MONEY,
|
||||
@Created DATETIME2 (7) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||
|
||||
UPDATE
|
||||
[dbo].[ProviderInvoiceItem]
|
||||
SET
|
||||
@ -22,7 +25,8 @@ BEGIN
|
||||
[PlanName] = @PlanName,
|
||||
[AssignedSeats] = @AssignedSeats,
|
||||
[UsedSeats] = @UsedSeats,
|
||||
[Total] = @Total
|
||||
[Total] = @Total,
|
||||
[Created] = @Created
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
|
@ -2,7 +2,7 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[InvoiceId] VARCHAR (50) NOT NULL,
|
||||
[InvoiceNumber] VARCHAR (50) NOT NULL,
|
||||
[InvoiceNumber] VARCHAR (50) NULL,
|
||||
[ClientName] NVARCHAR (50) NOT NULL,
|
||||
[PlanName] NVARCHAR (50) NOT NULL,
|
||||
[AssignedSeats] INT NOT NULL,
|
||||
@ -10,6 +10,5 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
||||
[Total] MONEY NOT NULL,
|
||||
[Created] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]),
|
||||
CONSTRAINT [PK_ProviderIdInvoiceId] UNIQUE ([ProviderId], [InvoiceId])
|
||||
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
|
@ -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<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
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
@ -73,6 +73,10 @@
|
||||
<EmbeddedResource Include="Resources\IPN\unsupported-transaction-type.txt">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<None Remove="Resources\Events\invoice.finalized.json" />
|
||||
<EmbeddedResource Include="Resources\Events\invoice.finalized.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
400
test/Billing.Test/Resources/Events/invoice.finalized.json
Normal file
400
test/Billing.Test/Resources/Events/invoice.finalized.json
Normal 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"
|
||||
}
|
318
test/Billing.Test/Services/ProviderEventServiceTests.cs
Normal file
318
test/Billing.Test/Services/ProviderEventServiceTests.cs
Normal 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
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -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"
|
||||
};
|
||||
|
119
util/Migrator/DbScripts/2024-06-11_00_FixProviderInvoiceItem.sql
Normal file
119
util/Migrator/DbScripts/2024-06-11_00_FixProviderInvoiceItem.sql
Normal 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
|
Loading…
Reference in New Issue
Block a user