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") } ] }; } }