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
@@ -1255,6 +1255,42 @@ public class ReportsController : Controller
: File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
}
/// <summary>
/// Cash Flow Statement — shows cash receipts from customers, cash payments to vendors and
/// for direct expenses, and a summary of beginning/ending cash position. Uses the direct
/// (cash-basis) method for operating activities so the numbers reflect actual cash movement
/// regardless of the company's accrual vs cash accounting preference.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/CashFlowStatement
public async Task<IActionResult> CashFlowStatement(DateTime? from, DateTime? to)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
return View(dto);
}
/// <summary>
/// PDF export of the Cash Flow Statement. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/CashFlowStatementPdf
public async Task<IActionResult> CashFlowStatementPdf(DateTime? from, DateTime? to, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateCashFlowStatementPdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"CashFlow-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
/// <summary>
@@ -0,0 +1,224 @@
@model PowderCoating.Application.DTOs.Accounting.CashFlowStatementDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Cash Flow Statement";
string AmountClass(decimal v) => v < 0 ? "text-danger" : "text-body";
string Fmt(decimal v) => v.ToString("C");
}
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h4 class="fw-bold mb-0"><i class="bi bi-water me-2 text-info"></i>Cash Flow Statement</h4>
<p class="text-muted small mb-0">
@Model.From.ToString("MMMM d, yyyy") @Model.To.ToString("MMMM d, yyyy")
&nbsp;·&nbsp; Direct Method (Cash Basis)
</p>
</div>
<div class="d-flex gap-2">
<a asp-action="CashFlowStatementPdf"
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>PDF
</a>
<a asp-action="CashFlowStatementPdf"
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
asp-route-inline="true"
target="_blank"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye me-1"></i>Preview
</a>
</div>
</div>
<!-- Date range filter -->
<form method="get" asp-action="CashFlowStatement" class="card shadow-sm mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label fw-semibold small">From</label>
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<label class="form-label fw-semibold small">To</label>
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Update</button>
</div>
<!-- Quick date presets -->
@{
var y = DateTime.Today.Year;
var presets = new[]
{
("YTD", new DateTime(y, 1, 1).ToString("yyyy-MM-dd"), DateTime.Today.ToString("yyyy-MM-dd")),
("This Qtr", new DateTime(y, ((DateTime.Today.Month - 1) / 3) * 3 + 1, 1).ToString("yyyy-MM-dd"), DateTime.Today.ToString("yyyy-MM-dd")),
("Last Year", new DateTime(y-1, 1, 1).ToString("yyyy-MM-dd"), new DateTime(y-1, 12, 31).ToString("yyyy-MM-dd")),
};
}
@foreach (var (label, f, t) in presets)
{
<div class="col-auto">
<a asp-action="CashFlowStatement" asp-route-from="@f" asp-route-to="@t"
class="btn btn-sm btn-outline-secondary">@label</a>
</div>
}
</div>
</div>
</form>
<div class="row g-4">
<!-- Main statement -->
<div class="col-lg-8">
<!-- Operating Activities -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold"><i class="bi bi-gear me-2 text-info"></i>Operating Activities</span>
<span class="badge @(Model.NetOperating >= 0 ? "bg-success" : "bg-danger")">@Fmt(Model.NetOperating)</span>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<td class="ps-3 text-body-secondary">Cash received from customers</td>
<td class="text-end pe-3 text-success fw-semibold">@Fmt(Model.CashFromCustomers)</td>
</tr>
<tr>
<td class="ps-3 text-body-secondary">Cash paid to vendors (bills)</td>
<td class="text-end pe-3 @AmountClass(-Model.CashToVendors)">(@Fmt(Model.CashToVendors))</td>
</tr>
<tr>
<td class="ps-3 text-body-secondary">Cash paid for direct expenses</td>
<td class="text-end pe-3 @AmountClass(-Model.CashForExpenses)">(@Fmt(Model.CashForExpenses))</td>
</tr>
</tbody>
<tfoot class="table-light">
<tr>
<td class="ps-3 fw-semibold">Net Cash from Operating Activities</td>
<td class="text-end pe-3 fw-bold @AmountClass(Model.NetOperating)">@Fmt(Model.NetOperating)</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Investing Activities -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold"><i class="bi bi-building me-2 text-primary"></i>Investing Activities</span>
<span class="badge @(Model.NetInvesting >= 0 ? "bg-success" : "bg-danger")">@Fmt(Model.NetInvesting)</span>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
@if (!Model.InvestingLines.Any())
{
<tr>
<td class="ps-3 text-muted" colspan="2">
<i class="bi bi-dash-circle me-1"></i>No investing activities recorded in this period.
</td>
</tr>
}
else
{
@foreach (var line in Model.InvestingLines)
{
<tr>
<td class="ps-3 text-body-secondary">@line.Label</td>
<td class="text-end pe-3 @AmountClass(line.Amount)">@Fmt(line.Amount)</td>
</tr>
}
}
</tbody>
<tfoot class="table-light">
<tr>
<td class="ps-3 fw-semibold">Net Cash from Investing Activities</td>
<td class="text-end pe-3 fw-bold @AmountClass(Model.NetInvesting)">@Fmt(Model.NetInvesting)</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Financing Activities -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold"><i class="bi bi-bank me-2 text-secondary"></i>Financing Activities</span>
<span class="badge @(Model.NetFinancing >= 0 ? "bg-success" : "bg-danger")">@Fmt(Model.NetFinancing)</span>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
@if (!Model.FinancingLines.Any())
{
<tr>
<td class="ps-3 text-muted" colspan="2">
<i class="bi bi-dash-circle me-1"></i>No financing activities recorded in this period.
</td>
</tr>
}
else
{
@foreach (var line in Model.FinancingLines)
{
<tr>
<td class="ps-3 text-body-secondary">@line.Label</td>
<td class="text-end pe-3 @AmountClass(line.Amount)">@Fmt(line.Amount)</td>
</tr>
}
}
</tbody>
<tfoot class="table-light">
<tr>
<td class="ps-3 fw-semibold">Net Cash from Financing Activities</td>
<td class="text-end pe-3 fw-bold @AmountClass(Model.NetFinancing)">@Fmt(Model.NetFinancing)</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<!-- Summary sidebar -->
<div class="col-lg-4">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold"><i class="bi bi-calculator me-2 text-info"></i>Cash Summary</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-8 text-muted small fw-normal">Beginning Cash</dt>
<dd class="col-4 text-end fw-semibold mb-2">@Fmt(Model.BeginningCash)</dd>
<dt class="col-8 text-muted small fw-normal">Operating</dt>
<dd class="col-4 text-end fw-semibold mb-1 @AmountClass(Model.NetOperating)">@Fmt(Model.NetOperating)</dd>
<dt class="col-8 text-muted small fw-normal">Investing</dt>
<dd class="col-4 text-end fw-semibold mb-1 @AmountClass(Model.NetInvesting)">@Fmt(Model.NetInvesting)</dd>
<dt class="col-8 text-muted small fw-normal">Financing</dt>
<dd class="col-4 text-end fw-semibold mb-2 @AmountClass(Model.NetFinancing)">@Fmt(Model.NetFinancing)</dd>
<dt class="col-8 text-muted small fw-normal">Net Change in Cash</dt>
<dd class="col-4 text-end fw-semibold mb-3 @AmountClass(Model.NetChangeInCash)">@Fmt(Model.NetChangeInCash)</dd>
<dt class="col-8 fw-bold">Ending Cash Balance</dt>
<dd class="col-4 text-end fw-bold fs-5 @AmountClass(Model.EndingCash)">@Fmt(Model.EndingCash)</dd>
</dl>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header fw-semibold small"><i class="bi bi-info-circle me-2"></i>Methodology</div>
<div class="card-body small text-muted">
<p class="mb-2">This statement uses the <strong>direct (cash basis)</strong> method for Operating Activities:</p>
<ul class="mb-2 ps-3">
<li>Inflows = customer invoice payments received</li>
<li>Outflows = vendor bill payments + direct expense payments</li>
</ul>
<p class="mb-0">Beginning Cash is approximated from all cash inflows and outflows recorded prior to the start date plus account opening balances. For the most accurate beginning balance, reconcile your bank accounts first.</p>
</div>
</div>
</div>
</div>
@@ -204,6 +204,14 @@
<p>All active accounts with debit and credit balances — validates that your books are in balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CashFlowStatement" class="report-card">
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
<i class="bi bi-water"></i>
</div>
<h5>Cash Flow Statement</h5>
<p>Track actual cash in/out across operating, investing, and financing activities with beginning and ending cash balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}