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
@@ -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('');