Add Phase A accounting features: AP Aging, Trial Balance, Cash vs Accrual

- AP Aging report (GetApAgingAsync, controller actions, view, PDF export)
  mirrors AR Aging — groups open bills by vendor, buckets by days past due date
- Trial Balance report (GetTrialBalanceAsync, view, PDF export)
  uses Account.CurrentBalance, groups by AccountType, validates debits == credits
- Cash vs Accrual accounting method setting on Company entity
  switchable at any time — report-time only, no GL re-posting on change
  P&L cash: revenue = payments received; expenses = bills/expenses paid in period
  Balance Sheet cash: omits AR and AP lines (no receivables/payables concept)
  AccountingMethod badge shown on P&L and Balance Sheet views
- Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies
- AP Aging and Trial Balance added to Reports Landing page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 23:34:54 -04:00
parent 379b0de885
commit 7e1676cfd7
18 changed files with 10765 additions and 67 deletions
@@ -2,6 +2,72 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Accounting;
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
// without needing a separate round-trip to the company settings.
// ── AP Aging ──────────────────────────────────────────────────────────────────
public class ApAgingReportDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<ApAgingVendorDto> Vendors { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingVendorDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public List<ApAgingBillDto> Bills { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingBillDto
{
public int BillId { get; set; }
public string BillNumber { get; set; } = string.Empty;
public DateTime BillDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal BalanceDue { get; set; }
public int DaysOverdue { get; set; }
}
// ── Trial Balance ─────────────────────────────────────────────────────────────
public class TrialBalanceDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<TrialBalanceLine> Lines { get; set; } = new();
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
}
public class TrialBalanceLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public decimal DebitBalance { get; set; }
public decimal CreditBalance { get; set; }
}
// ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto
@@ -9,6 +75,7 @@ public class ProfitAndLossDto
public DateTime From { get; set; }
public DateTime To { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public List<FinancialReportLine> RevenueLines { get; set; } = new();
public decimal TotalRevenue { get; set; }
@@ -40,6 +107,7 @@ public class BalanceSheetDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
// Assets
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? TimeZone { get; set; }
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public bool HasLogo { get; set; }
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
public string? TimeZone { get; set; }
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
}
/// <summary>
@@ -1,4 +1,5 @@
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Interfaces;
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
/// Read-only service for financial aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// The <paramref name="method"/> parameter overrides the company's stored preference when
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
@@ -23,4 +26,13 @@ public interface IFinancialReportService
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
}
@@ -42,6 +42,8 @@ public interface IPdfService
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
Task<byte[]> GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert,
@@ -2357,4 +2357,240 @@ public class PdfService : IPdfService
return document.GeneratePdf();
});
}
/// <summary>
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
/// </summary>
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#b91c1c";
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, "Accounts Payable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
});
if (!dto.Vendors.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("All bills are paid — no outstanding balances.")
.FontSize(11).FontColor("#16a34a");
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Vendor", "Current", "130", "3160", "6190", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var vend in dto.Vendors)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
foreach (var vend in dto.Vendors)
{
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
{
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
vendCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
: bill.DaysOverdue <= 30 ? "#ca8a04"
: bill.DaysOverdue <= 60 ? "#ea580c"
: bill.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
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();
});
}
/// <summary>
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
/// totals and a balanced/unbalanced indicator.
/// </summary>
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
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, "Trial Balance",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
dto.IsBalanced ? "#16a34a" : "#dc2626");
});
if (!dto.Lines.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("No active accounts with balances found.")
.FontSize(11).FontColor(Colors.Grey.Darken1);
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.ConstantColumn(70);
cols.RelativeColumn(4);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var line in dto.Lines)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
});
});
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();
});
}
}