Phase F: Customer/Vendor Statements, Payment Terms Parser, Tax Rates

F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
    StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
    CustomersController + VendorsController; Statement views + PDF download via
    StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.

F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
    EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
    endpoint on InvoicesController auto-populates Terms + due date on customer select;
    early payment discount notice on Invoice Create.

F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
    IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
    (Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
    AJAX endpoint; Tax Rates in Settings gear menu.

Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 10:55:22 -04:00
parent 1229081436
commit d3a5d827f9
27 changed files with 11492 additions and 12 deletions
@@ -26,6 +26,7 @@ public class CustomersController : Controller
private readonly ISubscriptionService _subscriptionService;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IFinancialReportService _financialReports;
public CustomersController(
IUnitOfWork unitOfWork,
@@ -34,7 +35,8 @@ public class CustomersController : Controller
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager)
UserManager<ApplicationUser> userManager,
IFinancialReportService financialReports)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -43,6 +45,7 @@ public class CustomersController : Controller
_subscriptionService = subscriptionService;
_tenantContext = tenantContext;
_userManager = userManager;
_financialReports = financialReports;
}
/// <summary>
@@ -935,6 +938,30 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
/// </summary>
[HttpGet]
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var toDate = to ?? DateTime.Today;
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
if (pdf)
{
var bytes = StatementPdfHelper.Generate(
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
}
return View(dto);
}
/// <summary>
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
@@ -1851,6 +1851,54 @@ public class InvoicesController : Controller
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Returns the customer's payment terms, derived due date, and early-payment discount info
/// for the Invoice Create form so JavaScript can auto-populate those fields on customer selection.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCustomerPaymentTerms(int customerId)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return NotFound();
var invoiceDate = DateTime.Today;
var dueDate = PaymentTermsParser.CalculateDueDate(customer.PaymentTerms, invoiceDate);
var (discountPercent, discountDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(customer.PaymentTerms);
return Json(new
{
paymentTerms = customer.PaymentTerms,
dueDate = dueDate?.ToString("yyyy-MM-dd"),
earlyPaymentDiscountPercent = discountPercent,
earlyPaymentDiscountDays = discountDays,
isTaxExempt = customer.IsTaxExempt
});
}
/// <summary>
/// Returns the default active tax rate for the current company, or zero for tax-exempt customers.
/// Called by the Invoice Create form when the customer selection changes.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetTaxRateForCustomer(int customerId)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return NotFound();
if (customer.IsTaxExempt)
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
var defaultRate = await _unitOfWork.TaxRates
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
return Json(new
{
taxPercent = defaultRate?.Rate ?? 0m,
taxRateName = defaultRate?.Name
});
}
/// <summary>
/// Populates ViewBag data used by both Create GET and Create POST (on validation failure re-display):
/// — Active customer list for the customer dropdown.
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages named tax rates used to pre-fill the tax percent field on invoices when a taxable
/// customer is selected. Only one rate may be marked as default at a time; that default is
/// auto-applied via the GetTaxRateForCustomer AJAX endpoint on the Invoice Create form.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class TaxRatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<TaxRatesController> _logger;
public TaxRatesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
ILogger<TaxRatesController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
/// <summary>Lists all tax rates for the current company.</summary>
public async Task<IActionResult> Index()
{
var rates = await _unitOfWork.TaxRates.GetAllAsync();
return View(rates.OrderBy(r => r.Name).ToList());
}
[HttpGet]
public IActionResult Create() => View(new TaxRate());
/// <summary>Creates a new tax rate. Enforces that only one rate is the default.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(TaxRate model)
{
if (!ModelState.IsValid) return View(model);
if (model.IsDefault)
await ClearOtherDefaultsAsync(0);
await _unitOfWork.TaxRates.AddAsync(model);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Tax rate \"{model.Name}\" created.";
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
if (rate == null) return NotFound();
return View(rate);
}
/// <summary>Updates an existing tax rate. Clears default flag on other rates when IsDefault is set.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, TaxRate model)
{
if (id != model.Id) return BadRequest();
if (!ModelState.IsValid) return View(model);
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
if (rate == null) return NotFound();
if (model.IsDefault && !rate.IsDefault)
await ClearOtherDefaultsAsync(id);
rate.Name = model.Name;
rate.Rate = model.Rate;
rate.State = model.State;
rate.Description = model.Description;
rate.IsDefault = model.IsDefault;
rate.IsActive = model.IsActive;
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Tax rate \"{rate.Name}\" updated.";
return RedirectToAction(nameof(Index));
}
/// <summary>Toggles IsActive without a full page reload.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
if (rate == null) return NotFound();
rate.IsActive = !rate.IsActive;
if (!rate.IsActive) rate.IsDefault = false;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
/// <summary>Soft-deletes a tax rate. Blocked when the rate is currently the default.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
if (rate == null) return NotFound();
if (rate.IsDefault)
{
TempData["Error"] = "Cannot delete the default tax rate. Set another rate as default first.";
return RedirectToAction(nameof(Index));
}
await _unitOfWork.TaxRates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Tax rate \"{rate.Name}\" deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Clears IsDefault on all rates except the one with <paramref name="exceptId"/>.
/// Called before saving a newly-designated default to enforce the single-default invariant.
/// </summary>
private async Task ClearOtherDefaultsAsync(int exceptId)
{
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
foreach (var r in others)
r.IsDefault = false;
}
}
@@ -7,9 +7,11 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Vendor;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
@@ -27,17 +29,23 @@ public class VendorsController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<VendorsController> _logger;
private readonly IFinancialReportService _financialReports;
private readonly ITenantContext _tenantContext;
public VendorsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<VendorsController> logger)
ILogger<VendorsController> logger,
IFinancialReportService financialReports,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_financialReports = financialReports;
_tenantContext = tenantContext;
}
/// <summary>
@@ -377,6 +385,29 @@ public class VendorsController : Controller
/// <summary>
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
/// <summary>
/// Displays or downloads a dated activity statement for a vendor.
/// </summary>
[HttpGet]
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var toDate = to ?? DateTime.Today;
var dto = await _financialReports.GetVendorStatementAsync(companyId, id, fromDate, toDate);
if (pdf)
{
var bytes = StatementPdfHelper.Generate(
dto.VendorName, dto.CompanyName, null,
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: true);
return File(bytes, "application/pdf", $"Statement-{dto.VendorName}-{toDate:yyyyMMdd}.pdf");
}
return View(dto);
}
/// for the vendor's default expense account dropdown. All three account types are included because
/// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
@@ -0,0 +1,49 @@
using System.Text.RegularExpressions;
namespace PowderCoating.Web.Helpers;
/// <summary>
/// Parses payment terms strings (e.g., "Net 30", "2/10 Net 30", "Due on Receipt")
/// to compute due dates and extract early-payment discount terms.
/// </summary>
public static class PaymentTermsParser
{
private static readonly Regex NetDaysRegex = new(@"\bnet\s+(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex EarlyDiscountRegex = new(@"(\d+(?:\.\d+)?)/(\d+)\s+net", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Returns the due date calculated from <paramref name="invoiceDate"/> and the supplied terms.
/// Returns null when the terms string cannot be parsed.
/// </summary>
public static DateTime? CalculateDueDate(string? terms, DateTime invoiceDate)
{
if (string.IsNullOrWhiteSpace(terms)) return null;
var normalized = terms.Trim().ToLowerInvariant();
if (Regex.IsMatch(normalized, @"\b(receipt|due\s*now|cod|immediate)\b"))
return invoiceDate;
var match = NetDaysRegex.Match(terms);
if (match.Success && int.TryParse(match.Groups[1].Value, out var days))
return invoiceDate.AddDays(days);
return null;
}
/// <summary>
/// Extracts early-payment discount terms from a string like "2/10 Net 30".
/// Returns (percent: 2, days: 10) for that example, or (0, 0) if not present.
/// </summary>
public static (decimal Percent, int Days) ParseEarlyPaymentDiscount(string? terms)
{
if (string.IsNullOrWhiteSpace(terms)) return (0, 0);
var match = EarlyDiscountRegex.Match(terms);
if (match.Success
&& decimal.TryParse(match.Groups[1].Value, out var percent)
&& int.TryParse(match.Groups[2].Value, out var days))
return (percent, days);
return (0, 0);
}
}
@@ -0,0 +1,111 @@
using PowderCoating.Application.DTOs.Accounting;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PowderCoating.Web.Helpers;
/// <summary>
/// Generates a QuestPDF account-activity statement. Shared by customer and vendor statement actions.
/// </summary>
public static class StatementPdfHelper
{
/// <summary>Generates and returns the raw PDF bytes for a statement.</summary>
public static byte[] Generate(
string entityName,
string companyName,
string? address,
DateTime from,
DateTime to,
decimal openingBalance,
List<StatementLineDto> lines,
decimal closingBalance,
bool isVendor)
{
var doc = Document.Create(container =>
{
container.Page(page =>
{
page.Margin(40);
page.Size(PageSizes.Letter);
page.DefaultTextStyle(t => t.FontSize(10));
page.Header().Element(h =>
{
h.Column(col =>
{
col.Item().Text(companyName).Bold().FontSize(16);
col.Item().Text($"{(isVendor ? "Vendor" : "Customer")} Statement").FontSize(12).FontColor("#555555");
col.Item().Text($"Period: {from:MMM d, yyyy} {to:MMM d, yyyy}");
col.Item().PaddingTop(4).Text(entityName).Bold();
if (!string.IsNullOrWhiteSpace(address))
col.Item().Text(address).FontColor("#555555");
});
});
page.Content().PaddingTop(16).Column(col =>
{
col.Item().Table(t =>
{
t.ColumnsDefinition(c =>
{
c.ConstantColumn(72); // Date
c.RelativeColumn(2); // Type / Ref
c.RelativeColumn(3); // Description
c.ConstantColumn(80); // Debit
c.ConstantColumn(80); // Credit
c.ConstantColumn(90); // Balance
});
static IContainer HeaderCell(IContainer c) =>
c.Background("#2c3e50").PaddingVertical(4).PaddingHorizontal(6);
t.Header(h =>
{
h.Cell().Element(HeaderCell).Text("Date").FontColor("white").Bold();
h.Cell().Element(HeaderCell).Text("Type / Ref").FontColor("white").Bold();
h.Cell().Element(HeaderCell).Text("Description").FontColor("white").Bold();
h.Cell().Element(HeaderCell).AlignRight().Text("Debit").FontColor("white").Bold();
h.Cell().Element(HeaderCell).AlignRight().Text("Credit").FontColor("white").Bold();
h.Cell().Element(HeaderCell).AlignRight().Text("Balance").FontColor("white").Bold();
});
static IContainer DataCell(IContainer c) =>
c.BorderBottom(0.5f).BorderColor("#dddddd").PaddingVertical(3).PaddingHorizontal(6);
// Opening balance row
t.Cell().Element(DataCell).Text(from.AddDays(-1).ToString("MM/dd/yy")).FontColor("#888888");
t.Cell().Element(DataCell).Text("Opening Balance").Bold();
t.Cell().Element(DataCell).Text("");
t.Cell().Element(DataCell).Text("");
t.Cell().Element(DataCell).Text("");
t.Cell().Element(DataCell).AlignRight().Text(openingBalance.ToString("C"));
foreach (var line in lines)
{
t.Cell().Element(DataCell).Text(line.Date.ToString("MM/dd/yy"));
t.Cell().Element(DataCell).Text($"{line.Type}\n{line.Reference}");
t.Cell().Element(DataCell).Text(line.Description);
t.Cell().Element(DataCell).AlignRight().Text(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "");
t.Cell().Element(DataCell).AlignRight().Text(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "");
t.Cell().Element(DataCell).AlignRight().Text(line.RunningBalance.ToString("C"));
}
static IContainer TotalCell(IContainer c) =>
c.Background("#f0f4f8").PaddingVertical(4).PaddingHorizontal(6);
t.Cell().ColumnSpan(5).Element(TotalCell).Text("Closing Balance").Bold();
t.Cell().Element(TotalCell).AlignRight().Text(closingBalance.ToString("C")).Bold();
});
});
page.Footer().AlignCenter().Text(t =>
{
t.Span($"Generated {DateTime.Now:MM/dd/yyyy HH:mm}").FontSize(8).FontColor("#888888");
});
});
});
return doc.GeneratePdf();
}
}
@@ -459,6 +459,9 @@
<a asp-action="Invoices" asp-route-id="@Model.Id" class="btn btn-outline-warning">
<i class="bi bi-receipt me-2"></i>View Invoices
</a>
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>Statement
</a>
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
@@ -0,0 +1,101 @@
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
@{
ViewData["Title"] = $"Statement {Model.CustomerName}";
}
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-0">Customer Statement</h4>
<p class="text-muted mb-0">@Model.CustomerName &nbsp;·&nbsp; @Model.From.ToString("MMM d, yyyy") @Model.To.ToString("MMM d, yyyy")</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="get" class="d-flex gap-2 align-items-center">
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
</form>
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
asp-route-pdf="true"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
</a>
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
<div>
<span class="fw-semibold">@Model.CustomerName</span>
@if (!string.IsNullOrWhiteSpace(Model.CustomerAddress))
{
<span class="text-muted small ms-2">@Model.CustomerAddress</span>
}
</div>
<div class="text-muted small">@Model.CompanyName</div>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-dark">
<tr>
<th style="width:100px">Date</th>
<th style="width:120px">Type</th>
<th style="width:130px">Reference</th>
<th>Description</th>
<th class="text-end" style="width:110px">Debit</th>
<th class="text-end" style="width:110px">Credit</th>
<th class="text-end" style="width:120px">Balance</th>
</tr>
</thead>
<tbody>
<!-- Opening balance -->
<tr class="table-light fw-semibold">
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
<td colspan="5">Opening Balance</td>
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
</tr>
@if (!Model.Lines.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
</tr>
}
else
{
@foreach (var line in Model.Lines)
{
<tr>
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
<td>
<span class="badge @(line.Type == "Invoice" ? "bg-primary" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
@line.Type
</span>
</td>
<td class="small">@line.Reference</td>
<td class="small text-muted">@line.Description</td>
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
@line.RunningBalance.ToString("C")
</td>
</tr>
}
}
<!-- Closing balance -->
<tr class="table-secondary fw-bold">
<td colspan="6">Closing Balance</td>
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
@Model.ClosingBalance.ToString("C")
</td>
</tr>
</tbody>
</table>
</div>
</div>
@@ -180,6 +180,7 @@
</a>
</div>
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
<div id="earlyPaymentDiscountNotice" class="form-text text-success d-none"></div>
</div>
</div>
</div>
@@ -456,11 +457,52 @@
function onCustomerChanged(select) {
document.getElementById('hiddenCustomerId').value = select.value;
const customerId = parseInt(select.value) || 0;
const taxField = document.getElementById('TaxPercent');
if (taxField) {
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
recalcTotals();
}
if (!customerId) return;
// Fetch payment terms + tax rate for the selected customer
fetch(`/Invoices/GetCustomerPaymentTerms?customerId=${customerId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
// Auto-fill Terms dropdown
const termsSelect = document.getElementById('Terms');
if (termsSelect && data.paymentTerms) {
// Set the matching option, or fall back to the raw value
const opt = Array.from(termsSelect.options).find(o => o.value === data.paymentTerms);
if (opt) termsSelect.value = data.paymentTerms;
// Trigger due date recalculation (invoice-due-date.js listens to 'change')
termsSelect.dispatchEvent(new Event('change'));
}
// Show/hide early payment discount notice
const discountEl = document.getElementById('earlyPaymentDiscountNotice');
if (discountEl) {
if (data.earlyPaymentDiscountPercent > 0) {
discountEl.textContent = `${data.earlyPaymentDiscountPercent}% discount if paid within ${data.earlyPaymentDiscountDays} days`;
discountEl.classList.remove('d-none');
} else {
discountEl.classList.add('d-none');
}
}
}).catch(() => {});
// Fetch tax rate for the selected customer
fetch(`/Invoices/GetTaxRateForCustomer?customerId=${customerId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const taxField = document.getElementById('TaxPercent');
if (taxField) {
taxField.value = data.taxPercent ?? 0;
recalcTotals();
}
}).catch(() => {
// Fall back to client-side tax exempt check
const taxField = document.getElementById('TaxPercent');
if (taxField) {
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
recalcTotals();
}
});
}
// ── Merchandise combobox ────────────────────────────────────────────────
@@ -1516,6 +1516,7 @@
{
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
}
<li><hr class="dropdown-divider"></li>
@if (gearIsAdmin)
@@ -0,0 +1,63 @@
@model PowderCoating.Core.Entities.TaxRate
@{
ViewData["Title"] = "Add Tax Rate";
}
<div class="mb-4">
<h4 class="mb-0">Add Tax Rate</h4>
<p class="text-muted small mb-0">Define a named tax rate to auto-fill invoice tax percent by jurisdiction.</p>
</div>
<div class="card border-0 shadow-sm" style="max-width:600px">
<div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" placeholder="e.g., CA Sales Tax" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label asp-for="Rate" class="form-label fw-semibold">Rate (%) <span class="text-danger">*</span></label>
<div class="input-group">
<input asp-for="Rate" type="number" step="0.0001" min="0" max="100" class="form-control" placeholder="8.25" />
<span class="input-group-text">%</span>
</div>
<span asp-validation-for="Rate" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="State" class="form-label fw-semibold">State</label>
<input asp-for="State" class="form-control" placeholder="e.g., CA" maxlength="2" />
<span asp-validation-for="State" class="text-danger small"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label fw-semibold">Description</label>
<input asp-for="Description" class="form-control" placeholder="Optional notes" />
</div>
<div class="mb-3 form-check">
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
<label asp-for="IsDefault" class="form-check-label">
Default rate
<span class="text-muted small d-block">Automatically applied to new invoices for taxable customers.</span>
</label>
</div>
<div class="mb-4 form-check">
<input asp-for="IsActive" class="form-check-input" type="checkbox" checked />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save Tax Rate</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@@ -0,0 +1,62 @@
@model PowderCoating.Core.Entities.TaxRate
@{
ViewData["Title"] = "Edit Tax Rate";
}
<div class="mb-4">
<h4 class="mb-0">Edit Tax Rate</h4>
</div>
<div class="card border-0 shadow-sm" style="max-width:600px">
<div class="card-body">
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label asp-for="Rate" class="form-label fw-semibold">Rate (%) <span class="text-danger">*</span></label>
<div class="input-group">
<input asp-for="Rate" type="number" step="0.0001" min="0" max="100" class="form-control" />
<span class="input-group-text">%</span>
</div>
<span asp-validation-for="Rate" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="State" class="form-label fw-semibold">State</label>
<input asp-for="State" class="form-control" maxlength="2" />
</div>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label fw-semibold">Description</label>
<input asp-for="Description" class="form-control" />
</div>
<div class="mb-3 form-check">
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
<label asp-for="IsDefault" class="form-check-label">
Default rate
<span class="text-muted small d-block">Automatically applied to new invoices for taxable customers.</span>
</label>
</div>
<div class="mb-4 form-check">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@@ -0,0 +1,108 @@
@model List<PowderCoating.Core.Entities.TaxRate>
@{
ViewData["Title"] = "Tax Rates";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-0">Tax Rates</h4>
<p class="text-muted mb-0 small">Named rates used to auto-fill invoice tax percent when a taxable customer is selected.</p>
</div>
<a asp-action="Create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add Tax Rate
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-percent fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-1">No tax rates defined yet.</p>
<p class="small">Add a rate and mark it as default to auto-populate tax on invoices.</p>
<a asp-action="Create" class="btn btn-primary btn-sm mt-2">
<i class="bi bi-plus-circle me-1"></i>Add First Tax Rate
</a>
</div>
}
else
{
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Rate</th>
<th>State</th>
<th>Description</th>
<th class="text-center">Default</th>
<th class="text-center">Active</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var rate in Model)
{
<tr class="@(!rate.IsActive ? "opacity-50" : "")">
<td>
@rate.Name
@if (rate.IsDefault)
{
<span class="badge bg-success ms-1">Default</span>
}
</td>
<td>@rate.Rate.ToString("0.##")%</td>
<td>@(rate.State ?? "—")</td>
<td class="text-muted small">@(rate.Description ?? "—")</td>
<td class="text-center">
@if (rate.IsDefault)
{
<i class="bi bi-check-circle-fill text-success"></i>
}
</td>
<td class="text-center">
<form asp-action="ToggleActive" asp-route-id="@rate.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-link p-0 border-0"
title="@(rate.IsActive ? "Deactivate" : "Activate")">
<i class="bi @(rate.IsActive ? "bi-toggle-on text-success fs-5" : "bi-toggle-off text-muted fs-5")"></i>
</button>
</form>
</td>
<td class="text-end">
<a asp-action="Edit" asp-route-id="@rate.Id" class="btn btn-sm btn-outline-secondary me-1">
<i class="bi bi-pencil"></i>
</a>
@if (!rate.IsDefault)
{
<form asp-action="Delete" asp-route-id="@rate.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete this tax rate?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
@@ -10,6 +10,9 @@
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>Statement
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
@@ -0,0 +1,93 @@
@model PowderCoating.Application.DTOs.Accounting.VendorStatementDto
@{
ViewData["Title"] = $"Statement {Model.VendorName}";
}
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-0">Vendor Statement</h4>
<p class="text-muted mb-0">@Model.VendorName &nbsp;·&nbsp; @Model.From.ToString("MMM d, yyyy") @Model.To.ToString("MMM d, yyyy")</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="get" class="d-flex gap-2 align-items-center">
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
</form>
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
asp-route-pdf="true"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
</a>
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
<span class="fw-semibold">@Model.VendorName</span>
<div class="text-muted small">@Model.CompanyName</div>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-dark">
<tr>
<th style="width:100px">Date</th>
<th style="width:120px">Type</th>
<th style="width:130px">Reference</th>
<th>Description</th>
<th class="text-end" style="width:110px">Debit</th>
<th class="text-end" style="width:110px">Credit</th>
<th class="text-end" style="width:120px">Balance</th>
</tr>
</thead>
<tbody>
<tr class="table-light fw-semibold">
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
<td colspan="5">Opening Balance</td>
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
</tr>
@if (!Model.Lines.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
</tr>
}
else
{
@foreach (var line in Model.Lines)
{
<tr>
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
<td>
<span class="badge @(line.Type == "Bill" ? "bg-danger" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
@line.Type
</span>
</td>
<td class="small">@line.Reference</td>
<td class="small text-muted">@line.Description</td>
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
@line.RunningBalance.ToString("C")
</td>
</tr>
}
}
<tr class="table-secondary fw-bold">
<td colspan="6">Closing Balance</td>
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
@Model.ClosingBalance.ToString("C")
</td>
</tr>
</tbody>
</table>
</div>
</div>