using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; /// /// Builds chronological ledger reports for individual accounts by querying every transaction /// table that can touch a given account (Payments, Expenses, BillPayments, InvoiceItems, Bills, /// AR, AP) and computing a running balance. Also drives /// by returning the authoritative closing balance. /// public class LedgerService : ILedgerService { private readonly ApplicationDbContext _context; private readonly ILogger _logger; /// /// Constructs the service with direct access. /// Direct context access (rather than repositories) is intentional here: the ledger /// must query multiple tables with heterogeneous filters and EF navigation properties, /// which would require many separate repository calls that would be slower and less readable. /// public LedgerService(ApplicationDbContext context, ILogger logger) { _context = context; _logger = logger; } /// /// Assembles a full ledger report for an account over the given date range, including /// nine distinct transaction sources: customer payments deposited, expenses paid from, /// bill payments paid from, invoice revenue line items, sales tax, direct expense /// categorizations, bill line-item categorizations, AR movements, and AP movements. /// Returns null when the account does not exist or is filtered out by the global /// query filters (company isolation / soft delete). /// The OpeningBalance in the returned DTO represents the account balance on the day /// before , computed by . /// public async Task GetAccountLedgerAsync(int accountId, DateTime from, DateTime to) { // Use FirstOrDefaultAsync so global query filters (company isolation, soft delete) apply var account = await _context.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); if (account == null) return null; var fromDate = from.Date; var toDate = to.Date.AddDays(1).AddTicks(-1); // inclusive end of day var entries = new List(); // ── 1. Customer payments deposited INTO this account (DEBIT) ────────── // e.g. Checking/Savings account receives a customer payment var depositedPayments = await _context.Payments .Include(p => p.Invoice) .Where(p => p.DepositAccountId == accountId && p.PaymentDate >= fromDate && p.PaymentDate <= toDate) .ToListAsync(); foreach (var p in depositedPayments) entries.Add(new LedgerEntryDto { Date = p.PaymentDate, Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}", Source = "Customer Payment", Description = p.Notes ?? p.Reference, Debit = p.Amount, Credit = 0, LinkController = "Invoices", LinkId = p.InvoiceId }); // ── 2. Direct expenses paid FROM this account (CREDIT) ──────────────── // e.g. Checking account used to pay an expense var expensesPaidFrom = await _context.Expenses .Include(e => e.Vendor) .Where(e => e.PaymentAccountId == accountId && e.Date >= fromDate && e.Date <= toDate) .ToListAsync(); foreach (var e in expensesPaidFrom) entries.Add(new LedgerEntryDto { Date = e.Date, Reference = e.ExpenseNumber, Source = "Expense", Description = e.Memo ?? e.Vendor?.CompanyName, Debit = 0, Credit = e.Amount, LinkController = "Expenses", LinkId = e.Id }); // ── 3. Bill payments made FROM this account (CREDIT) ────────────────── // e.g. Checking account used to pay a vendor bill var billPaymentsPaidFrom = await _context.BillPayments .Include(bp => bp.Bill) .Where(bp => bp.BankAccountId == accountId && bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate) .ToListAsync(); foreach (var bp in billPaymentsPaidFrom) entries.Add(new LedgerEntryDto { Date = bp.PaymentDate, Reference = bp.PaymentNumber, Source = "Bill Payment", Description = bp.Memo ?? bp.Bill?.BillNumber, Debit = 0, Credit = bp.Amount, LinkController = "Bills", LinkId = bp.BillId }); // ── 4. Invoice line items that post revenue to this account (CREDIT) ── // e.g. Revenue account 4000 receives line-item revenue var revenueItems = await _context.InvoiceItems .Include(ii => ii.Invoice) .Where(ii => ii.RevenueAccountId == accountId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toDate) .ToListAsync(); foreach (var item in revenueItems) entries.Add(new LedgerEntryDto { Date = item.Invoice.InvoiceDate, Reference = item.Invoice.InvoiceNumber, Source = "Invoice", Description = item.Description, Debit = 0, Credit = item.TotalPrice, LinkController = "Invoices", LinkId = item.InvoiceId }); // ── 5. Sales tax collected to this account (CREDIT) ─────────────────── // e.g. Liability account 2200 receives sales tax from invoices var taxInvoices = await _context.Invoices .Where(i => i.SalesTaxAccountId == accountId && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate) .ToListAsync(); foreach (var inv in taxInvoices) entries.Add(new LedgerEntryDto { Date = inv.InvoiceDate, Reference = inv.InvoiceNumber, Source = "Sales Tax", Description = $"Tax on {inv.InvoiceNumber}", Debit = 0, Credit = inv.TaxAmount, LinkController = "Invoices", LinkId = inv.Id }); // ── 6. Direct expenses categorized to this account (DEBIT) ──────────── // e.g. Expense account 6200 receives direct expense entries var expensesTo = await _context.Expenses .Include(e => e.Vendor) .Where(e => e.ExpenseAccountId == accountId && e.Date >= fromDate && e.Date <= toDate) .ToListAsync(); foreach (var e in expensesTo) entries.Add(new LedgerEntryDto { Date = e.Date, Reference = e.ExpenseNumber, Source = "Expense", Description = e.Memo ?? e.Vendor?.CompanyName, Debit = e.Amount, Credit = 0, LinkController = "Expenses", LinkId = e.Id }); // ── 7. Bill line items categorized to this account (DEBIT) ──────────── // e.g. Expense/COGS account receives costs from vendor bill line items var billLineItems = await _context.BillLineItems .Include(bli => bli.Bill) .Where(bli => bli.AccountId == accountId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toDate) .ToListAsync(); foreach (var bli in billLineItems) entries.Add(new LedgerEntryDto { Date = bli.Bill.BillDate, Reference = bli.Bill.BillNumber, Source = "Bill", Description = bli.Description, Debit = bli.Amount, Credit = 0, LinkController = "Bills", LinkId = bli.BillId }); // ── 8. Accounts Receivable ───────────────────────────────────────────── // Invoice creation increases AR (DEBIT); customer payments reduce AR (CREDIT) if (account.AccountSubType == AccountSubType.AccountsReceivable) { var arInvoices = await _context.Invoices .Include(i => i.Customer) .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate) .ToListAsync(); foreach (var inv in arInvoices) { var customerName = inv.Customer?.IsCommercial == true ? inv.Customer.CompanyName : $"{inv.Customer?.ContactFirstName} {inv.Customer?.ContactLastName}".Trim(); entries.Add(new LedgerEntryDto { Date = inv.InvoiceDate, Reference = inv.InvoiceNumber, Source = "Invoice", Description = customerName, Debit = inv.Total, Credit = 0, LinkController = "Invoices", LinkId = inv.Id }); } var arPayments = await _context.Payments .Include(p => p.Invoice) .Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toDate) .ToListAsync(); foreach (var p in arPayments) entries.Add(new LedgerEntryDto { Date = p.PaymentDate, Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}", Source = "Invoice Payment", Description = $"Payment — {p.Invoice?.InvoiceNumber}", Debit = 0, Credit = p.Amount, LinkController = "Invoices", LinkId = p.InvoiceId }); } // ── 9. Accounts Payable ──────────────────────────────────────────────── // Bill creation increases AP (CREDIT); bill payments reduce AP (DEBIT) if (account.AccountSubType == AccountSubType.AccountsPayable) { var apBills = await _context.Bills .Include(b => b.Vendor) .Where(b => b.APAccountId == accountId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate >= fromDate && b.BillDate <= toDate) .ToListAsync(); foreach (var b in apBills) entries.Add(new LedgerEntryDto { Date = b.BillDate, Reference = b.BillNumber, Source = "Bill", Description = b.Vendor?.CompanyName ?? b.Memo, Debit = 0, Credit = b.Total, LinkController = "Bills", LinkId = b.Id }); var apPayments = await _context.BillPayments .Include(bp => bp.Bill) .Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate) .ToListAsync(); foreach (var bp in apPayments) entries.Add(new LedgerEntryDto { Date = bp.PaymentDate, Reference = bp.PaymentNumber, Source = "Bill Payment", Description = bp.Memo ?? bp.Bill?.BillNumber, Debit = bp.Amount, Credit = 0, LinkController = "Bills", LinkId = bp.BillId }); } // ── Sort and compute running balance ────────────────────────────────── entries = entries .OrderBy(e => e.Date) .ThenBy(e => e.Reference) .ToList(); // Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType, // since users could misconfigure AccountType while SubType is picked from a constrained list). bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType); // Compute the balance before the selected period decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance); decimal runningBalance = priorBalance; foreach (var entry in entries) { runningBalance += normalDebitBalance ? entry.Debit - entry.Credit : entry.Credit - entry.Debit; entry.RunningBalance = runningBalance; } return new AccountLedgerDto { Id = account.Id, AccountNumber = account.AccountNumber, Name = account.Name, AccountType = account.AccountType, AccountSubType = account.AccountSubType, From = fromDate, To = to.Date, OpeningBalance = priorBalance, PeriodDebits = entries.Sum(e => e.Debit), PeriodCredits = entries.Sum(e => e.Credit), ClosingBalance = runningBalance, Entries = entries }; } /// /// Returns true if the account sub-type has a normal debit balance (Assets, Expenses, COGS), /// false for normal credit balance (Liabilities, Equity, Revenue). /// is used rather than /// because sub-type is constrained to a known set of values and cannot be misconfigured by a user, /// whereas AccountType is a broader category that a user might set incorrectly. /// Expense enum values are ≥ 50 by convention, allowing a catch-all range match. /// private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch { // Asset subtypes → normal debit balance AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => true, // COGS → normal debit balance AccountSubType.CostOfGoodsSold => true, // Expense subtypes (enum values ≥ 50) → normal debit balance var st when (int)st >= 50 => true, // Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance _ => false }; /// /// Computes the account balance on the day immediately before /// by summing all activity prior to that date across every transaction source and adding /// the stored OpeningBalance. The opening balance is only included when its recorded /// date is on or before — a future-dated opening balance (e.g. /// from a mid-year chart-of-accounts migration) should not pollute earlier period reports. /// A null OpeningBalanceDate means the balance predates all transactions and always applies. /// The sign convention follows : debits increase debit-normal /// accounts and credits increase credit-normal accounts. /// private async Task ComputePriorBalanceAsync( PowderCoating.Core.Entities.Account account, DateTime beforeDate, DateTime periodEnd, bool normalDebitBalance) { var accountId = account.Id; decimal debits = 0; decimal credits = 0; // 1. Customer payments deposited INTO this account (DEBIT) debits += await _context.Payments .Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate) .SumAsync(p => (decimal?)p.Amount) ?? 0; // 2. Direct expenses paid FROM this account (CREDIT) credits += await _context.Expenses .Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate) .SumAsync(e => (decimal?)e.Amount) ?? 0; // 3. Bill payments made FROM this account (CREDIT) credits += await _context.BillPayments .Where(bp => bp.BankAccountId == accountId && bp.PaymentDate < beforeDate) .SumAsync(bp => (decimal?)bp.Amount) ?? 0; // 4. Invoice line items posting revenue to this account (CREDIT) credits += await _context.InvoiceItems .Where(ii => ii.RevenueAccountId == accountId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate < beforeDate) .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; // 5. Sales tax collected to this account (CREDIT) credits += await _context.Invoices .Where(i => i.SalesTaxAccountId == accountId && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate < beforeDate) .SumAsync(i => (decimal?)i.TaxAmount) ?? 0; // 6. Direct expenses categorized to this account (DEBIT) debits += await _context.Expenses .Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate) .SumAsync(e => (decimal?)e.Amount) ?? 0; // 7. Bill line items categorized to this account (DEBIT) debits += await _context.BillLineItems .Where(bli => bli.AccountId == accountId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate < beforeDate) .SumAsync(bli => (decimal?)bli.Amount) ?? 0; // 8. Accounts Receivable if (account.AccountSubType == AccountSubType.AccountsReceivable) { debits += await _context.Invoices .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate < beforeDate) .SumAsync(i => (decimal?)i.Total) ?? 0; credits += await _context.Payments .Where(p => p.PaymentDate < beforeDate) .SumAsync(p => (decimal?)p.Amount) ?? 0; } // 9. Accounts Payable if (account.AccountSubType == AccountSubType.AccountsPayable) { credits += await _context.Bills .Where(b => b.APAccountId == accountId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate < beforeDate) .SumAsync(b => (decimal?)b.Total) ?? 0; debits += await _context.BillPayments .Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate) .SumAsync(bp => (decimal?)bp.Amount) ?? 0; } decimal netActivity = normalDebitBalance ? debits - credits : credits - debits; // Apply the opening balance if it was established on or before the end of the viewed period. // A null date means it predates all transactions and always applies. decimal openingBalance = (account.OpeningBalanceDate == null || account.OpeningBalanceDate.Value.Date <= periodEnd) ? account.OpeningBalance : 0; return openingBalance + netActivity; } }