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!); } }