Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
T
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

1484 lines
71 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Implements financial aggregate reports (P&amp;L, Balance Sheet, AR Aging, Sales &amp; 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 <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class FinancialReportService : IFinancialReportService
{
private readonly ApplicationDbContext _context;
public FinancialReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<ProfitAndLossDto> 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<FinancialReportLine>();
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<int, decimal>();
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<FinancialReportLine>();
var expenseLines = new List<FinancialReportLine>();
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),
};
}
/// <inheritdoc/>
public async Task<BalanceSheetDto> 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,
};
}
/// <inheritdoc/>
public async Task<ArAgingReportDto> 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<ArAgingCustomerDto>();
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),
};
}
/// <inheritdoc/>
public async Task<SalesIncomeReportDto> 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,
};
}
/// <inheritdoc/>
public async Task<SalesTaxReportDto> 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
};
}
/// <inheritdoc/>
public async Task<ApAgingReportDto> 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<ApAgingVendorDto>();
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),
};
}
/// <inheritdoc/>
/// <remarks>
/// Balances are computed dynamically from transaction tables using the same pre-computed
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
/// date is respected. This replaces the previous implementation that read the denormalised
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
/// what date was selected.
/// </remarks>
public async Task<TrialBalanceDto> 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<int, decimal>();
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<TrialBalanceLine>();
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),
};
}
/// <inheritdoc/>
public async Task<AccountingMethod> 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;
}
/// <inheritdoc/>
public async Task<CustomerStatementDto> 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<StatementLineDto>();
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,
};
}
/// <inheritdoc/>
public async Task<VendorStatementDto> 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<StatementLineDto>();
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,
};
}
/// <inheritdoc/>
/// <summary>
/// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method
/// for operating activities:
/// <list type="bullet">
/// <item><b>CashFromCustomers</b> — sum of <see cref="Payment"/> amounts in the period.</item>
/// <item><b>CashToVendors</b> — sum of <see cref="BillPayment"/> amounts in the period.</item>
/// <item><b>CashForExpenses</b> — sum of <see cref="Expense"/> amounts in the period.</item>
/// </list>
/// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows
/// prior to <paramref name="from"/>. 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.
/// </summary>
public async Task<CashFlowStatementDto> 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<CashFlowLineDto>();
if (capEx != 0m)
investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx });
// ── Financing — placeholder (equity changes not explicitly tracked) ─
var financingLines = new List<CashFlowLineDto>();
// ── 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,
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> GetCompanyNameAsync(int companyId)
{
if (companyId <= 0) return "Your Company";
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
return company?.CompanyName ?? "Your Company";
}
}