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:
@@ -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.
|
||||||
@@ -159,3 +159,65 @@ public class SalesInvoiceLineDto
|
|||||||
public decimal AmountPaid { get; set; }
|
public decimal AmountPaid { get; set; }
|
||||||
public decimal BalanceDue { 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>
|
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
||||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
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[]> GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
|
||||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||||
|
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||||
|
|
||||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||||
GiftCertificateDto cert,
|
GiftCertificateDto cert,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Accounting;
|
||||||
using PowderCoating.Application.DTOs.Company;
|
using PowderCoating.Application.DTOs.Company;
|
||||||
using PowderCoating.Application.DTOs.GiftCertificate;
|
using PowderCoating.Application.DTOs.GiftCertificate;
|
||||||
using PowderCoating.Application.DTOs.Invoice;
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
/// 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.
|
/// Falls back to "Your Company" if the record is not found.
|
||||||
|
|||||||
@@ -1124,6 +1124,74 @@ public class ReportsController : Controller
|
|||||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
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 ──────────────────────────────────────────────
|
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -569,6 +569,7 @@ public static class HelpKnowledgeBase
|
|||||||
- *Balance Sheet* — assets, liabilities, equity snapshot
|
- *Balance Sheet* — assets, liabilities, equity snapshot
|
||||||
- *AR Aging* — outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
|
- *AR Aging* — outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
|
||||||
- *Sales & Income* — revenue trends by period
|
- *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
|
- *Revenue Trends* — monthly/quarterly revenue charting
|
||||||
- *Operations Report* — job throughput, cycle times, status breakdown
|
- *Operations Report* — job throughput, cycle times, status breakdown
|
||||||
- *Customer Overview* — top customers, revenue per customer
|
- *Customer Overview* — top customers, revenue per customer
|
||||||
@@ -579,7 +580,7 @@ public static class HelpKnowledgeBase
|
|||||||
- *Powder Usage Report* — powder consumption by item/job
|
- *Powder Usage Report* — powder consumption by item/job
|
||||||
- *Job Cycle Time Report* — how long jobs spend in each status
|
- *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):
|
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.*
|
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.
|
patterns in your job volume and revenue.
|
||||||
</p>
|
</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 & Income</strong> report.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-3 mb-2">Revenue Trends</h3>
|
<h3 class="h6 fw-semibold mt-3 mb-2">Revenue Trends</h3>
|
||||||
<p>
|
<p>
|
||||||
A charting view of monthly and quarterly revenue over time. Useful for year-over-year
|
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">
|
<section id="pdf-export" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<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 & CSV Export
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Most financial reports (P&L, Balance Sheet, AR Aging, and others) include a
|
Most financial reports (P&L, Balance Sheet, AR Aging, Sales & Income, and others)
|
||||||
<strong>Download PDF</strong> button. Use this to generate a print-ready version for your
|
include a <strong>Download PDF</strong> button. Use this to generate a print-ready version
|
||||||
accountant, a business review, or your own records.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -261,9 +301,10 @@
|
|||||||
<nav class="nav flex-column">
|
<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="#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" 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="#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="#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 & CSV Export</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -212,6 +212,14 @@
|
|||||||
<p>Detailed breakdown of sales by customer, job type, and period.</p>
|
<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>
|
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||||
</a>
|
</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">
|
<a asp-controller="Reports" asp-action="CustomerOverview" class="report-card">
|
||||||
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
|
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
|
||||||
<i class="bi bi-people"></i>
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user