From 62a0936c2efae533b60fffdc2054b645b5afcb41 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:31:59 -0500 Subject: [PATCH] [PM-25183] Update the BitPay purchasing procedure (#6396) * Revise BitPay controller * Run dotnet format * Kyle's feedback * Run dotnet format * Temporary logging * Whoops * Undo temporary logging --- .gitignore | 2 + src/Api/Controllers/MiscController.cs | 45 -- .../Request/BitPayInvoiceRequestModel.cs | 73 ---- src/Api/Startup.cs | 3 - src/Api/appsettings.json | 3 +- src/Billing/BillingSettings.cs | 1 - src/Billing/Constants/BitPayInvoiceStatus.cs | 7 - src/Billing/Controllers/BitPayController.cs | 223 ++++------ src/Billing/Startup.cs | 3 - src/Core/Billing/Constants/BitPayConstants.cs | 14 + .../CreateBitPayInvoiceForCreditCommand.cs | 13 +- src/Core/Settings/GlobalSettings.cs | 1 + src/Core/Utilities/BitPayClient.cs | 30 -- .../Controllers/BitPayControllerTests.cs | 391 ++++++++++++++++++ ...reateBitPayInvoiceForCreditCommandTests.cs | 21 +- 15 files changed, 508 insertions(+), 322 deletions(-) delete mode 100644 src/Api/Controllers/MiscController.cs delete mode 100644 src/Api/Models/Request/BitPayInvoiceRequestModel.cs delete mode 100644 src/Billing/Constants/BitPayInvoiceStatus.cs create mode 100644 src/Core/Billing/Constants/BitPayConstants.cs delete mode 100644 src/Core/Utilities/BitPayClient.cs create mode 100644 test/Billing.Test/Controllers/BitPayControllerTests.cs diff --git a/.gitignore b/.gitignore index 5a708ede30..60fc894285 100644 --- a/.gitignore +++ b/.gitignore @@ -234,4 +234,6 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json + +# Serena .serena/ diff --git a/src/Api/Controllers/MiscController.cs b/src/Api/Controllers/MiscController.cs deleted file mode 100644 index 6f23a27fbf..0000000000 --- a/src/Api/Controllers/MiscController.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Api.Models.Request; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Stripe; - -namespace Bit.Api.Controllers; - -public class MiscController : Controller -{ - private readonly BitPayClient _bitPayClient; - private readonly GlobalSettings _globalSettings; - - public MiscController( - BitPayClient bitPayClient, - GlobalSettings globalSettings) - { - _bitPayClient = bitPayClient; - _globalSettings = globalSettings; - } - - [Authorize("Application")] - [HttpPost("~/bitpay-invoice")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model) - { - var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings)); - return invoice.Url; - } - - [Authorize("Application")] - [HttpPost("~/setup-payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSetupPayment() - { - var options = new SetupIntentCreateOptions - { - Usage = "off_session" - }; - var service = new SetupIntentService(); - var setupIntent = await service.CreateAsync(options); - return setupIntent.ClientSecret; - } -} diff --git a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs deleted file mode 100644 index d27736d712..0000000000 --- a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs +++ /dev/null @@ -1,73 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Settings; - -namespace Bit.Api.Models.Request; - -public class BitPayInvoiceRequestModel : IValidatableObject -{ - public Guid? UserId { get; set; } - public Guid? OrganizationId { get; set; } - public Guid? ProviderId { get; set; } - public bool Credit { get; set; } - [Required] - public decimal? Amount { get; set; } - public string ReturnUrl { get; set; } - public string Name { get; set; } - public string Email { get; set; } - - public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings) - { - var inv = new BitPayLight.Models.Invoice.Invoice - { - Price = Convert.ToDouble(Amount.Value), - Currency = "USD", - RedirectUrl = ReturnUrl, - Buyer = new BitPayLight.Models.Invoice.Buyer - { - Email = Email, - Name = Name - }, - NotificationUrl = globalSettings.BitPay.NotificationUrl, - FullNotifications = true, - ExtendedNotifications = true - }; - - var posData = string.Empty; - if (UserId.HasValue) - { - posData = "userId:" + UserId.Value; - } - else if (OrganizationId.HasValue) - { - posData = "organizationId:" + OrganizationId.Value; - } - else if (ProviderId.HasValue) - { - posData = "providerId:" + ProviderId.Value; - } - - if (Credit) - { - posData += ",accountCredit:1"; - inv.ItemDesc = "Bitwarden Account Credit"; - } - else - { - inv.ItemDesc = "Bitwarden"; - } - - inv.PosData = posData; - return inv; - } - - public IEnumerable Validate(ValidationContext validationContext) - { - if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue) - { - yield return new ValidationResult("User, Organization or Provider is required."); - } - } -} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1519bb25c8..0967b4f662 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -94,9 +94,6 @@ public class Startup services.AddMemoryCache(); services.AddDistributedCache(globalSettings); - // BitPay - services.AddSingleton(); - if (!globalSettings.SelfHosted) { services.AddIpRateLimiting(globalSettings); diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index f8a69dcfac..98bb4df8ac 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -64,7 +64,8 @@ "bitPay": { "production": false, "token": "SECRET", - "notificationUrl": "https://bitwarden.com/SECRET" + "notificationUrl": "https://bitwarden.com/SECRET", + "webhookKey": "SECRET" }, "amazon": { "accessKeyId": "SECRET", diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index fc38f8fe60..64a52ed290 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -8,7 +8,6 @@ public class BillingSettings public virtual string JobsKey { get; set; } public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; } - public virtual string BitPayWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; } public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual string FreshsalesApiKey { get; set; } diff --git a/src/Billing/Constants/BitPayInvoiceStatus.cs b/src/Billing/Constants/BitPayInvoiceStatus.cs deleted file mode 100644 index b9c1e5834d..0000000000 --- a/src/Billing/Constants/BitPayInvoiceStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Billing.Constants; - -public static class BitPayInvoiceStatus -{ - public const string Confirmed = "confirmed"; - public const string Complete = "complete"; -} diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index 111ffabc2b..b24a8d8c36 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -1,125 +1,79 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Globalization; -using Bit.Billing.Constants; +using System.Globalization; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Utilities; +using BitPayLight.Models.Invoice; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Options; namespace Bit.Billing.Controllers; +using static BitPayConstants; +using static StripeConstants; + [Route("bitpay")] [ApiExplorerSettings(IgnoreApi = true)] -public class BitPayController : Controller +public class BitPayController( + GlobalSettings globalSettings, + IBitPayClient bitPayClient, + ITransactionRepository transactionRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IProviderRepository providerRepository, + IMailService mailService, + IPaymentService paymentService, + ILogger logger, + IPremiumUserBillingService premiumUserBillingService) + : Controller { - private readonly BillingSettings _billingSettings; - private readonly BitPayClient _bitPayClient; - private readonly ITransactionRepository _transactionRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; - private readonly IProviderRepository _providerRepository; - private readonly IMailService _mailService; - private readonly IPaymentService _paymentService; - private readonly ILogger _logger; - private readonly IPremiumUserBillingService _premiumUserBillingService; - - public BitPayController( - IOptions billingSettings, - BitPayClient bitPayClient, - ITransactionRepository transactionRepository, - IOrganizationRepository organizationRepository, - IUserRepository userRepository, - IProviderRepository providerRepository, - IMailService mailService, - IPaymentService paymentService, - ILogger logger, - IPremiumUserBillingService premiumUserBillingService) - { - _billingSettings = billingSettings?.Value; - _bitPayClient = bitPayClient; - _transactionRepository = transactionRepository; - _organizationRepository = organizationRepository; - _userRepository = userRepository; - _providerRepository = providerRepository; - _mailService = mailService; - _paymentService = paymentService; - _logger = logger; - _premiumUserBillingService = premiumUserBillingService; - } - [HttpPost("ipn")] public async Task PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key) { - if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey)) + if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey)) { - return new BadRequestResult(); - } - if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) || - string.IsNullOrWhiteSpace(model.Event?.Name)) - { - return new BadRequestResult(); + return new BadRequestObjectResult("Invalid key"); } - if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed) - { - // Only processing confirmed invoice events for now. - return new OkResult(); - } - - var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id); - if (invoice == null) - { - // Request forged...? - _logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id); - return new BadRequestResult(); - } - - if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete) - { - _logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id); - return new BadRequestResult(); - } + var invoice = await bitPayClient.GetInvoice(model.Data.Id); if (invoice.Currency != "USD") { - // Only process USD payments - _logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency); + return new BadRequestObjectResult("Cannot process non-USD payments"); } var (organizationId, userId, providerId) = GetIdsFromPosData(invoice); - if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) + if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit)) { - return new OkResult(); + logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData); + return new BadRequestObjectResult("Invalid POS data"); } - var isAccountCredit = IsAccountCredit(invoice); - if (!isAccountCredit) + if (invoice.Status != InvoiceStatuses.Complete) { - // Only processing credits - _logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}", + invoice.Id, invoice.Status); + return new OkObjectResult("Waiting for invoice to be completed"); } - var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); - if (transaction != null) + var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); + if (existingTransaction != null) { - _logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id); + return new OkObjectResult("Invoice already processed"); } try { - var tx = new Transaction + var transaction = new Transaction { Amount = Convert.ToDecimal(invoice.Price), CreationDate = GetTransactionDate(invoice), @@ -132,50 +86,47 @@ public class BitPayController : Controller PaymentMethodType = PaymentMethodType.BitPay, Details = $"{invoice.Currency}, BitPay {invoice.Id}" }; - await _transactionRepository.CreateAsync(tx); - string billingEmail = null; - if (tx.OrganizationId.HasValue) + await transactionRepository.CreateAsync(transaction); + + var billingEmail = ""; + if (transaction.OrganizationId.HasValue) { - var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); - if (org != null) + var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value); + if (organization != null) { - billingEmail = org.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(org, tx.Amount)) + billingEmail = organization.BillingEmailAddress(); + if (await paymentService.CreditAccountAsync(organization, transaction.Amount)) { - await _organizationRepository.ReplaceAsync(org); + await organizationRepository.ReplaceAsync(organization); } } } - else if (tx.UserId.HasValue) + else if (transaction.UserId.HasValue) { - var user = await _userRepository.GetByIdAsync(tx.UserId.Value); + var user = await userRepository.GetByIdAsync(transaction.UserId.Value); if (user != null) { billingEmail = user.BillingEmailAddress(); - await _premiumUserBillingService.Credit(user, tx.Amount); + await premiumUserBillingService.Credit(user, transaction.Amount); } } - else if (tx.ProviderId.HasValue) + else if (transaction.ProviderId.HasValue) { - var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value); + var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value); if (provider != null) { billingEmail = provider.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(provider, tx.Amount)) + if (await paymentService.CreditAccountAsync(provider, transaction.Amount)) { - await _providerRepository.ReplaceAsync(provider); + await providerRepository.ReplaceAsync(provider); } } } - else - { - _logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id); - } if (!string.IsNullOrWhiteSpace(billingEmail)) { - await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); + await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount); } } // Catch foreign key violations because user/org could have been deleted. @@ -186,58 +137,34 @@ public class BitPayController : Controller return new OkResult(); } - private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice) + private static DateTime GetTransactionDate(Invoice invoice) { - return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1"); + var transactions = invoice.Transactions?.Where(transaction => + transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) && + transaction.Confirmations != "0").ToList(); + + return transactions?.Count == 1 + ? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + : CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime); } - private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice) + public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice) { - var transactions = invoice.Transactions?.Where(t => t.Type == null && - !string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0"); - if (transactions != null && transactions.Count() == 1) + if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':')) { - return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind); - } - return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime); - } - - public Tuple GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice) - { - Guid? orgId = null; - Guid? userId = null; - Guid? providerId = null; - - if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':')) - { - return new Tuple(null, null, null); + return new ValueTuple(null, null, null); } - var mainParts = invoice.PosData.Split(','); - foreach (var mainPart in mainParts) - { - var parts = mainPart.Split(':'); + var ids = invoice.PosData + .Split(',') + .Select(part => part.Split(':')) + .Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _)) + .ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1])); - if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id)) - { - continue; - } - - switch (parts[0]) - { - case "userId": - userId = id; - break; - case "organizationId": - orgId = id; - break; - case "providerId": - providerId = id; - break; - } - } - - return new Tuple(orgId, userId, providerId); + return new ValueTuple( + ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null, + ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null, + ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null + ); } } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 5b464d5ef6..cdb9700ad5 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -51,9 +51,6 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - // BitPay Client - services.AddSingleton(); - // PayPal IPN Client services.AddHttpClient(); diff --git a/src/Core/Billing/Constants/BitPayConstants.cs b/src/Core/Billing/Constants/BitPayConstants.cs new file mode 100644 index 0000000000..a1b2ff6f5b --- /dev/null +++ b/src/Core/Billing/Constants/BitPayConstants.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Billing.Constants; + +public static class BitPayConstants +{ + public static class InvoiceStatuses + { + public const string Complete = "complete"; + } + + public static class PosDataKeys + { + public const string AccountCredit = "accountCredit:1"; + } +} diff --git a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs index a86f0e3ada..cc07f1b5db 100644 --- a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs +++ b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Clients; using Bit.Core.Entities; using Bit.Core.Settings; @@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Billing.Payment.Commands; +using static BitPayConstants; + public interface ICreateBitPayInvoiceForCreditCommand { Task> Run( @@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand( { var (name, email, posData) = GetSubscriberInformation(subscriber); + var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}"; + var invoice = new Invoice { Buyer = new Buyer { Email = email, Name = name }, @@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand( ExtendedNotifications = true, FullNotifications = true, ItemDesc = "Bitwarden", - NotificationUrl = globalSettings.BitPay.NotificationUrl, + NotificationUrl = notificationUrl, PosData = posData, Price = Convert.ToDouble(amount), RedirectUrl = redirectUrl @@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand( private static (string? Name, string? Email, string POSData) GetSubscriberInformation( ISubscriber subscriber) => subscriber switch { - User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), + User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"), Organization organization => (organization.Name, organization.BillingEmail, - $"organizationId:{organization.Id},accountCredit:1"), - Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), + $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"), + Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"), _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) }; } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index d79b7290ec..c467d1e652 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -677,6 +677,7 @@ public class GlobalSettings : IGlobalSettings public bool Production { get; set; } public string Token { get; set; } public string NotificationUrl { get; set; } + public string WebhookKey { get; set; } } public class InstallationSettings : IInstallationSettings diff --git a/src/Core/Utilities/BitPayClient.cs b/src/Core/Utilities/BitPayClient.cs deleted file mode 100644 index cf241d5723..0000000000 --- a/src/Core/Utilities/BitPayClient.cs +++ /dev/null @@ -1,30 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Settings; - -namespace Bit.Core.Utilities; - -public class BitPayClient -{ - private readonly BitPayLight.BitPay _bpClient; - - public BitPayClient(GlobalSettings globalSettings) - { - if (CoreHelpers.SettingHasValue(globalSettings.BitPay.Token)) - { - _bpClient = new BitPayLight.BitPay(globalSettings.BitPay.Token, - globalSettings.BitPay.Production ? BitPayLight.Env.Prod : BitPayLight.Env.Test); - } - } - - public Task GetInvoiceAsync(string id) - { - return _bpClient.GetInvoice(id); - } - - public Task CreateInvoiceAsync(BitPayLight.Models.Invoice.Invoice invoice) - { - return _bpClient.CreateInvoice(invoice); - } -} diff --git a/test/Billing.Test/Controllers/BitPayControllerTests.cs b/test/Billing.Test/Controllers/BitPayControllerTests.cs new file mode 100644 index 0000000000..d2d1c5b571 --- /dev/null +++ b/test/Billing.Test/Controllers/BitPayControllerTests.cs @@ -0,0 +1,391 @@ +using Bit.Billing.Controllers; +using Bit.Billing.Models; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using BitPayLight.Models.Invoice; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Transaction = Bit.Core.Entities.Transaction; + +namespace Bit.Billing.Test.Controllers; + +using static BitPayConstants; + +public class BitPayControllerTests +{ + private readonly GlobalSettings _globalSettings = new(); + private readonly IBitPayClient _bitPayClient = Substitute.For(); + private readonly ITransactionRepository _transactionRepository = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IPaymentService _paymentService = Substitute.For(); + + private readonly IPremiumUserBillingService _premiumUserBillingService = + Substitute.For(); + + private const string _validWebhookKey = "valid-webhook-key"; + private const string _invalidWebhookKey = "invalid-webhook-key"; + + public BitPayControllerTests() + { + var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey }; + _globalSettings.BitPay = bitPaySettings; + } + + private BitPayController CreateController() => new( + _globalSettings, + _bitPayClient, + _transactionRepository, + _organizationRepository, + _userRepository, + _providerRepository, + _mailService, + _paymentService, + Substitute.For>(), + _premiumUserBillingService); + + [Fact] + public async Task PostIpn_InvalidKey_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + var result = await controller.PostIpn(eventModel, _invalidWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid key", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NullKey_ThrowsException() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + await Assert.ThrowsAsync(() => controller.PostIpn(eventModel, null!)); + } + + [Fact] + public async Task PostIpn_EmptyKey_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + var result = await controller.PostIpn(eventModel, string.Empty); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid key", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NonUsdCurrency_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(currency: "EUR"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Cannot process non-USD payments", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NullPosData_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: null!); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_EmptyPosData_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: ""); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_PosDataWithoutValidId_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_IncompleteInvoice_Ok() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(status: "paid"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var okResult = Assert.IsType(result); + Assert.Equal("Waiting for invoice to be completed", okResult.Value); + } + + [Fact] + public async Task PostIpn_ExistingTransaction_Ok() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(); + var existingTransaction = new Transaction { GatewayId = invoice.Id }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var okResult = Assert.IsType(result); + Assert.Equal("Invoice already processed", okResult.Value); + } + + [Fact] + public async Task PostIpn_ValidOrganizationTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var organizationId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}"); + var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _paymentService.CreditAccountAsync(organization, Arg.Any()).Returns(true); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.OrganizationId == organizationId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _organizationRepository.Received(1).ReplaceAsync(organization); + await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m); + } + + [Fact] + public async Task PostIpn_ValidUserTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var userId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}"); + var user = new User { Id = userId, Email = "user@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _userRepository.GetByIdAsync(userId).Returns(user); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.UserId == userId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _premiumUserBillingService.Received(1).Credit(user, 100.00m); + await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m); + } + + [Fact] + public async Task PostIpn_ValidProviderTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var providerId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}"); + var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider)); + _paymentService.CreditAccountAsync(provider, Arg.Any()).Returns(true); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.ProviderId == providerId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _providerRepository.Received(1).ReplaceAsync(provider); + await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m); + } + + [Fact] + public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId() + { + var controller = CreateController(); + var organizationId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Equal(organizationId, result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId() + { + var controller = CreateController(); + var userId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Equal(userId, result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId() + { + var controller = CreateController(); + var providerId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Equal(providerId, result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_InvalidGuid_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_NullPosData_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: null!); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_EmptyPosData_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: ""); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id") + { + return new BitPayEventModel + { + Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" }, + Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId } + }; + } + + private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete", + string currency = "USD", decimal price = 100.00m, + string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1") + { + return new Invoice + { + Id = invoiceId, + Status = status, + Currency = currency, + Price = (double)price, + PosData = posData, + CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Transactions = + [ + new InvoiceTransaction + { + Type = null, + Confirmations = "1", + ReceivedTime = DateTime.UtcNow.ToString("O") + } + ] + }; + } + +} diff --git a/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs index 800c3ec3ae..c933306399 100644 --- a/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Entities; @@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice; namespace Bit.Core.Test.Billing.Payment.Commands; +using static BitPayConstants; + public class CreateBitPayInvoiceForCreditCommandTests { private readonly IBitPayClient _bitPayClient = Substitute.For(); private readonly GlobalSettings _globalSettings = new() { - BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" } + BitPay = new GlobalSettings.BitPaySettings + { + NotificationUrl = "https://example.com/bitpay/notification", + WebhookKey = "test-webhook-key" + } }; private const string _redirectUrl = "https://bitwarden.com/redirect"; private readonly CreateBitPayInvoiceForCreditCommand _command; @@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == user.Email && options.Buyer.Name == user.Email && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"userId:{user.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); @@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == organization.BillingEmail && options.Buyer.Name == organization.Name && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"organizationId:{organization.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); @@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == provider.BillingEmail && options.Buyer.Name == provider.Name && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"providerId:{provider.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });