diff --git a/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs new file mode 100644 index 0000000..3e0ae10 --- /dev/null +++ b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs @@ -0,0 +1,300 @@ +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!); + } +} diff --git a/tests/PowderCoating.UnitTests/DepositsControllerTests.cs b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs new file mode 100644 index 0000000..e1b2fa2 --- /dev/null +++ b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs @@ -0,0 +1,355 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class DepositsControllerTests +{ + // ── Record — input validation (no user resolution needed) ───────────── + + [Fact] + public async Task Record_WhenAmountIsZero_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 0m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("greater than zero", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenAmountIsNegative_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: -50m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + } + + [Fact] + public async Task Record_WhenNeitherJobNorQuoteProvided_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: null, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("Job or Quote must be specified", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenInvalidPaymentMethod_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "BitcoinPizza", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("Invalid payment method", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenUserNotFound_ReturnsUnauthorized() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: null); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + Assert.IsType(result); + } + + // ── Record — success paths ──────────────────────────────────────────── + + [Fact] + public async Task Record_WithJobId_CreatesDepositAndReturnsSuccessJson() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + var receivedDate = new DateTime(2026, 4, 15); + + var result = await controller.Record(jobId: 42, quoteId: null, customerId: 7, + amount: 250m, paymentMethod: "Check", + receivedDate: receivedDate, reference: "REF-001", notes: "Half up front"); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(250m, doc.RootElement.GetProperty("amount").GetDecimal()); + Assert.Equal("Check", doc.RootElement.GetProperty("paymentMethod").GetString()); + Assert.Equal("04/15/2026", doc.RootElement.GetProperty("receivedDate").GetString()); + Assert.Equal("REF-001", doc.RootElement.GetProperty("reference").GetString()); + Assert.Equal("Half up front", doc.RootElement.GetProperty("notes").GetString()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(42, deposit.JobId); + Assert.Null(deposit.QuoteId); + Assert.Equal(7, deposit.CustomerId); + Assert.Equal(250m, deposit.Amount); + Assert.Equal(PaymentMethod.Check, deposit.PaymentMethod); + } + + [Fact] + public async Task Record_WithQuoteId_CreatesDepositLinkedToQuote() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + + var result = await controller.Record(jobId: null, quoteId: 99, customerId: 5, + amount: 500m, paymentMethod: "CreditDebitCard", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("Credit / Debit Card", doc.RootElement.GetProperty("paymentMethod").GetString()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(99, deposit.QuoteId); + Assert.Null(deposit.JobId); + } + + [Fact] + public async Task Record_GeneratesReceiptNumberInExpectedFormat() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!; + + // Format: DEP-YYMM-#### (e.g. DEP-2604-0001) + Assert.Matches(@"^DEP-\d{4}-\d{4}$", receiptNumber); + Assert.EndsWith("-0001", receiptNumber); + } + + [Fact] + public async Task Record_SecondDepositInSameMonth_IncrementsSequenceNumber() + { + await using var context = CreateContext(); + var user = CreateUser(companyId: 1); + var controller = CreateController(context, currentUser: user); + + // First deposit + await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + // Second deposit + var result = await controller.Record(jobId: 2, quoteId: null, customerId: 1, + amount: 200m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!; + Assert.EndsWith("-0002", receiptNumber); + } + + [Fact] + public async Task Record_PaymentMethodDisplayStrings_AreHumanReadable() + { + await using var context = CreateContext(); + var user = CreateUser(companyId: 1); + + var cases = new[] + { + ("Cash", "Cash"), + ("Check", "Check"), + ("CreditDebitCard", "Credit / Debit Card"), + ("BankTransferACH", "Bank Transfer / ACH"), + ("DigitalPayment", "Digital Payment"), + }; + + foreach (var (method, expected) in cases) + { + var controller = CreateController(context, currentUser: user); + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 10m, paymentMethod: method, + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + if (doc.RootElement.GetProperty("success").GetBoolean()) + Assert.Equal(expected, doc.RootElement.GetProperty("paymentMethod").GetString()); + } + } + + // ── Delete ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_WhenDepositNotFound_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 999, returnUrl: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("not found", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Delete_WhenDepositAlreadyAppliedToInvoice_ReturnsJsonError() + { + await using var context = CreateContext(); + context.Deposits.Add(new Deposit + { + Id = 1, + CompanyId = 1, + ReceiptNumber = "DEP-2604-0001", + CustomerId = 1, + JobId = 1, + Amount = 100m, + PaymentMethod = PaymentMethod.Cash, + ReceivedDate = DateTime.UtcNow, + AppliedToInvoiceId = 55 // already applied + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 1, returnUrl: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("already been applied", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Delete_WhenUnappliedDeposit_SoftDeletesAndReturnsSuccess() + { + await using var context = CreateContext(); + context.Deposits.Add(new Deposit + { + Id = 2, + CompanyId = 1, + ReceiptNumber = "DEP-2604-0001", + CustomerId = 1, + JobId = 1, + Amount = 75m, + PaymentMethod = PaymentMethod.Cash, + ReceivedDate = DateTime.UtcNow, + AppliedToInvoiceId = null // not yet applied + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 2, returnUrl: null); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(d => d.Id == 2); + Assert.True(deposit.IsDeleted); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static DepositsController CreateController( + ApplicationDbContext context, + ApplicationUser? currentUser) + { + var uow = new UnitOfWork(context); + var userManager = CreateUserManagerMock(); + userManager + .Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(currentUser); + + var controller = new DepositsController( + uow, + userManager.Object, + Mock.Of>(), + context); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + + return controller; + } + + private static ApplicationUser CreateUser(int companyId) => new() + { + Id = "test-user", + CompanyId = companyId, + UserName = "test@example.com", + Email = "test@example.com", + FirstName = "Test", + LastName = "User" + }; + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + private static JsonDocument ParseJson(IActionResult result) + { + var json = Assert.IsType(result); + var serialized = JsonSerializer.Serialize(json.Value); + return JsonDocument.Parse(serialized); + } + + 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!); + } +} diff --git a/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs new file mode 100644 index 0000000..8b05d6f --- /dev/null +++ b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs @@ -0,0 +1,307 @@ +using System.Security.Claims; +using AutoMapper; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.DTOs.GiftCertificate; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class GiftCertificatesControllerTests +{ + // ── Index — lazy expiration ─────────────────────────────────────────── + + [Fact] + public async Task Index_LazilySetsExpiredStatus_ForActiveCertsPastExpiryDate() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2603-0001", + OriginalAmount = 100m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow.AddMonths(-2), + ExpiryDate = DateTime.UtcNow.AddDays(-1) // past expiry + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index(null, null); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + Assert.Single(dtos); + Assert.Equal(GiftCertificateStatus.Expired, dtos[0].Status); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Expired, dbCert.Status); + } + + [Fact] + public async Task Index_DoesNotExpire_ActiveCertsWithFutureExpiryDate() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2604-0001", + OriginalAmount = 50m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow, + ExpiryDate = DateTime.UtcNow.AddDays(30) // future expiry + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + await controller.Index(null, null); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Active, dbCert.Status); + } + + // ── Index — filtering ───────────────────────────────────────────────── + + [Fact] + public async Task Index_StatusFilter_ReturnsOnlyMatchingStatus() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 200m, Status = GiftCertificateStatus.Voided, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index(null, nameof(GiftCertificateStatus.Active)); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + var dto = Assert.Single(dtos); + Assert.Equal(GiftCertificateStatus.Active, dto.Status); + } + + [Fact] + public async Task Index_SearchTerm_FiltersByCertificateCode() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index("0001", null); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + var dto = Assert.Single(dtos); + Assert.Equal("GC-2604-0001", dto.CertificateCode); + } + + [Fact] + public async Task Index_ViewBag_TotalValueIncludesOnlyActiveAndPartiallyRedeemed() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-A", OriginalAmount = 100m, RedeemedAmount = 0, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-P", OriginalAmount = 80m, RedeemedAmount = 30m, Status = GiftCertificateStatus.PartiallyRedeemed, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 3, CompanyId = 1, CertificateCode = "GC-V", OriginalAmount = 200m, RedeemedAmount = 200m, Status = GiftCertificateStatus.FullyRedeemed, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + await controller.Index(null, null); + + // Active: RemainingBalance=100, PartiallyRedeemed: 80-30=50, FullyRedeemed: excluded + Assert.Equal(150m, controller.ViewBag.TotalValue); + Assert.Equal(2, controller.ViewBag.TotalActive); + } + + // ── Create — success paths ──────────────────────────────────────────── + + [Fact] + public async Task Create_WithSoldReason_SetsPurchasePriceAndGeneratesCode() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Create(new CreateGiftCertificateDto + { + Amount = 100m, + IssuedReason = GiftCertificateIssuedReason.Sold, + PurchasePrice = 80m, + PurchasingCustomerId = null, + RecipientName = "Jane Doe" + }); + + Assert.IsType(result); + + var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(80m, cert.PurchasePrice); + Assert.Equal(GiftCertificateStatus.Active, cert.Status); + Assert.Matches(@"^GC-\d{4}-\d{4}$", cert.CertificateCode); + Assert.EndsWith("-0001", cert.CertificateCode); + } + + [Fact] + public async Task Create_WithNonSoldReason_NullsPurchasePriceAndPurchasingCustomerId() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Create(new CreateGiftCertificateDto + { + Amount = 50m, + IssuedReason = GiftCertificateIssuedReason.Promotional, + PurchasePrice = 50m, // Provided but should be nulled + PurchasingCustomerId = 7 // Provided but should be nulled + }); + + Assert.IsType(result); + + var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Null(cert.PurchasePrice); + Assert.Null(cert.PurchasingCustomerId); + } + + // ── Void ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Void_ActiveCert_ChangesStatusToVoidedAndRedirectsToIndex() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2604-0001", + OriginalAmount = 100m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 1); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Voided, dbCert.Status); + } + + [Fact] + public async Task Void_FullyRedeemedCert_BlocksWithTempDataErrorAndRedirectsToDetails() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 2, CompanyId = 1, + CertificateCode = "GC-2604-0002", + OriginalAmount = 100m, RedeemedAmount = 100m, + Status = GiftCertificateStatus.FullyRedeemed, + IssueDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 2); + + var redirect = Assert.IsType(result); + Assert.Equal("Details", redirect.ActionName); + Assert.Contains("fully redeemed", controller.TempData["Error"]?.ToString()); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.FullyRedeemed, dbCert.Status); // unchanged + } + + [Fact] + public async Task Void_NonExistentCert_ReturnsNotFound() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 999); + + Assert.IsType(result); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static GiftCertificatesController CreateController( + ApplicationDbContext context, + ApplicationUser? currentUser) + { + var uow = new UnitOfWork(context); + var userManager = CreateUserManagerMock(); + userManager + .Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(currentUser); + + var httpContext = new DefaultHttpContext(); + var controller = new GiftCertificatesController( + uow, + Mock.Of(), + Mock.Of>(), + userManager.Object, + Mock.Of()); + + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + return controller; + } + + private static ApplicationUser CreateUser(int companyId) => new() + { + Id = "test-user", + CompanyId = companyId, + UserName = "test@example.com", + Email = "test@example.com", + FirstName = "Test", + LastName = "User" + }; + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + 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!); + } +} diff --git a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs new file mode 100644 index 0000000..08bacd7 --- /dev/null +++ b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs @@ -0,0 +1,616 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Services; + +namespace PowderCoating.UnitTests; + +public class LedgerServiceTests +{ + private static readonly DateTime PeriodStart = new DateTime(2026, 4, 1); + private static readonly DateTime PeriodEnd = new DateTime(2026, 4, 30); + private static readonly DateTime InPeriod = new DateTime(2026, 4, 15); + + // ── Returns null for unknown account ───────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_WhenAccountDoesNotExist_ReturnsNull() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var result = await service.GetAccountLedgerAsync(999, PeriodStart, PeriodEnd); + + Assert.Null(result); + } + + // ── Source 1: customer payment deposited (DEBIT) ───────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source1_CustomerPaymentDeposited_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedInvoice(context, id: 99); + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 99, + DepositAccountId = 1, + Amount = 250m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Customer Payment", entry.Source); + Assert.Equal(250m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + // ── Source 2: expense paid FROM account (CREDIT) ────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source2_ExpensePaidFromAccount_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-001", + PaymentAccountId = 1, // Checking account pays the expense + ExpenseAccountId = 999, // Different account — so Source 6 does not also fire + Amount = 80m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Expense", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(80m, entry.Credit); + } + + // ── Source 3: bill payment made FROM account (CREDIT) ───────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source3_BillPaymentFromAccount_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedBill(context, id: 99); // Include(bp => bp.Bill) requires the Bill to exist + context.BillPayments.Add(new BillPayment + { + Id = 1, CompanyId = 1, + PaymentNumber = "BP-001", + BillId = 99, + VendorId = 1, + BankAccountId = 1, // Checking account used to pay the bill + Amount = 150m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Bill Payment", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(150m, entry.Credit); + } + + // ── Source 4: invoice revenue line items (CREDIT) ───────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source4_InvoiceLineItem_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Sales); + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-001", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 500m + }); + context.InvoiceItems.Add(new InvoiceItem + { + Id = 1, CompanyId = 1, + InvoiceId = 10, + RevenueAccountId = 1, + Description = "Powder Coating", + Quantity = 1, UnitPrice = 500m, TotalPrice = 500m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Invoice", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(500m, entry.Credit); + } + + [Fact] + public async Task GetAccountLedgerAsync_Source4_DraftAndVoidedInvoicesExcluded() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Sales); + context.Invoices.AddRange( + new Invoice { Id = 10, CompanyId = 1, InvoiceNumber = "INV-DRAFT", CustomerId = 1, Status = InvoiceStatus.Draft, InvoiceDate = InPeriod }, + new Invoice { Id = 11, CompanyId = 1, InvoiceNumber = "INV-VOID", CustomerId = 1, Status = InvoiceStatus.Voided, InvoiceDate = InPeriod }); + context.InvoiceItems.AddRange( + new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }, + new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 11, RevenueAccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Empty(ledger!.Entries); + } + + // ── Source 5: sales tax collected (CREDIT) ──────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source5_SalesTaxCollected_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.OtherCurrentLiability); + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-TAX", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + TaxAmount = 42m, + SalesTaxAccountId = 1, + Total = 542m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Sales Tax", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(42m, entry.Credit); + } + + // ── Source 6: expense categorized to account (DEBIT) ────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source6_ExpenseCategorizedToAccount_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Advertising); + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-002", + ExpenseAccountId = 1, // Advertising account receives the expense + PaymentAccountId = 999, // Different account — so Source 2 does not also fire + Amount = 300m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Expense", entry.Source); + Assert.Equal(300m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + // ── Source 7: bill line items to expense account (DEBIT) ────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source7_BillLineItem_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials); + context.Bills.Add(new Bill + { + Id = 10, CompanyId = 1, + BillNumber = "BILL-001", + VendorId = 1, + APAccountId = 999, // AP account different from test account + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 200m + }); + context.BillLineItems.Add(new BillLineItem + { + Id = 1, CompanyId = 1, + BillId = 10, + AccountId = 1, + Description = "Powder supplies", + Quantity = 1, UnitPrice = 200m, Amount = 200m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Bill", entry.Source); + Assert.Equal(200m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + [Fact] + public async Task GetAccountLedgerAsync_Source7_DraftAndVoidedBillsExcluded() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials); + context.Bills.AddRange( + new Bill { Id = 10, CompanyId = 1, BillNumber = "B-DRAFT", VendorId = 1, APAccountId = 999, Status = BillStatus.Draft, BillDate = InPeriod, Total = 100m }, + new Bill { Id = 11, CompanyId = 1, BillNumber = "B-VOID", VendorId = 1, APAccountId = 999, Status = BillStatus.Voided, BillDate = InPeriod, Total = 100m }); + context.BillLineItems.AddRange( + new BillLineItem { Id = 1, CompanyId = 1, BillId = 10, AccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, Amount = 100m }, + new BillLineItem { Id = 2, CompanyId = 1, BillId = 11, AccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, Amount = 100m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Empty(ledger!.Entries); + } + + // ── Source 8: Accounts Receivable ───────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source8_AR_InvoicesDebitPaymentsCredit() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.AccountsReceivable); + SeedCustomer(context, id: 1); // Include(i => i.Customer) requires the Customer to exist + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-AR", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 400m + }); + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, + Amount = 100m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + // No DepositAccountId — so Source 1 does not also fire for this account + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(2, ledger!.Entries.Count); + var invoiceEntry = ledger.Entries.Single(e => e.Source == "Invoice"); + var paymentEntry = ledger.Entries.Single(e => e.Source == "Invoice Payment"); + Assert.Equal(400m, invoiceEntry.Debit); + Assert.Equal(0m, invoiceEntry.Credit); + Assert.Equal(0m, paymentEntry.Debit); + Assert.Equal(100m, paymentEntry.Credit); + Assert.Equal(300m, ledger.ClosingBalance); // debit-normal: 400 - 100 + } + + // ── Source 9: Accounts Payable ──────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source9_AP_BillsCreditBillPaymentsDebit() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.AccountsPayable); + SeedVendor(context, id: 1); // Include(b => b.Vendor) requires the Vendor to exist + context.Bills.Add(new Bill + { + Id = 10, CompanyId = 1, + BillNumber = "BILL-AP", + VendorId = 1, + APAccountId = 1, // This is the AP account under test + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 400m + }); + // Bill must be seeded because the WHERE for BillPayments navigates through Bill.APAccountId + context.BillPayments.Add(new BillPayment + { + Id = 1, CompanyId = 1, + PaymentNumber = "BP-AP", + BillId = 10, + VendorId = 1, + BankAccountId = 999, // Different account — so Source 3 does not also fire + Amount = 150m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(2, ledger!.Entries.Count); + var billEntry = ledger.Entries.Single(e => e.Source == "Bill"); + var paymentEntry = ledger.Entries.Single(e => e.Source == "Bill Payment"); + Assert.Equal(0m, billEntry.Debit); + Assert.Equal(400m, billEntry.Credit); + Assert.Equal(150m, paymentEntry.Debit); + Assert.Equal(0m, paymentEntry.Credit); + Assert.Equal(250m, ledger.ClosingBalance); // credit-normal: 400 - 150 + } + + // ── Date range filtering ────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_DateRangeFiltering_ExcludesEntriesOutsideRange() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + var dates = new[] + { + new DateTime(2026, 3, 15), // before period — excluded from entries, goes to priorBalance + new DateTime(2026, 4, 15), // in period — included + new DateTime(2026, 5, 15), // after period — excluded + }; + var i = 0; + foreach (var date in dates) + { + context.Expenses.Add(new Expense + { + Id = ++i, CompanyId = 1, + ExpenseNumber = $"EXP-{i:D3}", + PaymentAccountId = 1, + ExpenseAccountId = 999, + Amount = 100m, + Date = date, + PaymentMethod = PaymentMethod.Cash + }); + } + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal(InPeriod.Date, entry.Date.Date); + } + + // ── Running balance ─────────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_RunningBalance_DebitNormalAccount_CorrectlyComputed() + { + await using var context = CreateContext(); + // Opening balance of 100 on Checking (debit-normal); null date means always applied + SeedAccount(context, id: 1, AccountSubType.Checking, openingBalance: 100m, openingBalanceDate: null); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + context.Payments.AddRange( + new Payment { Id = 1, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 50m, PaymentDate = new DateTime(2026, 4, 10), PaymentMethod = PaymentMethod.Cash }, + new Payment { Id = 2, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 75m, PaymentDate = new DateTime(2026, 4, 20), PaymentMethod = PaymentMethod.Cash }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(100m, ledger!.OpeningBalance); + Assert.Equal(2, ledger.Entries.Count); + Assert.Equal(150m, ledger.Entries[0].RunningBalance); // 100 + 50 + Assert.Equal(225m, ledger.Entries[1].RunningBalance); // 150 + 75 + Assert.Equal(225m, ledger.ClosingBalance); + } + + [Fact] + public async Task GetAccountLedgerAsync_RunningBalance_CreditNormalAccount_CorrectlyComputed() + { + await using var context = CreateContext(); + // Opening balance of 200 on Sales (credit-normal); null date means always applied + SeedAccount(context, id: 1, AccountSubType.Sales, openingBalance: 200m, openingBalanceDate: null); + // Source 4: InvoiceItems must have the Invoice seeded because the WHERE navigates through Invoice + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-001", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 150m + }); + context.InvoiceItems.AddRange( + new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "A", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }, + new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "B", Quantity = 1, UnitPrice = 50m, TotalPrice = 50m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(200m, ledger!.OpeningBalance); + Assert.Equal(2, ledger.Entries.Count); + // Credit-normal: runningBalance += Credit - Debit + // After entry 1 (Credit=100): 200 + 100 = 300 + // After entry 2 (Credit=50): 300 + 50 = 350 + Assert.Equal(350m, ledger.ClosingBalance); + } + + // ── Opening balance — future-dated excluded ─────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_FutureDatedOpeningBalance_ExcludedFromPriorBalance() + { + await using var context = CreateContext(); + // Opening balance dated 2027-01-01 — later than PeriodEnd (2026-04-30), so excluded + SeedAccount(context, id: 1, AccountSubType.Checking, + openingBalance: 500m, openingBalanceDate: new DateTime(2027, 1, 1)); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, DepositAccountId = 1, + Amount = 100m, PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + // OpeningBalance (prior balance) = 0, not 500 — future-dated opening balance excluded + Assert.Equal(0m, ledger!.OpeningBalance); + Assert.Equal(100m, ledger.ClosingBalance); // 0 + 100 + } + + // ── Period totals ───────────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_PeriodTotals_CorrectlySumDebitsAndCredits() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + // Source 1: payment deposited → DEBIT 200 + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, DepositAccountId = 1, + Amount = 200m, PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + // Source 2: expense paid from account → CREDIT 50 + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-001", + PaymentAccountId = 1, // Checking pays + ExpenseAccountId = 999, // Expense account (different) — Source 6 does not also fire + Amount = 50m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(200m, ledger!.PeriodDebits); + Assert.Equal(50m, ledger.PeriodCredits); + Assert.Equal(150m, ledger.ClosingBalance); // debit-normal: 200 - 50 + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static LedgerService CreateService(ApplicationDbContext context) + => new LedgerService(context, Mock.Of>()); + + private static Account SeedAccount( + ApplicationDbContext context, + int id, + AccountSubType subType, + int companyId = 1, + decimal openingBalance = 0m, + DateTime? openingBalanceDate = null) + { + var account = new Account + { + Id = id, + CompanyId = companyId, + AccountNumber = $"ACCT-{id:D4}", + Name = $"Account {id}", + AccountSubType = subType, + OpeningBalance = openingBalance, + OpeningBalanceDate = openingBalanceDate, + IsActive = true + }; + context.Accounts.Add(account); + return account; + } + + private static Invoice SeedInvoice(ApplicationDbContext context, int id, int companyId = 1) + { + var invoice = new Invoice + { + Id = id, + CompanyId = companyId, + InvoiceNumber = $"INV-{id:D4}", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 0m + }; + context.Invoices.Add(invoice); + return invoice; + } + + private static void SeedVendor(ApplicationDbContext context, int id, int companyId = 1) + { + context.Vendors.Add(new Vendor + { + Id = id, + CompanyId = companyId, + CompanyName = $"Vendor {id}" + }); + } + + private static void SeedCustomer(ApplicationDbContext context, int id, int companyId = 1) + { + context.Customers.Add(new Customer + { + Id = id, + CompanyId = companyId, + CompanyName = $"Customer {id}" + }); + } + + private static void SeedBill(ApplicationDbContext context, int id, int companyId = 1) + { + context.Bills.Add(new Bill + { + Id = id, + CompanyId = companyId, + BillNumber = $"BILL-{id:D4}", + VendorId = 999, + APAccountId = 999, + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 0m + }); + } + + 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!); + } +}