using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Services; namespace PowderCoating.UnitTests; public class AccountBalanceServiceTests { // ── DebitAsync ──────────────────────────────────────────────────────── [Fact] public async Task DebitAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged() { await using var context = CreateContext(); var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.DebitAsync(null, 50m); Assert.Equal(100m, account.CurrentBalance); } [Fact] public async Task DebitAsync_WhenAmountIsZero_LeavesBalanceUnchanged() { await using var context = CreateContext(); var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.DebitAsync(1, 0m); Assert.Equal(100m, account.CurrentBalance); } [Fact] public async Task DebitAsync_WhenAccountNotFound_DoesNotThrow() { await using var context = CreateContext(); var (service, _) = CreateService(context); // Account 999 does not exist — service must silently no-op await service.DebitAsync(999, 50m); } [Theory] [InlineData(AccountSubType.Checking, 100, 50, 150)] // Asset — debit-normal [InlineData(AccountSubType.Savings, 200, 75, 275)] // Asset — debit-normal [InlineData(AccountSubType.AccountsReceivable, 0, 100, 100)] // Asset — debit-normal [InlineData(AccountSubType.CostOfGoodsSold, 50, 25, 75)] // COGS — debit-normal [InlineData(AccountSubType.Advertising, 0, 100, 100)] // Expense (≥50) — debit-normal [InlineData(AccountSubType.Payroll, 40, 60, 100)] // Expense (≥50) — debit-normal public async Task DebitAsync_OnDebitNormalAccount_IncreasesBalance( AccountSubType subType, decimal start, decimal amount, decimal expected) { await using var context = CreateContext(); var account = SeedAccount(context, 1, subType, start); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.DebitAsync(1, amount); Assert.Equal(expected, account.CurrentBalance); } [Theory] [InlineData(AccountSubType.AccountsPayable, 100, 50, 50)] // Liability — credit-normal [InlineData(AccountSubType.Sales, 200, 75, 125)] // Revenue — credit-normal [InlineData(AccountSubType.OwnersEquity, 500, 100, 400)] // Equity — credit-normal [InlineData(AccountSubType.RetainedEarnings, 300, 50, 250)] // Equity — credit-normal public async Task DebitAsync_OnCreditNormalAccount_DecreasesBalance( AccountSubType subType, decimal start, decimal amount, decimal expected) { await using var context = CreateContext(); var account = SeedAccount(context, 1, subType, start); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.DebitAsync(1, amount); Assert.Equal(expected, account.CurrentBalance); } // ── CreditAsync ─────────────────────────────────────────────────────── [Fact] public async Task CreditAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged() { await using var context = CreateContext(); var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.CreditAsync(null, 50m); Assert.Equal(100m, account.CurrentBalance); } [Fact] public async Task CreditAsync_WhenAmountIsZero_LeavesBalanceUnchanged() { await using var context = CreateContext(); var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.CreditAsync(1, 0m); Assert.Equal(100m, account.CurrentBalance); } [Theory] [InlineData(AccountSubType.Checking, 200, 50, 150)] // Asset — debit-normal → credit decreases [InlineData(AccountSubType.CostOfGoodsSold, 100, 30, 70)] // COGS — debit-normal → credit decreases [InlineData(AccountSubType.SuppliesMaterials, 80, 20, 60)] // Expense (≥50) — debit-normal → credit decreases public async Task CreditAsync_OnDebitNormalAccount_DecreasesBalance( AccountSubType subType, decimal start, decimal amount, decimal expected) { await using var context = CreateContext(); var account = SeedAccount(context, 1, subType, start); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.CreditAsync(1, amount); Assert.Equal(expected, account.CurrentBalance); } [Theory] [InlineData(AccountSubType.AccountsPayable, 100, 50, 150)] // Liability — credit-normal → credit increases [InlineData(AccountSubType.Sales, 0, 200, 200)] // Revenue — credit-normal → credit increases [InlineData(AccountSubType.RetainedEarnings, 300, 100, 400)] // Equity — credit-normal → credit increases public async Task CreditAsync_OnCreditNormalAccount_IncreasesBalance( AccountSubType subType, decimal start, decimal amount, decimal expected) { await using var context = CreateContext(); var account = SeedAccount(context, 1, subType, start); await context.SaveChangesAsync(); var (service, _) = CreateService(context); await service.CreditAsync(1, amount); Assert.Equal(expected, account.CurrentBalance); } // ── RecalculateAllAsync ─────────────────────────────────────────────── [Fact] public async Task RecalculateAllAsync_UpdatesEachActiveAccountFromLedgerClosingBalance() { await using var context = CreateContext(); SeedAccount(context, 1, AccountSubType.Checking, 0m, companyId: 5); SeedAccount(context, 2, AccountSubType.Sales, 0m, companyId: 5); await context.SaveChangesAsync(); var ledger = new Mock(); ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m }); ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny(), It.IsAny())) .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 1200m }); var (service, _) = CreateService(context, ledger); await service.RecalculateAllAsync(5); var accounts = await context.Accounts.IgnoreQueryFilters() .Where(a => a.CompanyId == 5).ToListAsync(); Assert.Equal(500m, accounts.Single(a => a.Id == 1).CurrentBalance); Assert.Equal(1200m, accounts.Single(a => a.Id == 2).CurrentBalance); } [Fact] public async Task RecalculateAllAsync_WhenLedgerReturnsNull_SkipsAccountBalance() { await using var context = CreateContext(); SeedAccount(context, 10, AccountSubType.Checking, 999m, companyId: 6); await context.SaveChangesAsync(); var ledger = new Mock(); ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync((AccountLedgerDto?)null); var (service, _) = CreateService(context, ledger); await service.RecalculateAllAsync(6); var account = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 10); Assert.Equal(999m, account.CurrentBalance); } [Fact] public async Task RecalculateAllAsync_WhenOneAccountThrows_StillUpdatesRemainingAccounts() { await using var context = CreateContext(); SeedAccount(context, 20, AccountSubType.Checking, 0m, companyId: 7); SeedAccount(context, 21, AccountSubType.Sales, 0m, companyId: 7); await context.SaveChangesAsync(); var ledger = new Mock(); ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("simulated ledger error")); ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny(), It.IsAny())) .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 750m }); var (service, _) = CreateService(context, ledger); await service.RecalculateAllAsync(7); var account21 = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 21); Assert.Equal(750m, account21.CurrentBalance); } [Fact] public async Task RecalculateAllAsync_ExcludesInactiveAccounts() { await using var context = CreateContext(); SeedAccount(context, 30, AccountSubType.Checking, 0m, companyId: 8, isActive: false); await context.SaveChangesAsync(); var ledger = new Mock(); var (service, _) = CreateService(context, ledger); await service.RecalculateAllAsync(8); ledger.Verify( l => l.GetAccountLedgerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── Helpers ─────────────────────────────────────────────────────────── private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService( ApplicationDbContext context, Mock? ledger = null) { var unitOfWork = new UnitOfWork(context); var service = new AccountBalanceService( unitOfWork, (ledger ?? new Mock()).Object, Mock.Of>()); return (service, unitOfWork); } private static Account SeedAccount( ApplicationDbContext context, int id, AccountSubType subType, decimal balance, int companyId = 1, bool isActive = true) { var account = new Account { Id = id, CompanyId = companyId, AccountNumber = $"ACCT-{id}", Name = $"Account {id}", AccountSubType = subType, CurrentBalance = balance, IsActive = isActive }; context.Accounts.Add(account); return account; } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; // Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the // global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null, // which would filter out every row. DefaultHttpContext.Session throws, so we mock // HttpContext itself and stub Session.TryGetValue to return false (no impersonation). var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test"); var principal = new ClaimsPrincipal(identity); byte[]? noBytes = null; var sessionMock = new Mock(); sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); var httpContextMock = new Mock(); httpContextMock.SetupGet(c => c.User).Returns(principal); httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); var accessor = new Mock(); accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); return new ApplicationDbContext(options, accessor.Object, null!); } }