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
@@ -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>