using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; /// /// Implements financial aggregate reports (P&L, Balance Sheet, AR Aging, Sales & Income) /// using direct DbContext access with AsNoTracking. Migrated from inline queries in /// ReportsController as part of Phase 2 of the data-access architecture migration. /// The four report types each have matching PDF export paths in the controller that /// share the same data by calling these methods, eliminating the previous duplication. /// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan. /// public class FinancialReportService : IFinancialReportService { private readonly ApplicationDbContext _context; public FinancialReportService(ApplicationDbContext context) { _context = context; } /// public async Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null) { var toEnd = to.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId); var isCash = accountingMethod == AccountingMethod.Cash; var revenueAccounts = await _context.Accounts .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .ToDictionaryAsync(a => a.Id); var revenueLines = new List(); if (isCash) { // Cash basis: total payments received in period (not split by revenue account) var cashRevenue = await _context.Payments .Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd && p.Invoice.Status != InvoiceStatus.Voided) .SumAsync(p => (decimal?)p.Amount) ?? 0; if (cashRevenue > 0) revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue }); } else { // Accrual basis: revenue = invoice item amounts by invoice date var accrualRevenue = await _context.InvoiceItems .Where(ii => ii.RevenueAccountId != null && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) .GroupBy(ii => ii.RevenueAccountId!.Value) .Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) }) .ToListAsync(); revenueLines.AddRange(accrualRevenue .Where(r => revenueAccounts.ContainsKey(r.AccountId)) .Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount }) .OrderBy(l => l.AccountNumber)); var unlinkedRevenue = await _context.InvoiceItems .Where(ii => ii.RevenueAccountId == null && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; if (unlinkedRevenue > 0) revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue }); // Contra-revenue: discounts granted and credit memos applied reduce gross revenue. var periodDiscounts = await _context.Invoices .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; var periodCredits = await _context.CreditMemoApplications .Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; var totalDeductions = periodDiscounts + periodCredits; if (totalDeductions > 0) revenueLines.Add(new FinancialReportLine { AccountNumber = "4950", AccountName = "Less: Sales Discounts & Credits", Amount = -totalDeductions }); // GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption. var periodGcReclassified = await _context.InvoiceItems .Where(ii => ii.IsGiftCertificate && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m; if (periodGcReclassified > 0) revenueLines.Add(new FinancialReportLine { AccountNumber = "2500", AccountName = "Less: Gift Certificates Issued (Deferred Revenue)", Amount = -periodGcReclassified }); // Voided GCs with remaining balance are breakage income (liability extinguished). var periodGcBreakage = await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided && gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd && gc.OriginalAmount > gc.RedeemedAmount) .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; if (periodGcBreakage > 0) revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Gift Certificate Breakage Income", Amount = periodGcBreakage }); } // COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date var expenseAmounts = new Dictionary(); if (isCash) { var cashExpenses = await _context.Expenses .Where(e => e.Date >= from && e.Date <= toEnd) .GroupBy(e => e.ExpenseAccountId) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .ToListAsync(); foreach (var e in cashExpenses) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; // Pro-rate paid bill line items by payment fraction (bill total may be partial) var paidBillLines = await _context.BillPayments .Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd) .Include(bp => bp.Bill).ThenInclude(b => b.LineItems) .ToListAsync(); foreach (var bp in paidBillLines) { var fraction = bp.Bill.Total == 0 ? 1m : bp.Amount / bp.Bill.Total; foreach (var li in bp.Bill.LineItems.Where(li => li.AccountId != null)) expenseAmounts[li.AccountId!.Value] = expenseAmounts.GetValueOrDefault(li.AccountId!.Value) + li.Amount * fraction; } } else { var accrualExpenses = await _context.Expenses .Where(e => e.Date >= from && e.Date <= toEnd) .GroupBy(e => e.ExpenseAccountId) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .ToListAsync(); foreach (var e in accrualExpenses) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; var accrualBillLines = await _context.BillLineItems .Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd) .GroupBy(bli => bli.AccountId!.Value) .Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }) .ToListAsync(); foreach (var b in accrualBillLines) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; } var expAccounts = await _context.Accounts .Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive) .ToDictionaryAsync(a => a.Id); var cogsLines = new List(); var expenseLines = new List(); foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999")) { if (!expAccounts.TryGetValue(accountId, out var acct)) continue; var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount }; if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line); } return new ProfitAndLossDto { From = from, To = to, CompanyName = companyName, AccountingMethod = accountingMethod, RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount), CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount), ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount), }; } /// public async Task GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null) { var asOfEnd = asOf.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId); var isCash = accountingMethod == AccountingMethod.Cash; // Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1) var depositsByAcct = await _context.Payments .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null && p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.WrittenOff) .GroupBy(p => p.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var expFromByAcct = await _context.Expenses .Where(e => e.Date <= asOfEnd) .GroupBy(e => e.PaymentAccountId) .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var bpFromByAcct = await _context.BillPayments .Where(bp => bp.PaymentDate <= asOfEnd) .GroupBy(bp => bp.BankAccountId) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var billsByApAcct = await _context.Bills .Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) .GroupBy(b => b.APAccountId) .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var bpByApAcct = await _context.BillPayments .Where(bp => bp.PaymentDate <= asOfEnd) .GroupBy(bp => bp.Bill.APAccountId) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); // AP: vendor credit applications reduce AP (DR side) when matched against specific bills. var vcByApAcctBs = await _context.VendorCreditApplications .Where(vca => vca.AppliedDate <= asOfEnd) .GroupBy(vca => vca.VendorCredit.APAccountId) .Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var taxByAcct = await _context.Invoices .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .GroupBy(i => i.SalesTaxAccountId!.Value) .Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); var arDebits = await _context.Invoices .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.Total) ?? 0; var arCredits = await _context.Payments .Where(p => p.PaymentDate <= asOfEnd && p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.WrittenOff) .SumAsync(p => (decimal?)p.Amount) ?? 0; // Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice). arCredits += await _context.CreditMemoApplications .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0; // Refunds reverse collected payments — they re-open AR so reduce net AR credits. arCredits -= await _context.Refunds .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted) .SumAsync(r => (decimal?)r.Amount) ?? 0m; // Refunds by bank account: money that left the account (CR to checking/bank). var refundsByAcctBs = await _context.Refunds .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null) .GroupBy(r => r.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); // Deposits by bank account: cash received at deposit recording time (DR bank). var depositsByAcctDepBs = await _context.Deposits .Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd) .GroupBy(d => d.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amount); // Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices. var custDepositsAcctIdBs = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted) .Select(a => (int?)a.Id).FirstOrDefaultAsync(); var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue ? (await _context.Deposits .Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd) .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue ? (await _context.Deposits .Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd) .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; // Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. var gcLiabilityAcctIdBs = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted) .Select(a => (int?)a.Id).FirstOrDefaultAsync(); var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue ? (await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd) .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m; var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue ? ((await _context.GiftCertificateRedemptions .Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m) + (await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; // Retained earnings = net P&L from inception through asOf, covering four sources: // (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs, // plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts // (accruals, depreciation, year-end closes, and other adjustments not in the tables above). var lifetimeRevenue = await _context.InvoiceItems .Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; var lifetimeDiscounts = isCash ? 0m : (await _context.Invoices .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m); // Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts). var lifetimeCreditMemos = isCash ? 0m : (await _context.CreditMemoApplications .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m); var lifetimeDirectExp = await _context.Expenses .Where(e => e.Date <= asOfEnd) .SumAsync(e => (decimal?)e.Amount) ?? 0; var lifetimeBillCosts = await _context.BillLineItems .Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd) .SumAsync(bli => (decimal?)bli.Amount) ?? 0; // JE net effect on revenue accounts (positive = additional revenue recognised via manual JE) var revenueAcctIds = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted) .Select(a => a.Id).ToListAsync(); var expCogsAcctIds = await _context.Accounts .Where(a => a.CompanyId == companyId && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && !a.IsDeleted) .Select(a => a.Id).ToListAsync(); var jeRevNet = revenueAcctIds.Count > 0 ? (await _context.JournalEntryLines .Where(l => revenueAcctIds.Contains(l.AccountId) && l.JournalEntry.Status == JournalEntryStatus.Posted && l.JournalEntry.EntryDate <= asOfEnd) .SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m) : 0m; // JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE) var jeExpNet = expCogsAcctIds.Count > 0 ? (await _context.JournalEntryLines .Where(l => expCogsAcctIds.Contains(l.AccountId) && l.JournalEntry.Status == JournalEntryStatus.Posted && l.JournalEntry.EntryDate <= asOfEnd) .SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m) : 0m; // GC items sold via invoices are reclassified to GC Liability and not yet earned income. var lifetimeGcReclassified = await _context.InvoiceItems .Where(ii => ii.IsGiftCertificate && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m; // Voided GCs with remaining balance become breakage income (the liability is extinguished). var lifetimeGcBreakage = await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; var retainedEarnings = lifetimeRevenue + jeRevNet - lifetimeDiscounts - lifetimeCreditMemos - lifetimeGcReclassified // deferred to GC Liability, not earned yet + lifetimeGcBreakage // breakage income when GC voided with balance - lifetimeDirectExp - lifetimeBillCosts - jeExpNet; var accounts = await _context.Accounts .Where(a => a.IsActive) .OrderBy(a => a.AccountNumber) .ToListAsync(); // Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance. decimal ComputeBalance(Account a) { bool normalDebit = a.AccountType == AccountType.Asset; decimal debits = 0, credits = 0; if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; } else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP } else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value) { credits += gcLiabilityCreditsBs; // GC issued → CR liability debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability } if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value) { credits += custDepositsCreditsBs; // deposits taken → CR liability debits += custDepositsDebitsBs; // deposits applied → DR liability } } decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf) ? a.OpeningBalance : 0; decimal net = normalDebit ? debits - credits : credits - debits; return opening + net; } FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) }; var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList(); var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList(); var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList(); // Cash basis: AR and AP have no meaning (no receivables/payables concept) var currentAssets = assetAccts .Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset || (!isCash && a.AccountSubType == AccountSubType.AccountsReceivable)) .Select(ToLine).ToList(); var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList(); var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList(); var currentLiabilities = liabilityAccts .Where(a => a.AccountSubType is AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability || (!isCash && a.AccountSubType == AccountSubType.AccountsPayable)) .Select(ToLine).ToList(); var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList(); var equityLines = equityAccts.Select(ToLine).ToList(); var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount); var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount); var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings; return new BalanceSheetDto { AsOf = asOf, CompanyName = companyName, AccountingMethod = accountingMethod, CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets, CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities, EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity, }; } /// public async Task GetArAgingAsync(int companyId, DateTime asOf) { var asOfEnd = asOf.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var openInvoices = await _context.Invoices .Include(i => i.Customer) .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd && (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0) .OrderBy(i => i.Customer!.CompanyName) .ThenBy(i => i.DueDate) .ToListAsync(); static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" }; var customerDtos = new List(); foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial })) { var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim(); var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName }; foreach (var inv in grp) { var balance = inv.BalanceDue; var daysOverdue = inv.DueDate.HasValue ? (int)(asOf - inv.DueDate.Value.Date).TotalDays : 0; custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue }); switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; } } customerDtos.Add(custDto); } var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList(); return new ArAgingReportDto { AsOf = asOf, CompanyName = companyName, Customers = sorted, TotalCurrent = sorted.Sum(c => c.TotalCurrent), Total1to30 = sorted.Sum(c => c.Total1to30), Total31to60 = sorted.Sum(c => c.Total31to60), Total61to90 = sorted.Sum(c => c.Total61to90), TotalOver90 = sorted.Sum(c => c.TotalOver90), }; } /// public async Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to) { var toEnd = to.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var invoices = await _context.Invoices .Include(i => i.Customer) .Include(i => i.Payments) .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) .OrderBy(i => i.InvoiceDate) .ToListAsync(); var collectedInPeriod = await _context.Payments .Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd) .SumAsync(p => (decimal?)p.Amount) ?? 0; var byCustomer = invoices .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }) .Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue), }) .OrderByDescending(c => c.TotalInvoiced) .ToList(); var byMonth = invoices .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) .Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count(), }) .OrderBy(m => m.Year).ThenBy(m => m.Month) .ToList(); var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto { InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(), SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, }).ToList(); return new SalesIncomeReportDto { From = from, To = to, CompanyName = companyName, TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod, TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount), InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(), ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines, }; } /// public async Task GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to) { var toEnd = to.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var invoices = await _context.Invoices .Include(i => i.Customer) .Include(i => i.SalesTaxAccount) .Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) .AsNoTracking() .OrderBy(i => i.InvoiceDate) .ToListAsync(); var taxable = invoices.Where(i => i.TaxAmount > 0).ToList(); var nonTaxable = invoices.Where(i => i.TaxAmount == 0).ToList(); var byAccount = invoices .Where(i => i.TaxAmount > 0) .GroupBy(i => new { i.SalesTaxAccountId, AccountName = i.SalesTaxAccount?.Name ?? "Unassigned", AccountNumber = i.SalesTaxAccount?.AccountNumber ?? string.Empty }) .Select(g => new SalesTaxByAccountDto { AccountId = g.Key.SalesTaxAccountId, AccountName = g.Key.AccountName, AccountNumber = g.Key.AccountNumber, TaxableSales = g.Sum(i => i.SubTotal), TaxBilled = g.Sum(i => i.TaxAmount), InvoiceCount = g.Count() }) .OrderBy(a => a.AccountNumber) .ThenBy(a => a.AccountName) .ToList(); var byMonth = invoices .Where(i => i.TaxAmount > 0) .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) .Select(g => new SalesTaxByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TaxableSales = g.Sum(i => i.SubTotal), TaxBilled = g.Sum(i => i.TaxAmount), InvoiceCount = g.Count() }) .OrderBy(m => m.Year).ThenBy(m => m.Month) .ToList(); var invoiceLines = invoices.Select(i => new SalesTaxInvoiceLineDto { InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName ?? string.Empty : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), InvoiceDate = i.InvoiceDate, Status = i.Status.ToString(), SubTotal = i.SubTotal, TaxPercent = i.TaxPercent, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, TaxAccountName = i.SalesTaxAccount != null ? $"{i.SalesTaxAccount.AccountNumber} {i.SalesTaxAccount.Name}".Trim() : string.Empty }).ToList(); return new SalesTaxReportDto { From = from, To = to, CompanyName = companyName, TotalTaxableSales = taxable.Sum(i => i.SubTotal), TotalNonTaxableSales = nonTaxable.Sum(i => i.SubTotal), TotalTaxBilled = taxable.Sum(i => i.TaxAmount), TaxableInvoiceCount = taxable.Count, NonTaxableInvoiceCount = nonTaxable.Count, ByAccount = byAccount, ByMonth = byMonth, Invoices = invoiceLines }; } /// public async Task GetApAgingAsync(int companyId, DateTime asOf) { var asOfEnd = asOf.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var openBills = await _context.Bills .Include(b => b.Vendor) .Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.Status != BillStatus.Paid && b.BillDate <= asOfEnd && b.BalanceDue > 0) .OrderBy(b => b.Vendor!.CompanyName) .ThenBy(b => b.DueDate) .ToListAsync(); static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" }; var vendorDtos = new List(); foreach (var grp in openBills.GroupBy(b => new { b.VendorId, b.Vendor!.CompanyName })) { var vendDto = new ApAgingVendorDto { VendorId = grp.Key.VendorId, VendorName = grp.Key.CompanyName }; foreach (var bill in grp) { var balance = bill.BalanceDue; var daysOverdue = bill.DueDate.HasValue ? (int)(asOf - bill.DueDate.Value.Date).TotalDays : 0; vendDto.Bills.Add(new ApAgingBillDto { BillId = bill.Id, BillNumber = bill.BillNumber, BillDate = bill.BillDate, DueDate = bill.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue }); switch (AgingBucket(daysOverdue)) { case "current": vendDto.TotalCurrent += balance; break; case "1-30": vendDto.Total1to30 += balance; break; case "31-60": vendDto.Total31to60 += balance; break; case "61-90": vendDto.Total61to90 += balance; break; default: vendDto.TotalOver90 += balance; break; } } vendorDtos.Add(vendDto); } var sorted = vendorDtos.OrderByDescending(v => v.TotalBalance).ToList(); return new ApAgingReportDto { AsOf = asOf, CompanyName = companyName, Vendors = sorted, TotalCurrent = sorted.Sum(v => v.TotalCurrent), Total1to30 = sorted.Sum(v => v.Total1to30), Total31to60 = sorted.Sum(v => v.Total31to60), Total61to90 = sorted.Sum(v => v.Total61to90), TotalOver90 = sorted.Sum(v => v.TotalOver90), }; } /// /// /// Balances are computed dynamically from transaction tables using the same pre-computed /// dictionary approach as , so the /// date is respected. This replaces the previous implementation that read the denormalised /// Account.CurrentBalance field, which always reflected the current date regardless of /// what date was selected. /// public async Task GetTrialBalanceAsync(int companyId, DateTime asOf) { var asOfEnd = asOf.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); // ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ────── // Bank/cash: customer payments deposited here (DR) var depositsByAcct = await _context.Payments .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null && p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.WrittenOff) .GroupBy(p => p.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor // issues a credit note and it is matched against a specific bill. var vcByApAcct = await _context.VendorCreditApplications .Where(vca => vca.AppliedDate <= asOfEnd) .GroupBy(vca => vca.VendorCredit.APAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Bank/cash: expenses paid from here (CR) var expFromByAcct = await _context.Expenses .Where(e => e.Date <= asOfEnd) .GroupBy(e => e.PaymentAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Bank/cash: bill payments made from here (CR) var bpFromByAcct = await _context.BillPayments .Where(bp => bp.PaymentDate <= asOfEnd) .GroupBy(bp => bp.BankAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // AP: bills increase AP (CR) var billsByApAcct = await _context.Bills .Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) .GroupBy(b => b.APAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // AP: bill payments reduce AP (DR) var bpByApAcct = await _context.BillPayments .Where(bp => bp.PaymentDate <= asOfEnd) .GroupBy(bp => bp.Bill.APAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Tax liability: sales tax collected (CR) var taxByAcct = await _context.Invoices .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .GroupBy(i => i.SalesTaxAccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Revenue accounts: invoice line items (CR) var revenueByAcct = await _context.InvoiceItems .Where(ii => ii.RevenueAccountId != null && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) .GroupBy(ii => ii.RevenueAccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Expense accounts: direct expenses (DR) var expenseByAcct = await _context.Expenses .Where(e => e.Date <= asOfEnd) .GroupBy(e => e.ExpenseAccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Expense/COGS accounts: vendor bill line items (DR) var billLinesByAcct = await _context.BillLineItems .Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd) .GroupBy(bli => bli.AccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR). // Both reduce net revenue and are attributed to account 4950 as contra-revenue debits. // Credit memo applications are also added to AR credits below so the double-entry balances. var discountAcctId = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted) .Select(a => (int?)a.Id) .FirstOrDefaultAsync(); discountAcctId ??= await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount")) .Select(a => (int?)a.Id) .FirstOrDefaultAsync(); var cmApplied = await _context.CreditMemoApplications .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; var discountsByAcct = new Dictionary(); if (discountAcctId.HasValue) { var totalDiscounts = await _context.Invoices .Where(i => i.DiscountAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; if (totalDiscounts + cmApplied > 0) discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied; } // JE lines: posted entries debit/credit all account types var jeDebitsByAcct = await _context.JournalEntryLines .Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted && l.JournalEntry.EntryDate <= asOfEnd) .GroupBy(l => l.AccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); var jeCreditsByAcct = await _context.JournalEntryLines .Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted && l.JournalEntry.EntryDate <= asOfEnd) .GroupBy(l => l.AccountId) .Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // AR totals (single AR account assumed per standard small-business chart of accounts). // Credits include both cash payments and credit memo applications (which reduce open AR // when a customer credit is applied against a specific invoice). var arTotalDebits = await _context.Invoices .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.Total) ?? 0m; var arTotalCredits = await _context.Payments .Where(p => p.PaymentDate <= asOfEnd && p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.WrittenOff) .SumAsync(p => (decimal?)p.Amount) ?? 0m; arTotalCredits += cmApplied; // credit memo applications reduce AR balance // Refunds reverse collected payments — reduce net AR credits (re-opens the receivable). var refundTotal = await _context.Refunds .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted) .SumAsync(r => (decimal?)r.Amount) ?? 0m; arTotalCredits -= refundTotal; // Refunds by bank account: money leaving the account (CR to checking/bank). var refundsByAcct = await _context.Refunds .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null) .GroupBy(r => r.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Deposits by bank account: cash received at deposit recording time (DR bank). // Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct. var depositsByAcctDep = await _context.Deposits .Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd) .GroupBy(d => d.DepositAccountId!.Value) .Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) }) .ToDictionaryAsync(g => g.Id, g => g.Amt); // Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices. var custDepositsAcctId = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted) .Select(a => (int?)a.Id).FirstOrDefaultAsync(); var custDepositsCredits = custDepositsAcctId.HasValue ? (await _context.Deposits .Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd) .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; var custDepositsDebits = custDepositsAcctId.HasValue ? (await _context.Deposits .Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd) .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; // Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. var gcLiabilityAcctId = await _context.Accounts .Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted) .Select(a => (int?)a.Id).FirstOrDefaultAsync(); var gcLiabilityCredits = gcLiabilityAcctId.HasValue ? (await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd) .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m; var gcLiabilityDebits = gcLiabilityAcctId.HasValue ? ((await _context.GiftCertificateRedemptions .Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m) + (await _context.GiftCertificates .Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; // ── Per-account balance computation ───────────────────────────────────────────────── var accounts = await _context.Accounts .Where(a => a.CompanyId == companyId && a.IsActive) .OrderBy(a => a.AccountNumber) .ToListAsync(); decimal ComputeAsOfBalance(Account a) { bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType); decimal debits = 0m, credits = 0m; if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arTotalDebits; credits = arTotalCredits; } else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP } else { // All other accounts: sum contributions from each transaction source that can // post to this account. Dictionaries only contain entries for relevant account IDs, // so GetValueOrDefault returns 0 for sources that do not apply to this account type. debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); credits += revenueByAcct.GetValueOrDefault(a.Id); debits += expenseByAcct.GetValueOrDefault(a.Id); debits += billLinesByAcct.GetValueOrDefault(a.Id); debits += discountsByAcct.GetValueOrDefault(a.Id); credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value) { credits += gcLiabilityCredits; // GC issued → CR liability debits += gcLiabilityDebits; // redeemed/voided → DR liability } if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value) { credits += custDepositsCredits; // deposits taken → CR liability debits += custDepositsDebits; // deposits applied → DR liability } } // Manual JEs apply to all account types (including AR/AP for unusual adjustments) debits += jeDebitsByAcct.GetValueOrDefault(a.Id); credits += jeCreditsByAcct.GetValueOrDefault(a.Id); decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf) ? a.OpeningBalance : 0m; decimal net = isDebitNormal ? debits - credits : credits - debits; return opening + net; } var lines = new List(); foreach (var acct in accounts) { var balance = ComputeAsOfBalance(acct); if (balance == 0m) continue; var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType); var line = new TrialBalanceLine { AccountId = acct.Id, AccountNumber = acct.AccountNumber, AccountName = acct.Name, AccountType = acct.AccountType }; if (isDebitNormal) { // Normal debit: positive balance → Debit column; negative → Credit column (abnormal) if (balance >= 0m) line.DebitBalance = balance; else line.CreditBalance = -balance; } else { // Normal credit: positive balance → Credit column; negative → Debit column (abnormal) if (balance >= 0m) line.CreditBalance = balance; else line.DebitBalance = -balance; } lines.Add(line); } return new TrialBalanceDto { AsOf = asOf, CompanyName = companyName, Lines = lines, TotalDebits = lines.Sum(l => l.DebitBalance), TotalCredits = lines.Sum(l => l.CreditBalance), }; } /// public async Task GetCompanyAccountingMethodAsync(int companyId) { if (companyId <= 0) return AccountingMethod.Accrual; var method = await _context.Companies .Where(c => c.Id == companyId) .Select(c => (AccountingMethod?)c.AccountingMethod) .FirstOrDefaultAsync(); return method ?? AccountingMethod.Accrual; } /// public async Task GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to) { var toEnd = to.AddDays(1).AddTicks(-1); var fromEnd = from.AddTicks(-1); // exclusive upper bound for pre-period queries var companyName = await GetCompanyNameAsync(companyId); var customer = await _context.Customers .Where(c => c.Id == customerId && c.CompanyId == companyId) .AsNoTracking().FirstOrDefaultAsync(); if (customer == null) return new CustomerStatementDto { CompanyName = companyName, From = from, To = to }; var customerName = customer.IsCommercial ? customer.CompanyName ?? string.Empty : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); var address = string.Join(", ", new[] { customer.Address, customer.City, customer.State, customer.ZipCode } .Where(s => !string.IsNullOrWhiteSpace(s))); // Opening balance: invoiced − paid before period start var preInvoiced = await _context.Invoices .Where(i => i.CustomerId == customerId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate < from) .SumAsync(i => (decimal?)i.Total) ?? 0; var prePaid = await _context.Payments .Where(p => p.Invoice.CustomerId == customerId && p.Invoice.Status != InvoiceStatus.Voided && p.PaymentDate < from) .SumAsync(p => (decimal?)p.Amount) ?? 0; var preCredits = await _context.CreditMemoApplications .Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0; var openingBalance = preInvoiced - prePaid - preCredits; // In-period activity — gather, then sort, then compute running balance var lines = new List(); var periodInvoices = await _context.Invoices .Where(i => i.CustomerId == customerId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var inv in periodInvoices) lines.Add(new StatementLineDto { Date = inv.InvoiceDate, Type = "Invoice", Reference = inv.InvoiceNumber, Description = "Invoice", Debit = inv.Total, }); var periodPayments = await _context.Payments .Include(p => p.Invoice) .Where(p => p.Invoice.CustomerId == customerId && p.Invoice.Status != InvoiceStatus.Voided && p.PaymentDate >= from && p.PaymentDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var pay in periodPayments) lines.Add(new StatementLineDto { Date = pay.PaymentDate, Type = "Payment", Reference = pay.Invoice.InvoiceNumber, Description = pay.Notes ?? "Payment received", Credit = pay.Amount, }); var periodCredits = await _context.CreditMemoApplications .Include(a => a.Invoice) .Include(a => a.CreditMemo) .Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate >= from && a.AppliedDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var cr in periodCredits) lines.Add(new StatementLineDto { Date = cr.AppliedDate, Type = "Credit Applied", Reference = cr.Invoice?.InvoiceNumber ?? string.Empty, Description = $"Credit memo applied", Credit = cr.AmountApplied, }); // Sort by date then compute running balance lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList(); var running = openingBalance; foreach (var line in lines) { running += (line.Debit ?? 0) - (line.Credit ?? 0); line.RunningBalance = running; } return new CustomerStatementDto { CustomerId = customerId, CustomerName = customerName, CustomerAddress = address, CompanyName = companyName, From = from, To = to, OpeningBalance = openingBalance, Lines = lines, ClosingBalance = running, }; } /// public async Task GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to) { var toEnd = to.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var vendor = await _context.Vendors .Where(v => v.Id == vendorId && v.CompanyId == companyId) .AsNoTracking().FirstOrDefaultAsync(); if (vendor == null) return new VendorStatementDto { CompanyName = companyName, From = from, To = to }; // Opening balance: bills − payments − credits before period start var preBills = await _context.Bills .Where(b => b.VendorId == vendorId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate < from) .SumAsync(b => (decimal?)b.Total) ?? 0; var prePayments = await _context.BillPayments .Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from) .SumAsync(bp => (decimal?)bp.Amount) ?? 0; var preVcApplied = await _context.VendorCreditApplications .Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from) .SumAsync(vca => (decimal?)vca.Amount) ?? 0; var openingBalance = preBills - prePayments - preVcApplied; var lines = new List(); var periodBills = await _context.Bills .Where(b => b.VendorId == vendorId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate >= from && b.BillDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var bill in periodBills) lines.Add(new StatementLineDto { Date = bill.BillDate, Type = "Bill", Reference = bill.BillNumber, Description = bill.Memo ?? "Vendor bill", Debit = bill.Total, }); var periodPayments = await _context.BillPayments .Include(bp => bp.Bill) .Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate >= from && bp.PaymentDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var pay in periodPayments) lines.Add(new StatementLineDto { Date = pay.PaymentDate, Type = "Payment", Reference = pay.Bill.BillNumber, Description = pay.Memo ?? "Bill payment", Credit = pay.Amount, }); var periodVcApplied = await _context.VendorCreditApplications .Include(vca => vca.VendorCredit) .Include(vca => vca.Bill) .Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate >= from && vca.AppliedDate <= toEnd) .AsNoTracking().ToListAsync(); foreach (var vca in periodVcApplied) lines.Add(new StatementLineDto { Date = vca.AppliedDate, Type = "Credit Applied", Reference = vca.VendorCredit.CreditNumber, Description = $"Vendor credit applied to {vca.Bill.BillNumber}", Credit = vca.Amount, }); lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList(); var running = openingBalance; foreach (var line in lines) { running += (line.Debit ?? 0) - (line.Credit ?? 0); line.RunningBalance = running; } return new VendorStatementDto { VendorId = vendorId, VendorName = vendor.CompanyName, CompanyName = companyName, From = from, To = to, OpeningBalance = openingBalance, Lines = lines, ClosingBalance = running, }; } /// /// /// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method /// for operating activities: /// /// CashFromCustomers — sum of amounts in the period. /// CashToVendors — sum of amounts in the period. /// CashForExpenses — sum of amounts in the period. /// /// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows /// prior to . This is an approximation when cash accounts have /// an OpeningBalance; it is the most accurate representation available without a dedicated /// cash-tracking journal. /// Investing and Financing sections are populated from the expense/asset account ledger /// (FixedAsset purchases from Expense entries whose account is FixedAsset subtype) and /// equity account changes respectively. /// public async Task GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to) { var toEnd = to.Date.AddDays(1).AddTicks(-1); var companyName = await GetCompanyNameAsync(companyId); var method = await GetCompanyAccountingMethodAsync(companyId); // ── Operating — direct / cash ────────────────────────────────────── var cashFromCustomers = await _context.Payments .IgnoreQueryFilters() .Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate >= from && p.PaymentDate <= toEnd) .SumAsync(p => (decimal?)p.Amount) ?? 0m; var cashToVendors = await _context.BillPayments .IgnoreQueryFilters() .Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate >= from && bp.PaymentDate <= toEnd) .SumAsync(bp => (decimal?)bp.Amount) ?? 0m; var cashForExpenses = await _context.Expenses .IgnoreQueryFilters() .Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date >= from && e.Date <= toEnd) .SumAsync(e => (decimal?)e.Amount) ?? 0m; // ── Investing — fixed-asset purchases from Expense entries ───────── var fixedAssetAccountIds = await _context.Accounts .IgnoreQueryFilters() .Where(a => a.CompanyId == companyId && !a.IsDeleted && a.AccountSubType == AccountSubType.FixedAsset) .Select(a => a.Id) .ToListAsync(); var capEx = fixedAssetAccountIds.Count > 0 ? (await _context.Expenses .IgnoreQueryFilters() .Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date >= from && e.Date <= toEnd && fixedAssetAccountIds.Contains(e.ExpenseAccountId)) .SumAsync(e => (decimal?)e.Amount) ?? 0m) : 0m; var investingLines = new List(); if (capEx != 0m) investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx }); // ── Financing — placeholder (equity changes not explicitly tracked) ─ var financingLines = new List(); // ── Beginning cash ───────────────────────────────────────────────── // Cash account opening balances + pre-period payments in - pre-period payments out var cashAccountOpeningBalance = await _context.Accounts .IgnoreQueryFilters() .Where(a => a.CompanyId == companyId && !a.IsDeleted && (a.AccountSubType == AccountSubType.Cash || a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Savings)) .SumAsync(a => (decimal?)a.OpeningBalance) ?? 0m; var prePaymentsIn = await _context.Payments .IgnoreQueryFilters() .Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate < from) .SumAsync(p => (decimal?)p.Amount) ?? 0m; var preBillPaymentsOut = await _context.BillPayments .IgnoreQueryFilters() .Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate < from) .SumAsync(bp => (decimal?)bp.Amount) ?? 0m; var preExpensesOut = await _context.Expenses .IgnoreQueryFilters() .Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date < from) .SumAsync(e => (decimal?)e.Amount) ?? 0m; var beginningCash = cashAccountOpeningBalance + prePaymentsIn - preBillPaymentsOut - preExpensesOut; return new CashFlowStatementDto { CompanyName = companyName, From = from, To = to, Method = method, CashFromCustomers = cashFromCustomers, CashToVendors = cashToVendors, CashForExpenses = cashForExpenses, InvestingLines = investingLines, FinancingLines = financingLines, BeginningCash = beginningCash, }; } /// /// Looks up the company name by ID for report headers and AI prompt injection. /// Falls back to "Your Company" if the record is not found. /// private async Task GetCompanyNameAsync(int companyId) { if (companyId <= 0) return "Your Company"; var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId); return company?.CompanyName ?? "Your Company"; } }