Sweep all .cshtml files for encoding corruption; add pre-commit guard

Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 21:37:10 -04:00
parent 21b39161a3
commit a0bdd2b5b4
252 changed files with 1785 additions and 1633 deletions
@@ -5,7 +5,7 @@
ViewData["Title"] = "Create Invoice";
ViewData["PageIcon"] = "bi-receipt";
ViewData["PageHelpTitle"] = "Create Invoice";
ViewData["PageHelpContent"] = "Invoices start as Drafts you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job&apos;s items but you can add, edit, or remove any line before sending. Partial payments are supported after sending.";
ViewData["PageHelpContent"] = "Invoices start as Drafts &mdash; you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job&apos;s items but you can add, edit, or remove any line before sending. Partial payments are supported after sending.";
var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName as string;
var customers = ViewBag.Customers as List<Customer>;
@@ -130,7 +130,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue this is what appears on the printed invoice and determines when payment terms start counting. Due Date drives overdue status and A/R aging reports. Payment Terms is free text (e.g., 'Net 30') that prints on the invoice; it defaults from the customer's settings but you can override it here.">
data-bs-content="Invoice Date is the date of issue &mdash; this is what appears on the printed invoice and determines when payment terms start counting. Due Date drives overdue status and A/R aging reports. Payment Terms is free text (e.g., 'Net 30') that prints on the invoice; it defaults from the customer's settings but you can override it here.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -143,7 +143,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Date"
data-bs-content="The date the invoice is issued. This appears on the printed document and is the reference date for payment terms e.g., Net 30 means payment is due 30 days after this date. Defaults to today.">
data-bs-content="The date the invoice is issued. This appears on the printed document and is the reference date for payment terms &mdash; e.g., Net 30 means payment is due 30 days after this date. Defaults to today.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -196,7 +196,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Line Items"
data-bs-content="Each row is a billable line on the invoice. Pre-populated from the job's items. Qty × Unit Price = Total per line; you can override the Total directly too. Color is optional it appears under the description on the printed invoice. Add manual lines for anything not in the job (e.g., pickup fee, rush charge).">
data-bs-content="Each row is a billable line on the invoice. Pre-populated from the job's items. Qty × Unit Price = Total per line; you can override the Total directly too. Color is optional &mdash; it appears under the description on the printed invoice. Add manual lines for anything not in the job (e.g., pickup fee, rush charge).">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -321,7 +321,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notes"
data-bs-content="Customer Notes appear on the printed and emailed invoice use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff here in the app and never sent to the customer.">
data-bs-content="Customer Notes appear on the printed and emailed invoice &mdash; use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff here in the app and never sent to the customer.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -354,7 +354,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Totals"
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax use it for customer-specific deals or courtesy adjustments. Tax % is applied to (Subtotal Discount). Both default from the company settings but can be overridden per invoice.">
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax &mdash; use it for customer-specific deals or courtesy adjustments. Tax % is applied to (Subtotal Discount). Both default from the company settings but can be overridden per invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -584,7 +584,7 @@
onmousedown="event.preventDefault();merchComboSelect(this)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseleave="this.classList.contains('mc-active')?null:this.style.background=''">
${i.name}${i.sKU ? ' <span class="text-muted">[' + i.sKU + ']</span>' : ''} <span class="text-muted"> ${formatCurrency(i.defaultPrice)}</span>
${i.name}${i.sKU ? ' <span class="text-muted">[' + i.sKU + ']</span>' : ''} <span class="text-muted">&mdash; ${formatCurrency(i.defaultPrice)}</span>
</div>`
).join('')
).join('');
@@ -656,7 +656,7 @@
}
function addGiftCertLineItem(btn) {
// Bootstrap teleports modals to <body> navigate relative to the button
// Bootstrap teleports modals to <body> &mdash; navigate relative to the button
const modalEl = btn ? btn.closest('.modal') : document.getElementById('gcModal');
const q = sel => modalEl ? modalEl.querySelector(sel) : document.querySelector(sel);
@@ -17,8 +17,8 @@
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
var hasSms = !string.IsNullOrWhiteSpace(smsPhone) && Model.CustomerNotifyBySms;
var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels show choice modal
var directSendSms = !hasEmail && hasSms; // SMS only skip modal
var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels &mdash; show choice modal
var directSendSms = !hasEmail && hasSms; // SMS only &mdash; skip modal
var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos).Any();
var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0;
var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits;
@@ -80,7 +80,7 @@
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
<i class="bi bi-envelope-slash fs-5"></i>
<span>
<strong>@Model.CustomerName</strong> has no email address on file you'll be prompted to enter one when sending.
<strong>@Model.CustomerName</strong> has no email address on file &mdash; you'll be prompted to enter one when sending.
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
</span>
</div>
@@ -179,12 +179,12 @@
<div class="col-md-4">
<label class="text-muted small mb-1">Due Date</label>
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "")
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "&mdash;")
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Sent Date</label>
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "")</p>
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "&mdash;")</p>
</div>
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
{
@@ -350,7 +350,7 @@
</span>
</td>
<td class="text-muted">
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "")
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "&mdash;")
</td>
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
<td>
@@ -396,7 +396,7 @@
<tr>
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
<td>@p.PaymentMethodDisplay</td>
<td>@(p.Reference ?? "")</td>
<td>@(p.Reference ?? "&mdash;")</td>
<td>
@if (!string.IsNullOrEmpty(p.DepositAccountName))
{
@@ -404,10 +404,10 @@
}
else
{
<span class="text-muted"></span>
<span class="text-muted">&mdash;</span>
}
</td>
<td>@(p.RecordedByName ?? "")</td>
<td>@(p.RecordedByName ?? "&mdash;")</td>
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
<td class="text-end">
@if (!isVoided)
@@ -463,7 +463,7 @@
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
<td>@r.RefundMethodDisplay</td>
<td>@r.Reason</td>
<td>@(r.Reference ?? "")</td>
<td>@(r.Reference ?? "&mdash;")</td>
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
<td class="text-nowrap">
@@ -575,7 +575,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Invoice Actions"
data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts.">
data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported &mdash; record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -601,7 +601,7 @@
}
else if (showSendModal)
{
@* Both email + SMS available let staff choose *@
@* Both email + SMS available &mdash; let staff choose *@
<button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendChannelModal">
<i class="bi bi-send me-2"></i>Send Invoice
@@ -609,7 +609,7 @@
}
else if (directSendSms)
{
@* SMS only send directly *@
@* SMS only &mdash; send directly *@
<button type="button" class="btn btn-primary w-100"
onclick="submitSendInvoice(false, true)">
<i class="bi bi-send me-2"></i>Send Invoice via SMS
@@ -792,7 +792,7 @@
{
<div class="mb-2">
<span class="badge bg-success-subtle text-success mb-2">
<i class="bi bi-check-circle me-1"></i>Active expires @Model.PaymentLinkExpiresAt!.Value.ToString("MMM d")
<i class="bi bi-check-circle me-1"></i>Active &mdash; expires @Model.PaymentLinkExpiresAt!.Value.ToString("MMM d")
</span>
<div class="input-group input-group-sm">
<input type="text" id="paymentLinkInput" class="form-control font-monospace"
@@ -970,7 +970,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Payment Reference"
data-bs-content="Optional identifier for reconciliation e.g., the check number, last 4 digits of the card, ACH transaction ID, or Venmo/PayPal confirmation code. Appears in payment history so you can match payments to your bank statement.">
data-bs-content="Optional identifier for reconciliation &mdash; e.g., the check number, last 4 digits of the card, ACH transaction ID, or Venmo/PayPal confirmation code. Appears in payment history so you can match payments to your bank statement.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -1189,7 +1189,7 @@
</div>
<div id="refundAlertCredit" class="alert alert-success small mb-3 d-none">
<i class="bi bi-piggy-bank me-1"></i>
The refund amount will be added to the customer's store credit balance immediately no manual action needed.
The refund amount will be added to the customer's store credit balance immediately &mdash; no manual action needed.
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
@@ -1326,7 +1326,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Select Credit Memo <span class="text-danger">*</span></label>
<select name="CreditMemoId" class="form-select" required>
<option value=""> Select </option>
<option value="">&mdash; Select &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos)
{
<option value="@item.Value">@item.Text</option>
@@ -1340,7 +1340,7 @@
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01"
max="@Model.BalanceDue.ToString("F2")" value="@Model.BalanceDue.ToString("F2")" required />
</div>
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C") the system will cap at the memo's remaining balance.</div>
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C") &mdash; the system will cap at the memo's remaining balance.</div>
</div>
</div>
<div class="modal-footer">
@@ -1422,7 +1422,7 @@
<div class="mb-3">
<label class="form-label">Bad Debt Expense Account</label>
<select name="expenseAccountId" class="form-select">
<option value=""> Use default bad debt account </option>
<option value="">&mdash; Use default bad debt account &mdash;</option>
@if (ViewBag.ExpenseAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
@@ -1503,7 +1503,7 @@
document.getElementById('gcAmountInput').value = max.toFixed(2);
document.getElementById('gcAmountInput').max = max;
const expiry = data.expiryDate ? ` · Expires ${data.expiryDate}` : '';
result.innerHTML = `<div class="alert alert-success py-1 mb-0 small"><i class="bi bi-check-circle me-1"></i><strong>${data.certificateCode}</strong> $${data.remainingBalance.toFixed(2)} remaining${expiry}</div>`;
result.innerHTML = `<div class="alert alert-success py-1 mb-0 small"><i class="bi bi-check-circle me-1"></i><strong>${data.certificateCode}</strong> &mdash; $${data.remainingBalance.toFixed(2)} remaining${expiry}</div>`;
}
} catch { result.innerHTML = '<div class="alert alert-danger py-1 mb-0 small">Lookup failed.</div>'; }
document.getElementById('gcLookupSpinner').style.display = 'none';
@@ -1616,7 +1616,7 @@
<td class="small">${escHtml(n.type.replace(/([A-Z])/g, ' $1').trim())}</td>
<td class="small"><i class="bi ${channelIcon} me-1"></i>${escHtml(n.channel)}</td>
<td class="small">${escHtml(n.recipientName)}<br><span class="text-muted">${escHtml(n.recipient)}</span></td>
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted"></span>'}</td>
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">&mdash;</span>'}</td>
<td><span class="badge bg-${statusClass}">${escHtml(n.status)}</span>${expandBtn}</td>
</tr>${errorRow}`;
}).join('');
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
@{
ViewData["Title"] = "Edit Invoice";
@@ -38,7 +38,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice &mdash; changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -163,7 +163,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notes"
data-bs-content="Customer Notes appear on the printed and emailed invoice use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
data-bs-content="Customer Notes appear on the printed and emailed invoice &mdash; use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -196,7 +196,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Totals"
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax. Tax % is applied to (Subtotal ∠Discount). Both default from the company settings but can be overridden for this invoice.">
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax. Tax % is applied to (Subtotal âˆ' Discount). Both default from the company settings but can be overridden for this invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -85,7 +85,7 @@
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
{
<span class="badge bg-success fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Paid @DateTime.Now.ToString("MMMM yyyy")
<i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy")
</span>
}
else if (thisMonthOnly)
@@ -107,7 +107,7 @@
{
var net = inv.OnlineAmountPaid;
var gross = inv.OnlineAmountPaid + inv.OnlineSurchargeCollected;
var dateDisplay = inv.PaidDate.HasValue ? inv.PaidDate.Value.ToString("MMM d, yyyy") : (inv.UpdatedAt?.ToString("MMM d, yyyy") ?? "");
var dateDisplay = inv.PaidDate.HasValue ? inv.PaidDate.Value.ToString("MMM d, yyyy") : (inv.UpdatedAt?.ToString("MMM d, yyyy") ?? "&mdash;");
var statusClass = inv.OnlinePaymentStatus switch
{
PowderCoating.Core.Enums.OnlinePaymentStatus.Paid => "bg-success-subtle text-success",
@@ -117,7 +117,7 @@
};
var customerName = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: "";
: "&mdash;";
<tr>
<td>
<a asp-action="Details" asp-route-id="@inv.Id" class="fw-semibold text-decoration-none">
@@ -134,7 +134,7 @@
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
{
<code class="small" title="@inv.StripePaymentIntentId">
@inv.StripePaymentIntentId[..Math.Min(20, inv.StripePaymentIntentId.Length)]
@inv.StripePaymentIntentId[..Math.Min(20, inv.StripePaymentIntentId.Length)]&hellip;
</code>
}
</td>
@@ -185,12 +185,12 @@
<tbody>
@foreach (var r in Model.Refunds)
{
var invNum = r.Invoice?.InvoiceNumber ?? "";
var invNum = r.Invoice?.InvoiceNumber ?? "&mdash;";
var invId = r.Invoice?.Id;
var cust = r.Invoice?.Customer;
var custName = cust != null
? (cust.CompanyName ?? $"{cust.ContactFirstName} {cust.ContactLastName}".Trim())
: "";
: "&mdash;";
var statusClass = r.Status switch
{
PowderCoating.Core.Enums.RefundStatus.Issued => "bg-success-subtle text-success",