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
@@ -426,6 +426,98 @@ public class FinancialReportService : IFinancialReportService
};
}
/// <inheritdoc/>
public async Task<SalesTaxReportDto> 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
};
}
/// <summary>
/// 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.