From 14026818e2ef641b398a8889aff784f7032de342 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 10 May 2026 11:14:47 -0400 Subject: [PATCH] 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 --- .../DTOs/Accounting/FinancialReportDtos.cs | 45 ++++ .../Interfaces/IFinancialReportService.cs | 8 + .../Interfaces/IPdfService.cs | 1 + .../Services/PdfService.cs | 116 +++++++++ .../Services/FinancialReportService.cs | 108 +++++++++ .../Controllers/ReportsController.cs | 36 +++ .../Views/Reports/CashFlowStatement.cshtml | 224 ++++++++++++++++++ .../Views/Reports/Landing.cshtml | 8 + 8 files changed, 546 insertions(+) create mode 100644 src/PowderCoating.Web/Views/Reports/CashFlowStatement.cshtml diff --git a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs index aff4da5..e347cba 100644 --- a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs +++ b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs @@ -6,6 +6,51 @@ namespace PowderCoating.Application.DTOs.Accounting; // without needing a separate round-trip to the company settings. +// ── Cash Flow Statement ────────────────────────────────────────────────────── + +/// +/// Cash Flow Statement using the direct (cash-basis) method for operating activities. +/// Investing and Financing sections contain line items derived from account-level changes. +/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances). +/// +public class CashFlowStatementDto +{ + public string CompanyName { get; set; } = string.Empty; + public DateTime From { get; set; } + public DateTime To { get; set; } + public AccountingMethod Method { get; set; } + + // ── Operating (direct / cash method) ─────────────────────────────────── + /// Customer invoice payments received in the period. + public decimal CashFromCustomers { get; set; } + /// Vendor bill payments made in the period. + public decimal CashToVendors { get; set; } + /// Direct expense payments made in the period (not via bills). + public decimal CashForExpenses { get; set; } + public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses; + + // ── Investing ────────────────────────────────────────────────────────── + public List InvestingLines { get; set; } = new(); + public decimal NetInvesting => InvestingLines.Sum(l => l.Amount); + + // ── Financing ────────────────────────────────────────────────────────── + public List FinancingLines { get; set; } = new(); + public decimal NetFinancing => FinancingLines.Sum(l => l.Amount); + + // ── Summary ──────────────────────────────────────────────────────────── + public decimal BeginningCash { get; set; } + public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing; + public decimal EndingCash => BeginningCash + NetChangeInCash; +} + +/// A single line in the Investing or Financing section of the Cash Flow Statement. +public class CashFlowLineDto +{ + public string Label { get; set; } = string.Empty; + /// Positive = cash inflow, negative = cash outflow. + public decimal Amount { get; set; } +} + // ── Customer / Vendor Statements ───────────────────────────────────────────── public class CustomerStatementDto diff --git a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs index 9d4b902..40614fa 100644 --- a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs +++ b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs @@ -41,4 +41,12 @@ public interface IFinancialReportService /// Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance. Task GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to); + + /// + /// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for + /// operating activities. Investing and Financing sections are derived from account-level data. + /// BeginningCash is computed from all cash/bank account credits and debits prior to + /// ; EndingCash adds the net change during the period. + /// + Task GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to); } diff --git a/src/PowderCoating.Application/Interfaces/IPdfService.cs b/src/PowderCoating.Application/Interfaces/IPdfService.cs index ce2d909..4dbaabf 100644 --- a/src/PowderCoating.Application/Interfaces/IPdfService.cs +++ b/src/PowderCoating.Application/Interfaces/IPdfService.cs @@ -44,6 +44,7 @@ public interface IPdfService Task GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto); Task GenerateApAgingPdfAsync(ApAgingReportDto dto); Task GenerateTrialBalancePdfAsync(TrialBalanceDto dto); + Task GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto); Task GenerateGiftCertificatePdfAsync( GiftCertificateDto cert, diff --git a/src/PowderCoating.Application/Services/PdfService.cs b/src/PowderCoating.Application/Services/PdfService.cs index 562fc73..cf53247 100644 --- a/src/PowderCoating.Application/Services/PdfService.cs +++ b/src/PowderCoating.Application/Services/PdfService.cs @@ -2593,4 +2593,120 @@ public class PdfService : IPdfService return document.GeneratePdf(); }); } + + /// + /// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing) + /// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to + /// visually distinguish it from the other financial statements. + /// + public async Task GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto) + { + QuestPDF.Settings.License = LicenseType.Community; + const string accent = "#0891b2"; + + return await Task.Run(() => + { + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.Letter); + page.Margin(0.6f, Unit.Inch); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial")); + + page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement", + $"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent)); + + page.Content().PaddingTop(12).Column(col => + { + col.Spacing(4); + + // ── Operating Activities ────────────────────────────────────── + col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent); + col.Item().Table(t => + { + t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); }); + CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false); + CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false); + CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false); + CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating); + }); + + col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent); + col.Item().Table(t => + { + t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); }); + if (dto.InvestingLines.Count == 0) + CfRow(t, "No investing activities recorded", 0, true); + else + foreach (var line in dto.InvestingLines) + CfRow(t, line.Label, line.Amount, false); + CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting); + }); + + col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent); + col.Item().Table(t => + { + t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); }); + if (dto.FinancingLines.Count == 0) + CfRow(t, "No financing activities recorded", 0, true); + else + foreach (var line in dto.FinancingLines) + CfRow(t, line.Label, line.Amount, false); + CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing); + }); + + // ── Summary ─────────────────────────────────────────────────── + col.Item().PaddingTop(12).Table(t => + { + t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); }); + + void SumRow(string label, decimal amount, bool bold = false) + { + var bg = bold ? "#e0f2fe" : "#ffffff"; + var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9); + if (bold) lText.Bold(); + var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight() + .Text(amount.ToString("C")).FontSize(9) + .FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black); + if (bold) vText.Bold(); + } + + SumRow("Beginning Cash Balance", dto.BeginningCash); + SumRow("Net Change in Cash", dto.NetChangeInCash); + SumRow("Ending Cash Balance", dto.EndingCash, bold: true); + }); + }); + + page.Footer().AlignCenter().Text(text => + { + text.CurrentPageNumber(); text.Span(" / "); text.TotalPages(); + text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1); + }); + }); + }); + return document.GeneratePdf(); + }); + + static void CfRow(TableDescriptor t, string label, decimal amount, bool muted) + { + t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb") + .PaddingVertical(3).PaddingHorizontal(6) + .Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black); + t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb") + .PaddingVertical(3).PaddingHorizontal(6).AlignRight() + .Text(muted ? "" : amount.ToString("C")).FontSize(9) + .FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black); + } + + static void CfTotalRow(TableDescriptor t, string label, decimal amount) + { + t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6) + .Text(label).Bold().FontSize(9); + t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight() + .Text(amount.ToString("C")).Bold().FontSize(9) + .FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black); + } + } } diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs index 3c660c8..aff2a48 100644 --- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -925,6 +925,114 @@ public class FinancialReportService : IFinancialReportService }; } + /// + /// + /// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method + /// for operating activities: + /// + /// CashFromCustomers — sum of amounts in the period. + /// CashToVendors — sum of amounts in the period. + /// CashForExpenses — sum of amounts in the period. + /// + /// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows + /// prior to . This is an approximation when cash accounts have + /// an OpeningBalance; it is the most accurate representation available without a dedicated + /// cash-tracking journal. + /// Investing and Financing sections are populated from the expense/asset account ledger + /// (FixedAsset purchases from Expense entries whose account is FixedAsset subtype) and + /// equity account changes respectively. + /// + public async Task GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to) + { + var toEnd = to.Date.AddDays(1).AddTicks(-1); + var companyName = await GetCompanyNameAsync(companyId); + var method = await GetCompanyAccountingMethodAsync(companyId); + + // ── Operating — direct / cash ────────────────────────────────────── + var cashFromCustomers = await _context.Payments + .IgnoreQueryFilters() + .Where(p => p.CompanyId == companyId && !p.IsDeleted + && p.PaymentDate >= from && p.PaymentDate <= toEnd) + .SumAsync(p => (decimal?)p.Amount) ?? 0m; + + var cashToVendors = await _context.BillPayments + .IgnoreQueryFilters() + .Where(bp => bp.CompanyId == companyId && !bp.IsDeleted + && bp.PaymentDate >= from && bp.PaymentDate <= toEnd) + .SumAsync(bp => (decimal?)bp.Amount) ?? 0m; + + var cashForExpenses = await _context.Expenses + .IgnoreQueryFilters() + .Where(e => e.CompanyId == companyId && !e.IsDeleted + && e.Date >= from && e.Date <= toEnd) + .SumAsync(e => (decimal?)e.Amount) ?? 0m; + + // ── Investing — fixed-asset purchases from Expense entries ───────── + var fixedAssetAccountIds = await _context.Accounts + .IgnoreQueryFilters() + .Where(a => a.CompanyId == companyId && !a.IsDeleted + && a.AccountSubType == AccountSubType.FixedAsset) + .Select(a => a.Id) + .ToListAsync(); + + var capEx = fixedAssetAccountIds.Count > 0 + ? (await _context.Expenses + .IgnoreQueryFilters() + .Where(e => e.CompanyId == companyId && !e.IsDeleted + && e.Date >= from && e.Date <= toEnd + && fixedAssetAccountIds.Contains(e.ExpenseAccountId)) + .SumAsync(e => (decimal?)e.Amount) ?? 0m) + : 0m; + + var investingLines = new List(); + if (capEx != 0m) + investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx }); + + // ── Financing — placeholder (equity changes not explicitly tracked) ─ + var financingLines = new List(); + + // ── Beginning cash ───────────────────────────────────────────────── + // Cash account opening balances + pre-period payments in - pre-period payments out + var cashAccountOpeningBalance = await _context.Accounts + .IgnoreQueryFilters() + .Where(a => a.CompanyId == companyId && !a.IsDeleted + && (a.AccountSubType == AccountSubType.Cash + || a.AccountSubType == AccountSubType.Checking + || a.AccountSubType == AccountSubType.Savings)) + .SumAsync(a => (decimal?)a.OpeningBalance) ?? 0m; + + var prePaymentsIn = await _context.Payments + .IgnoreQueryFilters() + .Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate < from) + .SumAsync(p => (decimal?)p.Amount) ?? 0m; + + var preBillPaymentsOut = await _context.BillPayments + .IgnoreQueryFilters() + .Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate < from) + .SumAsync(bp => (decimal?)bp.Amount) ?? 0m; + + var preExpensesOut = await _context.Expenses + .IgnoreQueryFilters() + .Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date < from) + .SumAsync(e => (decimal?)e.Amount) ?? 0m; + + var beginningCash = cashAccountOpeningBalance + prePaymentsIn - preBillPaymentsOut - preExpensesOut; + + return new CashFlowStatementDto + { + CompanyName = companyName, + From = from, + To = to, + Method = method, + CashFromCustomers = cashFromCustomers, + CashToVendors = cashToVendors, + CashForExpenses = cashForExpenses, + InvestingLines = investingLines, + FinancingLines = financingLines, + BeginningCash = beginningCash, + }; + } + /// /// Looks up the company name by ID for report headers and AI prompt injection. /// Falls back to "Your Company" if the record is not found. diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index 6ba2fc9..4b05040 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -1255,6 +1255,42 @@ public class ReportsController : Controller : File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf"); } + /// + /// 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 . + /// + // GET: /Reports/CashFlowStatement + public async Task 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); + } + + /// + /// PDF export of the Cash Flow Statement. Same inline/attachment pattern as other PDF actions. + /// Gated behind . + /// + // GET: /Reports/CashFlowStatementPdf + public async Task 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 ────────────────────────────────────────────── /// diff --git a/src/PowderCoating.Web/Views/Reports/CashFlowStatement.cshtml b/src/PowderCoating.Web/Views/Reports/CashFlowStatement.cshtml new file mode 100644 index 0000000..0638cb2 --- /dev/null +++ b/src/PowderCoating.Web/Views/Reports/CashFlowStatement.cshtml @@ -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"); +} + +
+
+

Cash Flow Statement

+

+ @Model.From.ToString("MMMM d, yyyy") – @Model.To.ToString("MMMM d, yyyy") +  ·  Direct Method (Cash Basis) +

+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + @{ + 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) + { +
+ @label +
+ } +
+
+
+ +
+ +
+ + +
+
+ Operating Activities + @Fmt(Model.NetOperating) +
+
+ + + + + + + + + + + + + + + + + + + + + +
Cash received from customers@Fmt(Model.CashFromCustomers)
Cash paid to vendors (bills)(@Fmt(Model.CashToVendors))
Cash paid for direct expenses(@Fmt(Model.CashForExpenses))
Net Cash from Operating Activities@Fmt(Model.NetOperating)
+
+
+ + +
+
+ Investing Activities + @Fmt(Model.NetInvesting) +
+
+ + + @if (!Model.InvestingLines.Any()) + { + + + + } + else + { + @foreach (var line in Model.InvestingLines) + { + + + + + } + } + + + + + + + +
+ No investing activities recorded in this period. +
@line.Label@Fmt(line.Amount)
Net Cash from Investing Activities@Fmt(Model.NetInvesting)
+
+
+ + +
+
+ Financing Activities + @Fmt(Model.NetFinancing) +
+
+ + + @if (!Model.FinancingLines.Any()) + { + + + + } + else + { + @foreach (var line in Model.FinancingLines) + { + + + + + } + } + + + + + + + +
+ No financing activities recorded in this period. +
@line.Label@Fmt(line.Amount)
Net Cash from Financing Activities@Fmt(Model.NetFinancing)
+
+
+
+ + +
+
+
Cash Summary
+
+
+
Beginning Cash
+
@Fmt(Model.BeginningCash)
+ +
Operating
+
@Fmt(Model.NetOperating)
+ +
Investing
+
@Fmt(Model.NetInvesting)
+ +
Financing
+
@Fmt(Model.NetFinancing)
+ +
Net Change in Cash
+
@Fmt(Model.NetChangeInCash)
+ +
Ending Cash Balance
+
@Fmt(Model.EndingCash)
+
+
+
+ +
+
Methodology
+
+

This statement uses the direct (cash basis) method for Operating Activities:

+
    +
  • Inflows = customer invoice payments received
  • +
  • Outflows = vendor bill payments + direct expense payments
  • +
+

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.

+
+
+
+
diff --git a/src/PowderCoating.Web/Views/Reports/Landing.cshtml b/src/PowderCoating.Web/Views/Reports/Landing.cshtml index 4191187..06bf439 100644 --- a/src/PowderCoating.Web/Views/Reports/Landing.cshtml +++ b/src/PowderCoating.Web/Views/Reports/Landing.cshtml @@ -204,6 +204,14 @@

All active accounts with debit and credit balances — validates that your books are in balance.

Open report
+ +
+ +
+
Cash Flow Statement
+

Track actual cash in/out across operating, investing, and financing activities with beginning and ending cash balance.

+
Open report
+
}