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
@@ -1124,6 +1124,74 @@ public class ReportsController : Controller
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
/// <summary>
/// Sales Tax Liability report (invoice basis). Shows taxable vs non-taxable sales,
/// total tax billed, breakdown by tax account and by month, and a full invoice detail grid.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTax
public async Task<IActionResult> SalesTax(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
return View(dto);
}
/// <summary>
/// PDF export of the Sales Tax Liability report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTaxPdf
public async Task<IActionResult> SalesTaxPdf(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateSalesTaxReportPdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
/// <summary>
/// CSV export of the Sales Tax Liability report. Returns one row per invoice, suitable
/// for handing to an accountant or importing into tax filing software.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTaxCsv
public async Task<IActionResult> SalesTaxCsv(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
var sb = new System.Text.StringBuilder();
sb.AppendLine("Invoice #,Customer,Date,Status,Subtotal,Tax %,Tax Amount,Total,Amount Paid,Balance Due,Tax Account");
foreach (var inv in dto.Invoices)
{
sb.AppendLine(string.Join(",",
$"\"{inv.InvoiceNumber}\"",
$"\"{inv.CustomerName.Replace("\"", "\"\"")}\"",
inv.InvoiceDate.ToString("yyyy-MM-dd"),
$"\"{inv.Status}\"",
inv.SubTotal.ToString("F2"),
inv.TaxPercent.ToString("F4"),
inv.TaxAmount.ToString("F2"),
inv.Total.ToString("F2"),
inv.AmountPaid.ToString("F2"),
inv.BalanceDue.ToString("F2"),
$"\"{inv.TaxAccountName.Replace("\"", "\"\"")}\""));
}
var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
}
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
/// <summary>
@@ -569,6 +569,7 @@ public static class HelpKnowledgeBase
- *Balance Sheet* assets, liabilities, equity snapshot
- *AR Aging* outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
- *Sales & Income* revenue trends by period
- *Sales Tax Report* invoice-basis tax liability: taxable vs non-taxable sales, tax billed by account and by month, full invoice detail grid. Supports PDF export and CSV export (for handing to your accountant or tax software). Found under Reports Finance.
- *Revenue Trends* monthly/quarterly revenue charting
- *Operations Report* job throughput, cycle times, status breakdown
- *Customer Overview* top customers, revenue per customer
@@ -579,7 +580,7 @@ public static class HelpKnowledgeBase
- *Powder Usage Report* powder consumption by item/job
- *Job Cycle Time Report* how long jobs spend in each status
Most financial reports support PDF export.
Most financial reports support PDF export. The Sales Tax Report also supports CSV export.
---
@@ -1216,7 +1217,7 @@ public static class HelpKnowledgeBase
The system includes several AI-powered features (all use Claude by Anthropic):
1. **AI Photo Quote** Upload photos of items on a quote; AI estimates surface area, complexity, and labor time. Available in the quote item wizard. *Availability depends on your subscription plan.* The AI is tuned for consistency running the same photo through the wizard multiple times should produce very similar estimates each time (small differences may occur due to the visual nature of photo analysis, but the numbers will be in the same ballpark rather than wildly different).
1. **AI Photo Quote** Upload photos of items on a quote; AI estimates surface area, complexity, and labor time. Available in the quote item wizard. *Availability depends on your subscription plan.* The AI is tuned for consistency running the same photo through the wizard multiple times should produce very similar estimates each time (small differences may occur due to the visual nature of photo analysis, but the numbers will be in the same ballpark rather than wildly different). If the AI service is temporarily under high demand, the system will automatically retry (including a fallback to a secondary model) before showing an error so a single click will usually succeed even if Anthropic's servers are briefly busy.
2. **AI Inventory Assist** AI-powered product lookup when adding or editing inventory items. Click the AI lookup button on the inventory form to auto-fill product details from a part name or description. *Availability depends on your subscription plan.*
@@ -96,6 +96,35 @@
patterns in your job volume and revenue.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Sales Tax Report</h3>
<p>
An invoice-basis Sales Tax Liability report — shows what you collected in tax during the
period and breaks it down so you can file accurately. Key figures:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Total Tax Billed</strong> — sum of all tax charged on invoices in the period.</li>
<li class="mb-1"><strong>Taxable Sales</strong> — subtotals of invoices where tax was charged.</li>
<li class="mb-1"><strong>Non-Taxable Sales</strong> — subtotals of tax-exempt invoices (e.g. tax-exempt customers).</li>
<li class="mb-1"><strong>Effective Tax Rate</strong> — overall average rate across all taxable invoices.</li>
<li class="mb-1"><strong>By Tax Account</strong> — breakdown by GL account (useful if you have multiple tax jurisdictions or rates).</li>
<li class="mb-1"><strong>By Month</strong> — month-by-month chart and table of taxable sales and tax billed.</li>
<li class="mb-1"><strong>Invoice Detail</strong> — every invoice in the period with its tax %, tax amount, and tax account. Non-taxable invoices appear shaded grey so they are easy to distinguish.</li>
</ul>
<p>
This report supports both <strong>PDF export</strong> and <strong>CSV export</strong>.
The CSV is formatted for handing to your accountant or importing into tax-filing software —
one row per invoice with all relevant columns. Use the quick preset buttons (This Month,
Last Month, YTD, Last Year) to jump to common filing periods without manually entering dates.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Invoice basis vs. cash basis:</strong> The Sales Tax Report counts tax when an
invoice is created, not when it is paid. If your jurisdiction requires cash-basis reporting,
cross-reference payments using the <strong>Sales &amp; Income</strong> report.
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Revenue Trends</h3>
<p>
A charting view of monthly and quarterly revenue over time. Useful for year-over-year
@@ -242,12 +271,23 @@
<section id="pdf-export" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-file-earmark-pdf text-primary me-2"></i>PDF Export
<i class="bi bi-file-earmark-pdf text-primary me-2"></i>PDF &amp; CSV Export
</h2>
<p>
Most financial reports (P&amp;L, Balance Sheet, AR Aging, and others) include a
<strong>Download PDF</strong> button. Use this to generate a print-ready version for your
accountant, a business review, or your own records.
Most financial reports (P&amp;L, Balance Sheet, AR Aging, Sales &amp; Income, and others)
include a <strong>Download PDF</strong> button. Use this to generate a print-ready version
for your accountant, a business review, or your own records.
</p>
<p>
The <strong>Sales Tax Report</strong> also includes an <strong>Export CSV</strong> button.
The CSV file contains one row per invoice and is formatted so it can be opened directly in
Excel or imported into most tax-filing and accounting packages. Column headers match standard
tax report terminology: Invoice #, Customer, Date, Status, Subtotal, Tax %, Tax Amount,
Total, Amount Paid, Balance Due, Tax Account.
</p>
<p>
All PDF and CSV exports respect the same date range you have selected in the report — what
you see on screen is exactly what gets exported.
</p>
</section>
@@ -261,9 +301,10 @@
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#financial-reports">Financial Reports</a>
<a class="nav-link py-1 px-3 small text-body ps-4" href="#financial-reports" style="font-size:.75rem">Sales Tax Report</a>
<a class="nav-link py-1 px-3 small text-body" href="#operations-reports">Operations Reports</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-reports">AI-Powered Reports</a>
<a class="nav-link py-1 px-3 small text-body" href="#pdf-export">PDF Export</a>
<a class="nav-link py-1 px-3 small text-body" href="#pdf-export">PDF &amp; CSV Export</a>
</nav>
</div>
</div>
@@ -212,6 +212,14 @@
<p>Detailed breakdown of sales by customer, job type, and period.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="SalesTax" class="report-card">
<div class="report-card-icon" style="background:#faf5ff;color:#7c3aed;">
<i class="bi bi-percent"></i>
</div>
<h5>Sales Tax Report</h5>
<p>Invoice-basis tax liability: taxable vs non-taxable sales, tax billed by account and month, full invoice detail.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CustomerOverview" class="report-card">
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
<i class="bi bi-people"></i>
@@ -0,0 +1,369 @@
@model PowderCoating.Application.DTOs.Accounting.SalesTaxReportDto
@{
ViewData["Title"] = "Sales Tax Report";
ViewData["PageIcon"] = "bi-percent";
var today = DateTime.Today;
var ytdFrom = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
var ytdTo = today.ToString("yyyy-MM-dd");
var lastYrFrom = new DateTime(today.Year - 1, 1, 1).ToString("yyyy-MM-dd");
var lastYrTo = new DateTime(today.Year - 1, 12, 31).ToString("yyyy-MM-dd");
var thisMonthFrom = new DateTime(today.Year, today.Month, 1).ToString("yyyy-MM-dd");
var thisMonthTo = today.ToString("yyyy-MM-dd");
var lastMonthFrom = new DateTime(today.Year, today.Month, 1).AddMonths(-1).ToString("yyyy-MM-dd");
var lastMonthTo = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd");
var monthLabels = Model.ByMonth.Select(m => m.Label).ToList();
var monthTaxable = Model.ByMonth.Select(m => m.TaxableSales).ToList();
var monthTaxBilled = Model.ByMonth.Select(m => m.TaxBilled).ToList();
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 11px; }
}
.row-nontaxable td { background-color: #f8f9fa !important; color: #6c757d; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">@Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("SalesTaxCsv", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-success no-print">
<i class="bi bi-filetype-csv me-1"></i>Export CSV
</a>
<a href="@Url.Action("SalesTaxPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("SalesTaxPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label form-label-sm mb-1">From</label>
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<label class="form-label form-label-sm mb-1">To</label>
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("SalesTax", new { from = thisMonthFrom, to = thisMonthTo })" class="btn btn-outline-secondary">This Month</a>
<a href="@Url.Action("SalesTax", new { from = lastMonthFrom, to = lastMonthTo })" class="btn btn-outline-secondary">Last Month</a>
<a href="@Url.Action("SalesTax", new { from = ytdFrom, to = ytdTo })" class="btn btn-outline-secondary">YTD</a>
<a href="@Url.Action("SalesTax", new { from = lastYrFrom, to = lastYrTo })" class="btn btn-outline-secondary">Last Year</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Sales Tax Liability Report</h5>
<p class="text-muted">@Model.From.ToString("MMMM d, yyyy") @Model.To.ToString("MMMM d, yyyy") · Invoice Basis</p>
</div>
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100 border-primary border-top border-3">
<div class="card-body py-3">
<div class="h5 fw-bold text-primary mb-1">@Model.TotalTaxBilled.ToString("C")</div>
<div class="text-muted small">Total Tax Billed</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold mb-1">@Model.TotalTaxableSales.ToString("C")</div>
<div class="text-muted small">Taxable Sales</div>
<div class="text-muted" style="font-size:0.7rem">@Model.TaxableInvoiceCount invoices</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold text-secondary mb-1">@Model.TotalNonTaxableSales.ToString("C")</div>
<div class="text-muted small">Non-Taxable Sales</div>
<div class="text-muted" style="font-size:0.7rem">@Model.NonTaxableInvoiceCount invoices</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold text-success mb-1">@Model.EffectiveTaxRate.ToString("F2")%</div>
<div class="text-muted small">Effective Tax Rate</div>
<div class="text-muted" style="font-size:0.7rem">on taxable sales</div>
</div>
</div>
</div>
</div>
@if (!Model.Invoices.Any())
{
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-receipt fs-1 d-block mb-2"></i>
<p class="mb-0">No invoices found for this period.</p>
</div>
</div>
}
else
{
<div class="row g-4 mb-4">
<!-- Monthly trend chart -->
@if (Model.ByMonth.Count > 1)
{
<div class="col-lg-8 no-print">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
<i class="bi bi-bar-chart me-1"></i>Monthly Tax Trend
</div>
<div class="card-body">
<canvas id="taxTrendChart" height="120"></canvas>
</div>
</div>
</div>
}
<!-- By Month table -->
<div class="col-lg-@(Model.ByMonth.Count > 1 ? "4" : "12")">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold"><i class="bi bi-calendar3 me-1"></i>By Month</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Month</th>
<th class="text-end">Taxable Sales</th>
<th class="text-end">Tax Billed</th>
<th class="text-center">#</th>
</tr>
</thead>
<tbody>
@foreach (var m in Model.ByMonth)
{
<tr>
<td>@m.Label</td>
<td class="text-end">@m.TaxableSales.ToString("C")</td>
<td class="text-end text-primary fw-semibold">@m.TaxBilled.ToString("C")</td>
<td class="text-center text-muted small">@m.InvoiceCount</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td>Total</td>
<td class="text-end">@Model.TotalTaxableSales.ToString("C")</td>
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
<td class="text-center">@Model.TaxableInvoiceCount</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- By Tax Account -->
@if (Model.ByAccount.Any())
{
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-bookmark me-1"></i>By Tax Account</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Account</th>
<th class="text-end">Taxable Sales</th>
<th class="text-end">Tax Billed</th>
<th class="text-center">Invoices</th>
<th class="text-end no-print">Effective Rate</th>
</tr>
</thead>
<tbody>
@foreach (var a in Model.ByAccount)
{
var rate = a.TaxableSales == 0 ? 0m : Math.Round(a.TaxBilled / a.TaxableSales * 100, 2);
<tr>
<td>
@if (!string.IsNullOrEmpty(a.AccountNumber))
{
<span class="text-muted small me-1">@a.AccountNumber</span>
}
@a.AccountName
</td>
<td class="text-end">@a.TaxableSales.ToString("C")</td>
<td class="text-end fw-semibold text-primary">@a.TaxBilled.ToString("C")</td>
<td class="text-center text-muted small">@a.InvoiceCount</td>
<td class="text-end text-muted small no-print">@rate.ToString("F2")%</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td>Total</td>
<td class="text-end">@Model.TotalTaxableSales.ToString("C")</td>
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
<td class="text-center">@Model.TaxableInvoiceCount</td>
<td class="no-print"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- Invoice Detail -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-receipt me-1"></i>Invoice Detail</span>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary">@(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</span>
<span class="badge bg-light text-muted border">
<span class="d-inline-block me-1" style="width:10px;height:10px;background:#f8f9fa;border:1px solid #dee2e6"></span>
Non-taxable rows shaded
</span>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Date</th>
<th>Status</th>
<th class="text-end">Subtotal</th>
<th class="text-end">Tax %</th>
<th class="text-end">Tax Amount</th>
<th class="text-end">Total</th>
<th class="text-end no-print">Paid</th>
<th>Tax Account</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.Invoices)
{
bool isTaxable = inv.TaxAmount > 0;
string statusBadge = inv.Status switch
{
"Paid" => "bg-success-subtle text-success",
"PartiallyPaid" => "bg-warning-subtle text-warning",
"Sent" => "bg-info-subtle text-info",
"Overdue" => "bg-danger-subtle text-danger",
_ => "bg-secondary-subtle text-secondary"
};
<tr class="@(!isTaxable ? "row-nontaxable" : "")">
<td>
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.InvoiceId" class="text-decoration-none fw-medium">
@inv.InvoiceNumber
</a>
</td>
<td class="small">@inv.CustomerName</td>
<td class="small text-muted">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td><span class="badge @statusBadge">@inv.Status</span></td>
<td class="text-end">@inv.SubTotal.ToString("C")</td>
<td class="text-end text-muted small">@(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "—")</td>
<td class="text-end @(isTaxable ? "fw-semibold text-primary" : "text-muted")">@(isTaxable ? inv.TaxAmount.ToString("C") : "—")</td>
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
<td class="text-end text-success no-print">@inv.AmountPaid.ToString("C")</td>
<td class="small text-muted">@(string.IsNullOrEmpty(inv.TaxAccountName) ? "—" : inv.TaxAccountName)</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="4">Totals</td>
<td class="text-end">@(Model.TotalTaxableSales + Model.TotalNonTaxableSales).ToString("C")</td>
<td></td>
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
<td class="text-end">@Model.Invoices.Sum(i => i.Total).ToString("C")</td>
<td class="text-end text-success no-print">@Model.Invoices.Sum(i => i.AmountPaid).ToString("C")</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Invoice basis — tax liability is recognized when invoiced, not when collected. Excludes Draft and Voided invoices.
</div>
@if (Model.ByMonth.Count > 1)
{
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function() {
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)';
const textColor = isDark ? '#adb5bd' : '#6c757d';
new Chart(document.getElementById('taxTrendChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthLabels)),
datasets: [
{
label: 'Taxable Sales',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthTaxable)),
backgroundColor: 'rgba(79,70,229,0.5)',
borderRadius: 4,
order: 2
},
{
label: 'Tax Billed',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthTaxBilled)),
type: 'line',
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
borderWidth: 2,
pointRadius: 4,
fill: false,
tension: 0.3,
order: 1,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toLocaleString('en-US', {minimumFractionDigits:2}) } }
},
scales: {
y: { ticks: { color: textColor, callback: v => '$' + v.toLocaleString() }, grid: { color: gridColor }, title: { display: true, text: 'Taxable Sales', color: textColor } },
y1: { ticks: { color: textColor, callback: v => '$' + v.toLocaleString() }, grid: { display: false }, position: 'right', title: { display: true, text: 'Tax Billed', color: textColor } },
x: { ticks: { color: textColor }, grid: { display: false } }
}
}
});
})();
</script>
}
}