Add Sales Tax Liability report with PDF and CSV export

Invoice-basis report showing taxable vs non-taxable sales, tax billed
by GL account, monthly trend table/chart, and full invoice detail grid.
Non-taxable invoice rows shaded grey for easy scanning. Quick-preset
date buttons (This Month, Last Month, YTD, Last Year) for common filing
periods. CSV export formatted for accountants and tax-filing software.
Gated behind AllowAccounting() like other financial reports.

- SalesTaxReportDto + 3 supporting DTOs in FinancialReportDtos.cs
- GetSalesTaxReportAsync on IFinancialReportService + implementation
- GenerateSalesTaxReportPdfAsync on IPdfService + QuestPDF implementation
- SalesTax / SalesTaxPdf / SalesTaxCsv actions in ReportsController
- Views/Reports/SalesTax.cshtml with Chart.js monthly trend chart
- Landing page card added to Finance section
- HelpKnowledgeBase and Help/Reports.cshtml updated with full docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:27:08 -04:00
parent 7e0699d5bd
commit ca4fb959aa
11 changed files with 898 additions and 7 deletions
@@ -1,3 +1,4 @@
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.DTOs.GiftCertificate;
using PowderCoating.Application.DTOs.Invoice;
@@ -2122,4 +2123,202 @@ public class PdfService : IPdfService
});
});
}
// ─── Sales Tax Report ─────────────────────────────────────────────────────
/// <summary>
/// Generates a letter-sized PDF for the Sales Tax Liability report. Sections: summary KPI
/// row, breakdown by tax account, breakdown by month, then a full invoice detail table.
/// Intended for handing to an accountant or attaching to a tax filing.
/// </summary>
public Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1e3a5f";
return Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.65f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text(dto.CompanyName).FontSize(14).Bold().FontColor(accent);
c.Item().Text("Sales Tax Liability Report").FontSize(10).FontColor(Colors.Grey.Darken1);
c.Item().Text($"{dto.From:MMMM d, yyyy} {dto.To:MMMM d, yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text("Invoice-Basis Report").FontSize(8).Italic().FontColor(Colors.Grey.Medium);
c.Item().Text($"Generated {DateTime.Today:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
});
});
col.Item().PaddingTop(4).BorderBottom(1.5f).BorderColor(accent);
});
page.Content().PaddingTop(12).Column(col =>
{
// KPI summary row
col.Item().PaddingBottom(12).Row(row =>
{
void KpiBox(RowDescriptor r, string label, string value, string bg)
{
r.RelativeItem().Background(bg).Padding(8).Column(c =>
{
c.Item().Text(label).FontSize(7.5f).FontColor(Colors.Grey.Darken2);
c.Item().Text(value).FontSize(13).Bold().FontColor(accent);
});
}
KpiBox(row, "Total Tax Billed", $"{dto.TotalTaxBilled:C}", "#e8f4fd");
row.ConstantItem(6);
KpiBox(row, "Taxable Sales", $"{dto.TotalTaxableSales:C}", "#f0fdf4");
row.ConstantItem(6);
KpiBox(row, "Non-Taxable Sales", $"{dto.TotalNonTaxableSales:C}", "#fafafa");
row.ConstantItem(6);
KpiBox(row, "Effective Tax Rate", $"{dto.EffectiveTaxRate:F2}%", "#fff7ed");
});
// By account
if (dto.ByAccount.Any())
{
col.Item().PaddingBottom(4).Text("Tax by Liability Account").FontSize(10).Bold().FontColor(accent);
col.Item().PaddingBottom(10).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(3);
c.RelativeColumn(2);
c.RelativeColumn(2);
c.ConstantColumn(40);
});
void AHdr(string t) => table.Header(h => h.Cell().Background("#e8f4fd").Padding(4).Text(t).Bold().FontSize(8));
table.Header(h =>
{
h.Cell().Background("#e8f4fd").Padding(4).Text("Account").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
});
foreach (var a in dto.ByAccount)
{
var label = string.IsNullOrEmpty(a.AccountNumber) ? a.AccountName : $"{a.AccountNumber} {a.AccountName}";
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(label).FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxableSales:C}").FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxBilled:C}").FontSize(8).Bold();
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(a.InvoiceCount.ToString()).FontSize(8);
}
// Totals row
table.Cell().Background("#f8fafc").Padding(4).Text("Total").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxableSales):C}").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxBilled):C}").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignCenter().Text(dto.ByAccount.Sum(a => a.InvoiceCount).ToString()).Bold().FontSize(8);
});
}
// By month
if (dto.ByMonth.Any())
{
col.Item().PaddingBottom(4).Text("Tax by Month").FontSize(10).Bold().FontColor(accent);
col.Item().PaddingBottom(10).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(2);
c.RelativeColumn(2);
c.RelativeColumn(2);
c.ConstantColumn(40);
});
table.Header(h =>
{
h.Cell().Background("#e8f4fd").Padding(4).Text("Month").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
});
foreach (var m in dto.ByMonth)
{
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(m.Label).FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxableSales:C}").FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxBilled:C}").FontSize(8).Bold();
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(m.InvoiceCount.ToString()).FontSize(8);
}
});
}
// Invoice detail
col.Item().PaddingBottom(4).Text("Invoice Detail").FontSize(10).Bold().FontColor(accent);
col.Item().Table(table =>
{
table.ColumnsDefinition(c =>
{
c.ConstantColumn(70); // Invoice #
c.RelativeColumn(2.5f); // Customer
c.ConstantColumn(58); // Date
c.ConstantColumn(48); // Status
c.ConstantColumn(52); // SubTotal
c.ConstantColumn(34); // Tax %
c.ConstantColumn(52); // Tax $
c.ConstantColumn(52); // Total
c.RelativeColumn(2); // Tax Account
});
table.Header(h =>
{
string bg = "#e8f4fd";
h.Cell().Background(bg).Padding(3).Text("Invoice #").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Customer").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Date").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Status").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Subtotal").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax %").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax $").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Total").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Tax Account").Bold().FontSize(7.5f);
});
foreach (var inv in dto.Invoices)
{
var rowBg = inv.TaxAmount == 0 ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceNumber).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.CustomerName).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.Status).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.SubTotal:C}").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxPercent:F2}%" : "—").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxAmount:C}" : "—").FontSize(7.5f).Bold();
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.Total:C}").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.TaxAccountName).FontSize(7).FontColor(Colors.Grey.Darken1);
}
// Totals row
table.Cell().ColumnSpan(4).Background("#f0fdf4").Padding(3).AlignRight().Text("Totals").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.SubTotal):C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.TotalTaxBilled:C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.Total):C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.DefaultTextStyle(s => s.FontSize(7.5f).FontColor(Colors.Grey.Medium));
text.Span("Sales Tax Liability Report | Invoice Basis | ");
text.CurrentPageNumber();
text.Span(" of ");
text.TotalPages();
});
});
});
return document.GeneratePdf();
});
}
}