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:
@@ -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, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 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", "1–30", "31–60", "61–90", "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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user