diff --git a/docs/Sales Tax Report.txt b/docs/Sales Tax Report.txt
new file mode 100644
index 0000000..969c075
--- /dev/null
+++ b/docs/Sales Tax Report.txt
@@ -0,0 +1,47 @@
+ I looked through the reporting code, and I do not see a dedicated sales tax report.
+
+ What exists today:
+
+ - The report menu has Financial Summary, AR Aging, Balance Sheet, and Sales & Income, but no sales-tax-specific report card in /Y:/PCC/
+ PowderCoatingApp/src/PowderCoating.Web/Views/Reports/Landing.cshtml:147 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/
+ Landing.cshtml:211.
+ - The reporting service only exposes four finance reports in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/
+ IFinancialReportService.cs:13 through /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs:22.
+ - Sales & Income does surface tax, but only as a total and per-invoice column in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/
+ Services/FinancialReportService.cs:406, /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs:419,
+ and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/SalesAndIncome.cshtml:268.
+ - The good news is the underlying data is already there: invoices store TaxAmount and SalesTaxAccountId in /Y:/PCC/PowderCoatingApp/src/
+ PowderCoating.Core/Entities/Invoice.cs:23 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/Invoice.cs:55, and the chart of
+ accounts seeds 2200 Sales Tax Payable in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs:59.
+
+ Plan I’d recommend:
+
+ 1. Add a new Sales Tax report under Reports > Finance with date range filters, matching the existing report pattern in /Y:/PCC/PowderCoatingApp/
+ src/PowderCoating.Web/Controllers/ReportsController.cs:993.
+ 2. Build a SalesTaxReportDto plus GetSalesTaxReportAsync(...) in the reporting interface/service.
+ 3. Phase 1 report contents:
+
+ - Total taxable sales
+ - Total non-taxed sales
+ - Total sales tax billed
+ - Breakdown by sales-tax liability account
+ - Breakdown by month
+ - Invoice detail grid: invoice date, invoice #, customer, subtotal, tax %, tax amount, total, amount paid, balance due, status, tax account
+
+ 4. Add PDF export, and probably CSV too, since this is the kind of report people hand to accountants.
+ 5. Put a report card on the Reports landing page and gate it the same way as the other accounting reports.
+
+ Two important caveats before we build it:
+
+ - The biggest decision is whether this should be invoice-basis or cash-basis. The current model naturally supports invoice-basis first.
+ - Refunds and credit memos do not appear to store a separate tax adjustment value in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/
+ Refund.cs:1 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/CreditMemo.cs:1, so phase 1 would be best framed as “sales tax billed
+ on invoices,” with net tax adjustments as a phase 2 enhancement.
+
+ My recommendation is:
+
+ - Build phase 1 as an invoice-basis Sales Tax Liability report
+ - Label zero-tax invoices as non-taxed sales
+ - Add cash-basis and refund/credit tax adjustments later if you need filing-grade accuracy for more complex cases
+
+ If you want, I can turn this plan into the actual report next.
\ No newline at end of file
diff --git a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs
index b9d4bc9..44901d7 100644
--- a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs
@@ -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;
+
+ /// Subtotal of invoices where TaxAmount > 0.
+ public decimal TotalTaxableSales { get; set; }
+ /// Subtotal of invoices where TaxAmount == 0.
+ public decimal TotalNonTaxableSales { get; set; }
+ /// Sum of all TaxAmount values across the period.
+ 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 ByAccount { get; set; } = new();
+ public List ByMonth { get; set; } = new();
+ public List 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;
+}
diff --git a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs
index 7537dc1..59eea64 100644
--- a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs
+++ b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs
@@ -20,4 +20,7 @@ public interface IFinancialReportService
/// Returns a Sales & Income report for the given company and date range.
Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
+
+ /// Returns an invoice-basis Sales Tax Liability report for the given company and date range.
+ Task GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
}
diff --git a/src/PowderCoating.Application/Interfaces/IPdfService.cs b/src/PowderCoating.Application/Interfaces/IPdfService.cs
index 0bb48f1..c59cbe4 100644
--- a/src/PowderCoating.Application/Interfaces/IPdfService.cs
+++ b/src/PowderCoating.Application/Interfaces/IPdfService.cs
@@ -41,6 +41,7 @@ public interface IPdfService
Task GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
Task GenerateArAgingPdfAsync(ArAgingReportDto dto);
Task GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
+ Task GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
Task GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert,
diff --git a/src/PowderCoating.Application/Services/PdfService.cs b/src/PowderCoating.Application/Services/PdfService.cs
index d0402c0..e42a196 100644
--- a/src/PowderCoating.Application/Services/PdfService.cs
+++ b/src/PowderCoating.Application/Services/PdfService.cs
@@ -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 ─────────────────────────────────────────────────────
+
+ ///
+ /// 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.
+ ///
+ public Task 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();
+ });
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
index 6563c80..3c3527d 100644
--- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
+++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
@@ -426,6 +426,98 @@ public class FinancialReportService : IFinancialReportService
};
}
+ ///
+ public async Task GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to)
+ {
+ var toEnd = to.AddDays(1).AddTicks(-1);
+ var companyName = await GetCompanyNameAsync(companyId);
+
+ var invoices = await _context.Invoices
+ .Include(i => i.Customer)
+ .Include(i => i.SalesTaxAccount)
+ .Where(i => i.CompanyId == companyId
+ && i.Status != InvoiceStatus.Draft
+ && i.Status != InvoiceStatus.Voided
+ && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
+ .AsNoTracking()
+ .OrderBy(i => i.InvoiceDate)
+ .ToListAsync();
+
+ var taxable = invoices.Where(i => i.TaxAmount > 0).ToList();
+ var nonTaxable = invoices.Where(i => i.TaxAmount == 0).ToList();
+
+ var byAccount = invoices
+ .Where(i => i.TaxAmount > 0)
+ .GroupBy(i => new
+ {
+ i.SalesTaxAccountId,
+ AccountName = i.SalesTaxAccount?.Name ?? "Unassigned",
+ AccountNumber = i.SalesTaxAccount?.AccountNumber ?? string.Empty
+ })
+ .Select(g => new SalesTaxByAccountDto
+ {
+ AccountId = g.Key.SalesTaxAccountId,
+ AccountName = g.Key.AccountName,
+ AccountNumber = g.Key.AccountNumber,
+ TaxableSales = g.Sum(i => i.SubTotal),
+ TaxBilled = g.Sum(i => i.TaxAmount),
+ InvoiceCount = g.Count()
+ })
+ .OrderBy(a => a.AccountNumber)
+ .ThenBy(a => a.AccountName)
+ .ToList();
+
+ var byMonth = invoices
+ .Where(i => i.TaxAmount > 0)
+ .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
+ .Select(g => new SalesTaxByMonthDto
+ {
+ Year = g.Key.Year,
+ Month = g.Key.Month,
+ Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
+ TaxableSales = g.Sum(i => i.SubTotal),
+ TaxBilled = g.Sum(i => i.TaxAmount),
+ InvoiceCount = g.Count()
+ })
+ .OrderBy(m => m.Year).ThenBy(m => m.Month)
+ .ToList();
+
+ var invoiceLines = invoices.Select(i => new SalesTaxInvoiceLineDto
+ {
+ InvoiceId = i.Id,
+ InvoiceNumber = i.InvoiceNumber,
+ CustomerName = i.Customer!.IsCommercial
+ ? i.Customer.CompanyName ?? string.Empty
+ : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
+ InvoiceDate = i.InvoiceDate,
+ Status = i.Status.ToString(),
+ SubTotal = i.SubTotal,
+ TaxPercent = i.TaxPercent,
+ TaxAmount = i.TaxAmount,
+ Total = i.Total,
+ AmountPaid = i.AmountPaid,
+ BalanceDue = i.BalanceDue,
+ TaxAccountName = i.SalesTaxAccount != null
+ ? $"{i.SalesTaxAccount.AccountNumber} {i.SalesTaxAccount.Name}".Trim()
+ : string.Empty
+ }).ToList();
+
+ return new SalesTaxReportDto
+ {
+ From = from,
+ To = to,
+ CompanyName = companyName,
+ TotalTaxableSales = taxable.Sum(i => i.SubTotal),
+ TotalNonTaxableSales = nonTaxable.Sum(i => i.SubTotal),
+ TotalTaxBilled = taxable.Sum(i => i.TaxAmount),
+ TaxableInvoiceCount = taxable.Count,
+ NonTaxableInvoiceCount = nonTaxable.Count,
+ ByAccount = byAccount,
+ ByMonth = byMonth,
+ Invoices = invoiceLines
+ };
+ }
+
///
/// Looks up the company name by ID for report headers and AI prompt injection.
/// Falls back to "Your Company" if the record is not found.
diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs
index d0e9ddc..701ba34 100644
--- a/src/PowderCoating.Web/Controllers/ReportsController.cs
+++ b/src/PowderCoating.Web/Controllers/ReportsController.cs
@@ -1124,6 +1124,74 @@ public class ReportsController : Controller
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
+ ///
+ /// 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 .
+ ///
+ // GET: /Reports/SalesTax
+ public async Task 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);
+ }
+
+ ///
+ /// PDF export of the Sales Tax Liability report. Same inline/attachment pattern as other PDF actions.
+ /// Gated behind .
+ ///
+ // GET: /Reports/SalesTaxPdf
+ public async Task 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");
+ }
+
+ ///
+ /// 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 .
+ ///
+ // GET: /Reports/SalesTaxCsv
+ public async Task 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 ──────────────────────────────────────────────
///
diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
index f139753..6db03f6 100644
--- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
+++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
@@ -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.*
diff --git a/src/PowderCoating.Web/Views/Help/Reports.cshtml b/src/PowderCoating.Web/Views/Help/Reports.cshtml
index a4c7222..df18fdb 100644
--- a/src/PowderCoating.Web/Views/Help/Reports.cshtml
+++ b/src/PowderCoating.Web/Views/Help/Reports.cshtml
@@ -96,6 +96,35 @@
patterns in your job volume and revenue.
+
Sales Tax Report
+
+ 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:
+
+
+
Total Tax Billed — sum of all tax charged on invoices in the period.
+
Taxable Sales — subtotals of invoices where tax was charged.
+
Non-Taxable Sales — subtotals of tax-exempt invoices (e.g. tax-exempt customers).
+
Effective Tax Rate — overall average rate across all taxable invoices.
+
By Tax Account — breakdown by GL account (useful if you have multiple tax jurisdictions or rates).
+
By Month — month-by-month chart and table of taxable sales and tax billed.
+
Invoice Detail — 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.
+
+
+ This report supports both PDF export and CSV export.
+ 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.
+
+
+
+
+ Invoice basis vs. cash basis: 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 Sales & Income report.
+
+
+
Revenue Trends
A charting view of monthly and quarterly revenue over time. Useful for year-over-year
@@ -242,12 +271,23 @@
- PDF Export
+ PDF & CSV Export
- Most financial reports (P&L, Balance Sheet, AR Aging, and others) include a
- Download PDF button. Use this to generate a print-ready version for your
- accountant, a business review, or your own records.
+ Most financial reports (P&L, Balance Sheet, AR Aging, Sales & Income, and others)
+ include a Download PDF button. Use this to generate a print-ready version
+ for your accountant, a business review, or your own records.
+
+
+ The Sales Tax Report also includes an Export CSV 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.
+
+
+ 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.