Phase H: Add Cash Flow Statement (direct / cash-basis method)

- CashFlowStatementDto (Operating, Investing, Financing sections; BeginningCash/EndingCash)
- CashFlowLineDto for Investing/Financing line items
- GetCashFlowStatementAsync on IFinancialReportService + implementation in FinancialReportService
- GenerateCashFlowStatementPdfAsync on IPdfService + QuestPDF implementation in PdfService
- ReportsController.CashFlowStatement GET + CashFlowStatementPdf GET with inline/download mode
- CashFlowStatement.cshtml view with date filter, 3-section cards, summary sidebar, methodology note
- Reports Landing page: Cash Flow Statement card added to Accounting section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:14:47 -04:00
parent 42eff3357e
commit 14026818e2
8 changed files with 546 additions and 0 deletions
@@ -925,6 +925,114 @@ public class FinancialReportService : IFinancialReportService
};
}
/// <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.