27bfd4db4d
- 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>
1484 lines
71 KiB
C#
1484 lines
71 KiB
C#
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&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 <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";
|
||
}
|
||
}
|