From a0a76540779e8981a8aa1b237341fa6a5a41561d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:33:28 -0400 Subject: [PATCH] [AC-1942] Add endpoint to get provider invoices (#4158) * Added endpoint to get provider invoices * Added missing properties of invoice * Run dotnet format' --- .../Controllers/ProviderBillingController.cs | 17 ++ .../Models/Responses/InvoicesResponse.cs | 30 +++ .../Billing/Services/ISubscriberService.cs | 13 + .../Implementations/SubscriberService.cs | 107 +++++--- .../ProviderBillingControllerTests.cs | 239 +++++++++++------- .../Services/SubscriberServiceTests.cs | 104 ++++++++ 6 files changed, 389 insertions(+), 121 deletions(-) create mode 100644 src/Api/Billing/Models/Responses/InvoicesResponse.cs diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 42df02c674..06e169048d 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -25,6 +25,23 @@ public class ProviderBillingController( IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : Controller { + [HttpGet("invoices")] + public async Task GetInvoicesAsync([FromRoute] Guid providerId) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var invoices = await subscriberService.GetInvoices(provider); + + var response = InvoicesResponse.From(invoices); + + return TypedResults.Ok(response); + } + [HttpGet("payment-information")] public async Task GetPaymentInformationAsync([FromRoute] Guid providerId) { diff --git a/src/Api/Billing/Models/Responses/InvoicesResponse.cs b/src/Api/Billing/Models/Responses/InvoicesResponse.cs new file mode 100644 index 0000000000..55f52768dc --- /dev/null +++ b/src/Api/Billing/Models/Responses/InvoicesResponse.cs @@ -0,0 +1,30 @@ +using Stripe; + +namespace Bit.Api.Billing.Models.Responses; + +public record InvoicesResponse( + List Invoices) +{ + public static InvoicesResponse From(IEnumerable invoices) => new( + invoices + .Where(i => i.Status is "open" or "paid" or "uncollectible") + .OrderByDescending(i => i.Created) + .Select(InvoiceDTO.From).ToList()); +} + +public record InvoiceDTO( + DateTime Date, + string Number, + decimal Total, + string Status, + string Url, + string PdfUrl) +{ + public static InvoiceDTO From(Invoice invoice) => new( + invoice.Created, + invoice.Number, + invoice.Total / 100M, + invoice.Status, + invoice.HostedInvoiceUrl, + invoice.InvoicePdf); +} diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index 761e5a00d2..115bd6f325 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.BitStripe; using Stripe; namespace Bit.Core.Billing.Services; @@ -46,6 +47,18 @@ public interface ISubscriberService ISubscriber subscriber, CustomerGetOptions customerGetOptions = null); + /// + /// Retrieves a list of Stripe objects using the 's property. + /// + /// The subscriber to retrieve the Stripe invoices for. + /// Optional parameters that can be passed to Stripe to expand, modify or filter the invoices. The 's + /// will be automatically attached to the provided options as the parameter. + /// A list of Stripe objects. + /// This method opts for returning an empty list rather than throwing exceptions, making it ideal for surfacing data from API endpoints. + Task> GetInvoices( + ISubscriber subscriber, + StripeInvoiceListOptions invoiceListOptions = null); + /// /// Retrieves the account credit, a masked representation of the default payment method and the tax information for the /// provided . This is essentially a consolidated invocation of the diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 34ae4e406f..92f245c3bf 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.BitStripe; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -137,6 +138,76 @@ public class SubscriberService( } } + public async Task GetCustomerOrThrow( + ISubscriber subscriber, + CustomerGetOptions customerGetOptions = null) + { + ArgumentNullException.ThrowIfNull(subscriber); + + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId)); + + throw ContactSupport(); + } + + try + { + var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); + + if (customer != null) + { + return customer; + } + + logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})", + subscriber.GatewayCustomerId, subscriber.Id); + + throw ContactSupport(); + } + catch (StripeException exception) + { + logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}", + subscriber.GatewayCustomerId, subscriber.Id, exception.Message); + + throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception); + } + } + + public async Task> GetInvoices( + ISubscriber subscriber, + StripeInvoiceListOptions invoiceListOptions = null) + { + ArgumentNullException.ThrowIfNull(subscriber); + + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + logger.LogError("Cannot retrieve invoices for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId)); + + return []; + } + + try + { + if (invoiceListOptions == null) + { + invoiceListOptions = new StripeInvoiceListOptions { Customer = subscriber.GatewayCustomerId }; + } + else + { + invoiceListOptions.Customer = subscriber.GatewayCustomerId; + } + + return await stripeAdapter.InvoiceListAsync(invoiceListOptions); + } + catch (StripeException exception) + { + logger.LogError("An error occurred while trying to retrieve Stripe invoices for subscriber ({SubscriberID}): {Error}", subscriber.Id, exception.Message); + + return []; + } + } + public async Task GetPaymentInformation( ISubscriber subscriber) { @@ -177,42 +248,6 @@ public class SubscriberService( return await GetMaskedPaymentMethodDTOAsync(subscriber.Id, customer); } - public async Task GetCustomerOrThrow( - ISubscriber subscriber, - CustomerGetOptions customerGetOptions = null) - { - ArgumentNullException.ThrowIfNull(subscriber); - - if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) - { - logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId)); - - throw ContactSupport(); - } - - try - { - var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); - - if (customer != null) - { - return customer; - } - - logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})", - subscriber.GatewayCustomerId, subscriber.Id); - - throw ContactSupport(); - } - catch (StripeException exception) - { - logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}", - subscriber.GatewayCustomerId, subscriber.Id, exception.Message); - - throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception); - } - } - public async Task GetSubscription( ISubscriber subscriber, SubscriptionGetOptions subscriptionGetOptions = null) diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index cb31cdac7c..4c1bf51728 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -26,6 +26,160 @@ namespace Bit.Api.Test.Billing.Controllers; [SutProviderCustomize] public class ProviderBillingControllerTests { + #region GetInvoices + + [Theory, BitAutoData] + public async Task GetInvoices_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var invoices = new List + { + new () + { + Created = new DateTime(2024, 7, 1), + Status = "draft", + Total = 100000, + HostedInvoiceUrl = "https://example.com/invoice/3", + InvoicePdf = "https://example.com/invoice/3/pdf" + }, + new () + { + Created = new DateTime(2024, 6, 1), + Number = "2", + Status = "open", + Total = 100000, + HostedInvoiceUrl = "https://example.com/invoice/2", + InvoicePdf = "https://example.com/invoice/2/pdf" + }, + new () + { + Created = new DateTime(2024, 5, 1), + Number = "1", + Status = "paid", + Total = 100000, + HostedInvoiceUrl = "https://example.com/invoice/1", + InvoicePdf = "https://example.com/invoice/1/pdf" + } + }; + + sutProvider.GetDependency().GetInvoices(provider).Returns(invoices); + + var result = await sutProvider.Sut.GetInvoicesAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(2, response.Invoices.Count); + + var openInvoice = response.Invoices.FirstOrDefault(i => i.Status == "open"); + + Assert.NotNull(openInvoice); + Assert.Equal(new DateTime(2024, 6, 1), openInvoice.Date); + Assert.Equal("2", 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(new DateTime(2024, 5, 1), paidInvoice.Date); + Assert.Equal("1", 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); + } + + #endregion + + #region GetPaymentInformationAsync + + [Theory, BitAutoData] + public async Task GetPaymentInformation_PaymentInformationNull_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentInformation(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPaymentInformation_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var maskedPaymentMethod = new MaskedPaymentMethodDTO(PaymentMethodType.Card, "VISA *1234", false); + + var taxInformation = + new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); + + sutProvider.GetDependency().GetPaymentInformation(provider).Returns(new PaymentInformationDTO( + 100, + maskedPaymentMethod, + taxInformation)); + + var result = await sutProvider.Sut.GetPaymentInformationAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(100, response.AccountCredit); + Assert.Equal(maskedPaymentMethod.Description, response.PaymentMethod.Description); + Assert.Equal(taxInformation.TaxId, response.TaxInformation.TaxId); + } + + #endregion + + #region GetPaymentMethodAsync + + [Theory, BitAutoData] + public async Task GetPaymentMethod_PaymentMethodNull_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentMethod(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentMethod(provider).Returns(new MaskedPaymentMethodDTO( + PaymentMethodType.Card, "Description", false)); + + var result = await sutProvider.Sut.GetPaymentMethodAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(PaymentMethodType.Card, response.Type); + Assert.Equal("Description", response.Description); + Assert.False(response.NeedsVerification); + } + + #endregion + #region GetSubscriptionAsync [Theory, BitAutoData] public async Task GetSubscriptionAsync_FFDisabled_NotFound( @@ -165,91 +319,6 @@ public class ProviderBillingControllerTests } #endregion - #region GetPaymentInformationAsync - - [Theory, BitAutoData] - public async Task GetPaymentInformation_PaymentInformationNull_NotFound( - Provider provider, - SutProvider sutProvider) - { - ConfigureStableInputs(provider, sutProvider); - - sutProvider.GetDependency().GetPaymentInformation(provider).ReturnsNull(); - - var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); - - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task GetPaymentInformation_Ok( - Provider provider, - SutProvider sutProvider) - { - ConfigureStableInputs(provider, sutProvider); - - var maskedPaymentMethod = new MaskedPaymentMethodDTO(PaymentMethodType.Card, "VISA *1234", false); - - var taxInformation = - new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); - - sutProvider.GetDependency().GetPaymentInformation(provider).Returns(new PaymentInformationDTO( - 100, - maskedPaymentMethod, - taxInformation)); - - var result = await sutProvider.Sut.GetPaymentInformationAsync(provider.Id); - - Assert.IsType>(result); - - var response = ((Ok)result).Value; - - Assert.Equal(100, response.AccountCredit); - Assert.Equal(maskedPaymentMethod.Description, response.PaymentMethod.Description); - Assert.Equal(taxInformation.TaxId, response.TaxInformation.TaxId); - } - - #endregion - - #region GetPaymentMethodAsync - - [Theory, BitAutoData] - public async Task GetPaymentMethod_PaymentMethodNull_NotFound( - Provider provider, - SutProvider sutProvider) - { - ConfigureStableInputs(provider, sutProvider); - - sutProvider.GetDependency().GetPaymentMethod(provider).ReturnsNull(); - - var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); - - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task GetPaymentMethod_Ok( - Provider provider, - SutProvider sutProvider) - { - ConfigureStableInputs(provider, sutProvider); - - sutProvider.GetDependency().GetPaymentMethod(provider).Returns(new MaskedPaymentMethodDTO( - PaymentMethodType.Card, "Description", false)); - - var result = await sutProvider.Sut.GetPaymentMethodAsync(provider.Id); - - Assert.IsType>(result); - - var response = ((Ok)result).Value; - - Assert.Equal(PaymentMethodType.Card, response.Type); - Assert.Equal("Description", response.Description); - Assert.False(response.NeedsVerification); - } - - #endregion - #region GetTaxInformationAsync [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 79147feb7e..6c2fdcd9f0 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Enums; +using Bit.Core.Models.BitStripe; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; @@ -320,6 +321,109 @@ public class SubscriberServiceTests } #endregion + #region GetInvoices + + [Theory, BitAutoData] + public async Task GetInvoices_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) + => await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetInvoices(null)); + + [Theory, BitAutoData] + public async Task GetCustomer_NoGatewayCustomerId_ReturnsEmptyList( + Organization organization, + SutProvider sutProvider) + { + organization.GatewayCustomerId = null; + + var invoices = await sutProvider.Sut.GetInvoices(organization); + + Assert.Empty(invoices); + } + + [Theory, BitAutoData] + public async Task GetInvoices_StripeException_ReturnsEmptyList( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InvoiceListAsync(Arg.Any()) + .ThrowsAsync(); + + var invoices = await sutProvider.Sut.GetInvoices(organization); + + Assert.Empty(invoices); + } + + [Theory, BitAutoData] + public async Task GetInvoices_NullOptions_Succeeds( + Organization organization, + SutProvider sutProvider) + { + var invoices = new List + { + new () + { + Created = new DateTime(2024, 6, 1), + Number = "2", + Status = "open", + Total = 100000, + HostedInvoiceUrl = "https://example.com/invoice/2", + InvoicePdf = "https://example.com/invoice/2/pdf" + }, + new () + { + Created = new DateTime(2024, 5, 1), + Number = "1", + Status = "paid", + Total = 100000, + HostedInvoiceUrl = "https://example.com/invoice/1", + InvoicePdf = "https://example.com/invoice/1/pdf" + } + }; + + sutProvider.GetDependency() + .InvoiceListAsync(Arg.Is(options => options.Customer == organization.GatewayCustomerId)) + .Returns(invoices); + + var gotInvoices = await sutProvider.Sut.GetInvoices(organization); + + Assert.Equivalent(invoices, gotInvoices); + } + + [Theory, BitAutoData] + public async Task GetInvoices_ProvidedOptions_Succeeds( + Organization organization, + SutProvider sutProvider) + { + var invoices = new List + { + new () + { + Created = new DateTime(2024, 5, 1), + Number = "1", + Status = "paid", + Total = 100000, + } + }; + + sutProvider.GetDependency() + .InvoiceListAsync(Arg.Is( + options => + options.Customer == organization.GatewayCustomerId && + options.Status == "paid")) + .Returns(invoices); + + var gotInvoices = await sutProvider.Sut.GetInvoices(organization, new StripeInvoiceListOptions + { + Status = "paid" + }); + + Assert.Equivalent(invoices, gotInvoices); + } + + #endregion + #region GetPaymentMethod [Theory, BitAutoData] public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException(