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:
@@ -159,3 +159,65 @@ public class SalesInvoiceLineDto
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SALES TAX REPORT
|
||||
// ============================================================
|
||||
|
||||
public class SalesTaxReportDto
|
||||
{
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Subtotal of invoices where TaxAmount > 0.</summary>
|
||||
public decimal TotalTaxableSales { get; set; }
|
||||
/// <summary>Subtotal of invoices where TaxAmount == 0.</summary>
|
||||
public decimal TotalNonTaxableSales { get; set; }
|
||||
/// <summary>Sum of all TaxAmount values across the period.</summary>
|
||||
public decimal TotalTaxBilled { get; set; }
|
||||
public int TaxableInvoiceCount { get; set; }
|
||||
public int NonTaxableInvoiceCount { get; set; }
|
||||
public decimal EffectiveTaxRate => TotalTaxableSales == 0 ? 0
|
||||
: Math.Round(TotalTaxBilled / TotalTaxableSales * 100, 2);
|
||||
|
||||
public List<SalesTaxByAccountDto> ByAccount { get; set; } = new();
|
||||
public List<SalesTaxByMonthDto> ByMonth { get; set; } = new();
|
||||
public List<SalesTaxInvoiceLineDto> Invoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SalesTaxByAccountDto
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public decimal TaxableSales { get; set; }
|
||||
public decimal TaxBilled { get; set; }
|
||||
public int InvoiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class SalesTaxByMonthDto
|
||||
{
|
||||
public int Year { get; set; }
|
||||
public int Month { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public decimal TaxableSales { get; set; }
|
||||
public decimal TaxBilled { get; set; }
|
||||
public int InvoiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class SalesTaxInvoiceLineDto
|
||||
{
|
||||
public int InvoiceId { get; set; }
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public decimal SubTotal { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string TaxAccountName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -20,4 +20,7 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user