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;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
@ -16,6 +18,7 @@ using Bit.Core.Repositories;
|
|||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using CsvHelper;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using static Bit.Core.Billing.Utilities;
|
using static Bit.Core.Billing.Utilities;
|
||||||
@ -23,16 +26,17 @@ using static Bit.Core.Billing.Utilities;
|
|||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService) : IProviderBillingService
|
||||||
IFeatureService featureService) : IProviderBillingService
|
|
||||||
{
|
{
|
||||||
public async Task AssignSeatsToClientOrganization(
|
public async Task AssignSeatsToClientOrganization(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
@ -197,6 +201,38 @@ public class ProviderBillingService(
|
|||||||
await organizationRepository.ReplaceAsync(organization);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> GenerateClientInvoiceReport(
|
||||||
|
string invoiceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(invoiceId))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoiceId);
|
||||||
|
|
||||||
|
if (invoiceItems.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var csvRows = invoiceItems.Select(ProviderClientInvoiceReportRow.From);
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
|
||||||
|
await using var streamWriter = new StreamWriter(memoryStream);
|
||||||
|
|
||||||
|
await using var csvWriter = new CsvWriter(streamWriter, CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
await csvWriter.WriteRecordsAsync(csvRows);
|
||||||
|
|
||||||
|
await streamWriter.FlushAsync();
|
||||||
|
|
||||||
|
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||||
Guid providerId,
|
Guid providerId,
|
||||||
PlanType planType)
|
PlanType planType)
|
||||||
|
@ -4,4 +4,8 @@
|
|||||||
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
|
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System.Net;
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
using Bit.Commercial.Core.Billing;
|
using Bit.Commercial.Core.Billing;
|
||||||
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
@ -20,6 +22,7 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using CsvHelper;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -635,6 +638,68 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region GenerateClientInvoiceReport
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider) =>
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.GenerateClientInvoiceReport(null));
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull(
|
||||||
|
string invoiceId,
|
||||||
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IProviderInvoiceItemRepository>().GetByInvoiceId(invoiceId).Returns([]);
|
||||||
|
|
||||||
|
var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId);
|
||||||
|
|
||||||
|
Assert.Null(reportContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GenerateClientInvoiceReport_Succeeds(
|
||||||
|
string invoiceId,
|
||||||
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
|
{
|
||||||
|
var invoiceItems = new List<ProviderInvoiceItem>
|
||||||
|
{
|
||||||
|
new ()
|
||||||
|
{
|
||||||
|
ClientName = "Client 1",
|
||||||
|
AssignedSeats = 50,
|
||||||
|
UsedSeats = 30,
|
||||||
|
PlanName = "Teams (Monthly)",
|
||||||
|
Total = 500
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderInvoiceItemRepository>().GetByInvoiceId(invoiceId).Returns(invoiceItems);
|
||||||
|
|
||||||
|
var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId);
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream(reportContent);
|
||||||
|
|
||||||
|
using var streamReader = new StreamReader(memoryStream);
|
||||||
|
|
||||||
|
using var csvReader = new CsvReader(streamReader, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var records = csvReader.GetRecords<ProviderClientInvoiceReportRow>().ToList();
|
||||||
|
|
||||||
|
Assert.Single(records);
|
||||||
|
|
||||||
|
var record = records.First();
|
||||||
|
|
||||||
|
Assert.Equal("Client 1", record.Client);
|
||||||
|
Assert.Equal(50, record.Assigned);
|
||||||
|
Assert.Equal(30, record.Used);
|
||||||
|
Assert.Equal(20, record.Remaining);
|
||||||
|
Assert.Equal("Teams (Monthly)", record.Plan);
|
||||||
|
Assert.Equal("$500.00", record.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region GetAssignedSeatTotalForPlanOrThrow
|
#region GetAssignedSeatTotalForPlanOrThrow
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
|
@ -42,6 +42,28 @@ public class ProviderBillingController(
|
|||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("invoices/{invoiceId}")]
|
||||||
|
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
|
||||||
|
{
|
||||||
|
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||||
|
|
||||||
|
if (provider == null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reportContent = await providerBillingService.GenerateClientInvoiceReport(invoiceId);
|
||||||
|
|
||||||
|
if (reportContent == null)
|
||||||
|
{
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypedResults.File(
|
||||||
|
reportContent,
|
||||||
|
"text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("payment-information")]
|
[HttpGet("payment-information")]
|
||||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||||
{
|
{
|
||||||
|
@ -13,6 +13,7 @@ public record InvoicesResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record InvoiceDTO(
|
public record InvoiceDTO(
|
||||||
|
string Id,
|
||||||
DateTime Date,
|
DateTime Date,
|
||||||
string Number,
|
string Number,
|
||||||
decimal Total,
|
decimal Total,
|
||||||
@ -21,6 +22,7 @@ public record InvoiceDTO(
|
|||||||
string PdfUrl)
|
string PdfUrl)
|
||||||
{
|
{
|
||||||
public static InvoiceDTO From(Invoice invoice) => new(
|
public static InvoiceDTO From(Invoice invoice) => new(
|
||||||
|
invoice.Id,
|
||||||
invoice.Created,
|
invoice.Created,
|
||||||
invoice.Number,
|
invoice.Number,
|
||||||
invoice.Total / 100M,
|
invoice.Total / 100M,
|
||||||
|
@ -12,4 +12,5 @@ public static class HandledStripeWebhook
|
|||||||
public const string InvoiceCreated = "invoice.created";
|
public const string InvoiceCreated = "invoice.created";
|
||||||
public const string PaymentMethodAttached = "payment_method.attached";
|
public const string PaymentMethodAttached = "payment_method.attached";
|
||||||
public const string CustomerUpdated = "customer.updated";
|
public const string CustomerUpdated = "customer.updated";
|
||||||
|
public const string InvoiceFinalized = "invoice.finalized";
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,7 @@ public class StripeController : Controller
|
|||||||
private readonly IStripeFacade _stripeFacade;
|
private readonly IStripeFacade _stripeFacade;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IProviderRepository _providerRepository;
|
private readonly IProviderRepository _providerRepository;
|
||||||
|
private readonly IProviderEventService _providerEventService;
|
||||||
|
|
||||||
public StripeController(
|
public StripeController(
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
@ -77,7 +78,8 @@ public class StripeController : Controller
|
|||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeFacade stripeFacade,
|
IStripeFacade stripeFacade,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IProviderRepository providerRepository)
|
IProviderRepository providerRepository,
|
||||||
|
IProviderEventService providerEventService)
|
||||||
{
|
{
|
||||||
_billingSettings = billingSettings?.Value;
|
_billingSettings = billingSettings?.Value;
|
||||||
_hostingEnvironment = hostingEnvironment;
|
_hostingEnvironment = hostingEnvironment;
|
||||||
@ -106,6 +108,7 @@ public class StripeController : Controller
|
|||||||
_stripeFacade = stripeFacade;
|
_stripeFacade = stripeFacade;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_providerRepository = providerRepository;
|
_providerRepository = providerRepository;
|
||||||
|
_providerEventService = providerEventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("webhook")]
|
[HttpPost("webhook")]
|
||||||
@ -203,6 +206,11 @@ public class StripeController : Controller
|
|||||||
await HandleCustomerUpdatedEventAsync(parsedEvent);
|
await HandleCustomerUpdatedEventAsync(parsedEvent);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
case HandledStripeWebhook.InvoiceFinalized:
|
||||||
|
{
|
||||||
|
await HandleInvoiceFinalizedEventAsync(parsedEvent);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
||||||
@ -397,12 +405,18 @@ public class StripeController : Controller
|
|||||||
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
|
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
|
||||||
{
|
{
|
||||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||||
if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice))
|
|
||||||
|
if (ShouldAttemptToPayInvoice(invoice))
|
||||||
{
|
{
|
||||||
return;
|
await AttemptToPayInvoiceAsync(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AttemptToPayInvoiceAsync(invoice);
|
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleInvoiceFinalizedEventAsync(Event parsedEvent)
|
||||||
|
{
|
||||||
|
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
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 =>
|
HandledStripeWebhook.UpcomingInvoice =>
|
||||||
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
|
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
|
||||||
|
|
||||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated =>
|
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
|
||||||
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
|
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
|
||||||
|
|
||||||
HandledStripeWebhook.PaymentMethodAttached =>
|
HandledStripeWebhook.PaymentMethodAttached =>
|
||||||
|
@ -81,6 +81,7 @@ public class Startup
|
|||||||
|
|
||||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||||
|
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Configure(
|
public void Configure(
|
||||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItem : ITableObject<Guid>
|
|||||||
public int AssignedSeats { get; set; }
|
public int AssignedSeats { get; set; }
|
||||||
public int UsedSeats { get; set; }
|
public int UsedSeats { get; set; }
|
||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
public DateTime Created { get; set; }
|
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public void SetNewId()
|
public void SetNewId()
|
||||||
{
|
{
|
||||||
|
@ -5,6 +5,6 @@ namespace Bit.Core.Billing.Repositories;
|
|||||||
|
|
||||||
public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid>
|
public interface IProviderInvoiceItemRepository : IRepository<ProviderInvoiceItem, Guid>
|
||||||
{
|
{
|
||||||
Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId);
|
Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId);
|
||||||
Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId);
|
Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId);
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,9 @@ public interface IProviderBillingService
|
|||||||
Provider provider,
|
Provider provider,
|
||||||
Organization organization);
|
Organization organization);
|
||||||
|
|
||||||
|
Task<byte[]> GenerateClientInvoiceReport(
|
||||||
|
string invoiceId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -14,7 +14,7 @@ public class ProviderInvoiceItemRepository(
|
|||||||
globalSettings.SqlServer.ConnectionString,
|
globalSettings.SqlServer.ConnectionString,
|
||||||
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository
|
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderInvoiceItemRepository
|
||||||
{
|
{
|
||||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||||
{
|
{
|
||||||
var sqlConnection = new SqlConnection(ConnectionString);
|
var sqlConnection = new SqlConnection(ConnectionString);
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ public class ProviderInvoiceItemRepository(
|
|||||||
new { InvoiceId = invoiceId },
|
new { InvoiceId = invoiceId },
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
return results.FirstOrDefault();
|
return results.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||||
|
@ -12,10 +12,6 @@ public class ProviderInvoiceItemEntityTypeConfiguration : IEntityTypeConfigurati
|
|||||||
.Property(t => t.Id)
|
.Property(t => t.Id)
|
||||||
.ValueGeneratedNever();
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
builder
|
|
||||||
.HasIndex(providerInvoiceItem => new { providerInvoiceItem.Id, providerInvoiceItem.InvoiceId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
builder.ToTable(nameof(ProviderInvoiceItem));
|
builder.ToTable(nameof(ProviderInvoiceItem));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ public class ProviderInvoiceItemRepository(
|
|||||||
mapper,
|
mapper,
|
||||||
context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository
|
context => context.ProviderInvoiceItems), IProviderInvoiceItemRepository
|
||||||
{
|
{
|
||||||
public async Task<ProviderInvoiceItem> GetByInvoiceId(string invoiceId)
|
public async Task<ICollection<ProviderInvoiceItem>> GetByInvoiceId(string invoiceId)
|
||||||
{
|
{
|
||||||
using var serviceScope = ServiceScopeFactory.CreateScope();
|
using var serviceScope = ServiceScopeFactory.CreateScope();
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ public class ProviderInvoiceItemRepository(
|
|||||||
where providerInvoiceItem.InvoiceId == invoiceId
|
where providerInvoiceItem.InvoiceId == invoiceId
|
||||||
select providerInvoiceItem;
|
select providerInvoiceItem;
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync();
|
return await query.ToArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
public async Task<ICollection<ProviderInvoiceItem>> GetByProviderId(Guid providerId)
|
||||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Create]
|
|||||||
@PlanName NVARCHAR (50),
|
@PlanName NVARCHAR (50),
|
||||||
@AssignedSeats INT,
|
@AssignedSeats INT,
|
||||||
@UsedSeats INT,
|
@UsedSeats INT,
|
||||||
@Total MONEY
|
@Total MONEY,
|
||||||
|
@Created DATETIME2 (7) = NULL
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||||
|
|
||||||
INSERT INTO [dbo].[ProviderInvoiceItem]
|
INSERT INTO [dbo].[ProviderInvoiceItem]
|
||||||
(
|
(
|
||||||
[Id],
|
[Id],
|
||||||
@ -36,6 +39,6 @@ BEGIN
|
|||||||
@AssignedSeats,
|
@AssignedSeats,
|
||||||
@UsedSeats,
|
@UsedSeats,
|
||||||
@Total,
|
@Total,
|
||||||
GETUTCDATE()
|
@Created
|
||||||
)
|
)
|
||||||
END
|
END
|
||||||
|
@ -7,11 +7,14 @@ CREATE PROCEDURE [dbo].[ProviderInvoiceItem_Update]
|
|||||||
@PlanName NVARCHAR (50),
|
@PlanName NVARCHAR (50),
|
||||||
@AssignedSeats INT,
|
@AssignedSeats INT,
|
||||||
@UsedSeats INT,
|
@UsedSeats INT,
|
||||||
@Total MONEY
|
@Total MONEY,
|
||||||
|
@Created DATETIME2 (7) = NULL
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
SET @Created = COALESCE(@Created, GETUTCDATE())
|
||||||
|
|
||||||
UPDATE
|
UPDATE
|
||||||
[dbo].[ProviderInvoiceItem]
|
[dbo].[ProviderInvoiceItem]
|
||||||
SET
|
SET
|
||||||
@ -22,7 +25,8 @@ BEGIN
|
|||||||
[PlanName] = @PlanName,
|
[PlanName] = @PlanName,
|
||||||
[AssignedSeats] = @AssignedSeats,
|
[AssignedSeats] = @AssignedSeats,
|
||||||
[UsedSeats] = @UsedSeats,
|
[UsedSeats] = @UsedSeats,
|
||||||
[Total] = @Total
|
[Total] = @Total,
|
||||||
|
[Created] = @Created
|
||||||
WHERE
|
WHERE
|
||||||
[Id] = @Id
|
[Id] = @Id
|
||||||
END
|
END
|
||||||
|
@ -2,7 +2,7 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
|||||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||||
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
|
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
|
||||||
[InvoiceId] VARCHAR (50) NOT NULL,
|
[InvoiceId] VARCHAR (50) NOT NULL,
|
||||||
[InvoiceNumber] VARCHAR (50) NOT NULL,
|
[InvoiceNumber] VARCHAR (50) NULL,
|
||||||
[ClientName] NVARCHAR (50) NOT NULL,
|
[ClientName] NVARCHAR (50) NOT NULL,
|
||||||
[PlanName] NVARCHAR (50) NOT NULL,
|
[PlanName] NVARCHAR (50) NOT NULL,
|
||||||
[AssignedSeats] INT NOT NULL,
|
[AssignedSeats] INT NOT NULL,
|
||||||
@ -10,6 +10,5 @@ CREATE TABLE [dbo].[ProviderInvoiceItem] (
|
|||||||
[Total] MONEY NOT NULL,
|
[Total] MONEY NOT NULL,
|
||||||
[Created] DATETIME2 (7) NOT NULL,
|
[Created] DATETIME2 (7) NOT NULL,
|
||||||
CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
CONSTRAINT [PK_ProviderInvoiceItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||||
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]),
|
CONSTRAINT [FK_ProviderInvoiceItem_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE
|
||||||
CONSTRAINT [PK_ProviderIdInvoiceId] UNIQUE ([ProviderId], [InvoiceId])
|
|
||||||
);
|
);
|
||||||
|
@ -26,7 +26,7 @@ namespace Bit.Api.Test.Billing.Controllers;
|
|||||||
[SutProviderCustomize]
|
[SutProviderCustomize]
|
||||||
public class ProviderBillingControllerTests
|
public class ProviderBillingControllerTests
|
||||||
{
|
{
|
||||||
#region GetInvoices
|
#region GetInvoicesAsync
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task GetInvoices_Ok(
|
public async Task GetInvoices_Ok(
|
||||||
@ -39,6 +39,7 @@ public class ProviderBillingControllerTests
|
|||||||
{
|
{
|
||||||
new ()
|
new ()
|
||||||
{
|
{
|
||||||
|
Id = "3",
|
||||||
Created = new DateTime(2024, 7, 1),
|
Created = new DateTime(2024, 7, 1),
|
||||||
Status = "draft",
|
Status = "draft",
|
||||||
Total = 100000,
|
Total = 100000,
|
||||||
@ -47,8 +48,9 @@ public class ProviderBillingControllerTests
|
|||||||
},
|
},
|
||||||
new ()
|
new ()
|
||||||
{
|
{
|
||||||
|
Id = "2",
|
||||||
Created = new DateTime(2024, 6, 1),
|
Created = new DateTime(2024, 6, 1),
|
||||||
Number = "2",
|
Number = "B",
|
||||||
Status = "open",
|
Status = "open",
|
||||||
Total = 100000,
|
Total = 100000,
|
||||||
HostedInvoiceUrl = "https://example.com/invoice/2",
|
HostedInvoiceUrl = "https://example.com/invoice/2",
|
||||||
@ -56,8 +58,9 @@ public class ProviderBillingControllerTests
|
|||||||
},
|
},
|
||||||
new ()
|
new ()
|
||||||
{
|
{
|
||||||
|
Id = "1",
|
||||||
Created = new DateTime(2024, 5, 1),
|
Created = new DateTime(2024, 5, 1),
|
||||||
Number = "1",
|
Number = "A",
|
||||||
Status = "paid",
|
Status = "paid",
|
||||||
Total = 100000,
|
Total = 100000,
|
||||||
HostedInvoiceUrl = "https://example.com/invoice/1",
|
HostedInvoiceUrl = "https://example.com/invoice/1",
|
||||||
@ -78,16 +81,19 @@ public class ProviderBillingControllerTests
|
|||||||
var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open");
|
var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open");
|
||||||
|
|
||||||
Assert.NotNull(openInvoice);
|
Assert.NotNull(openInvoice);
|
||||||
|
Assert.Equal("2", openInvoice.Id);
|
||||||
Assert.Equal(new DateTime(2024, 6, 1), openInvoice.Date);
|
Assert.Equal(new DateTime(2024, 6, 1), openInvoice.Date);
|
||||||
Assert.Equal("2", openInvoice.Number);
|
Assert.Equal("B", openInvoice.Number);
|
||||||
Assert.Equal(1000, openInvoice.Total);
|
Assert.Equal(1000, openInvoice.Total);
|
||||||
Assert.Equal("https://example.com/invoice/2", openInvoice.Url);
|
Assert.Equal("https://example.com/invoice/2", openInvoice.Url);
|
||||||
Assert.Equal("https://example.com/invoice/2/pdf", openInvoice.PdfUrl);
|
Assert.Equal("https://example.com/invoice/2/pdf", openInvoice.PdfUrl);
|
||||||
|
|
||||||
var paidInvoice = response.Invoices.FirstOrDefault(i => i.Status == "paid");
|
var paidInvoice = response.Invoices.FirstOrDefault(i => i.Status == "paid");
|
||||||
|
|
||||||
Assert.NotNull(paidInvoice);
|
Assert.NotNull(paidInvoice);
|
||||||
|
Assert.Equal("1", paidInvoice.Id);
|
||||||
Assert.Equal(new DateTime(2024, 5, 1), paidInvoice.Date);
|
Assert.Equal(new DateTime(2024, 5, 1), paidInvoice.Date);
|
||||||
Assert.Equal("1", paidInvoice.Number);
|
Assert.Equal("A", paidInvoice.Number);
|
||||||
Assert.Equal(1000, paidInvoice.Total);
|
Assert.Equal(1000, paidInvoice.Total);
|
||||||
Assert.Equal("https://example.com/invoice/1", paidInvoice.Url);
|
Assert.Equal("https://example.com/invoice/1", paidInvoice.Url);
|
||||||
Assert.Equal("https://example.com/invoice/1/pdf", paidInvoice.PdfUrl);
|
Assert.Equal("https://example.com/invoice/1/pdf", paidInvoice.PdfUrl);
|
||||||
@ -95,6 +101,33 @@ public class ProviderBillingControllerTests
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region GenerateClientInvoiceReportAsync
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task GenerateClientInvoiceReportAsync_Ok(
|
||||||
|
Provider provider,
|
||||||
|
string invoiceId,
|
||||||
|
SutProvider<ProviderBillingController> sutProvider)
|
||||||
|
{
|
||||||
|
ConfigureStableInputs(provider, sutProvider);
|
||||||
|
|
||||||
|
var reportContent = "Report"u8.ToArray();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderBillingService>().GenerateClientInvoiceReport(invoiceId)
|
||||||
|
.Returns(reportContent);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.GenerateClientInvoiceReportAsync(provider.Id, invoiceId);
|
||||||
|
|
||||||
|
Assert.IsType<FileContentHttpResult>(result);
|
||||||
|
|
||||||
|
var response = (FileContentHttpResult)result;
|
||||||
|
|
||||||
|
Assert.Equal("text/csv", response.ContentType);
|
||||||
|
Assert.Equal(reportContent, response.FileContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region GetPaymentInformationAsync
|
#region GetPaymentInformationAsync
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
|
@ -73,6 +73,10 @@
|
|||||||
<EmbeddedResource Include="Resources\IPN\unsupported-transaction-type.txt">
|
<EmbeddedResource Include="Resources\IPN\unsupported-transaction-type.txt">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
<None Remove="Resources\Events\invoice.finalized.json" />
|
||||||
|
<EmbeddedResource Include="Resources\Events\invoice.finalized.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
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
|
public class StripeEventServiceTests
|
||||||
{
|
{
|
||||||
private readonly IStripeFacade _stripeFacade;
|
private readonly IStripeFacade _stripeFacade;
|
||||||
private readonly IStripeEventService _stripeEventService;
|
private readonly StripeEventService _stripeEventService;
|
||||||
|
|
||||||
public StripeEventServiceTests()
|
public StripeEventServiceTests()
|
||||||
{
|
{
|
||||||
|
@ -8,6 +8,7 @@ public enum StripeEventType
|
|||||||
CustomerSubscriptionUpdated,
|
CustomerSubscriptionUpdated,
|
||||||
CustomerUpdated,
|
CustomerUpdated,
|
||||||
InvoiceCreated,
|
InvoiceCreated,
|
||||||
|
InvoiceFinalized,
|
||||||
InvoiceUpcoming,
|
InvoiceUpcoming,
|
||||||
PaymentMethodAttached
|
PaymentMethodAttached
|
||||||
}
|
}
|
||||||
@ -22,6 +23,7 @@ public static class StripeTestEvents
|
|||||||
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json",
|
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json",
|
||||||
StripeEventType.CustomerUpdated => "customer.updated.json",
|
StripeEventType.CustomerUpdated => "customer.updated.json",
|
||||||
StripeEventType.InvoiceCreated => "invoice.created.json",
|
StripeEventType.InvoiceCreated => "invoice.created.json",
|
||||||
|
StripeEventType.InvoiceFinalized => "invoice.finalized.json",
|
||||||
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json",
|
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json",
|
||||||
StripeEventType.PaymentMethodAttached => "payment_method.attached.json"
|
StripeEventType.PaymentMethodAttached => "payment_method.attached.json"
|
||||||
};
|
};
|
||||||
|
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