Compare commits

..

5 Commits

Author SHA1 Message Date
spouliot 81dc34bab4 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:03 -04:00
spouliot b9e9449c8b Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:42:54 -04:00
spouliot fd38785942 Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:27:10 -04:00
spouliot 33277de727 Payment hardening: InvariantCulture on JS literals, remove dead CustomerEmail
Razor numeric expressions emitted into JS literals (MAX_TOTAL,
SURCHARGE_VALUE) now use InvariantCulture, matching the pattern already
used on the deposit page. Without this, a server culture with comma
decimal separators would silently truncate values like 2.5% to 2.

CustomerEmail removed from PaymentPageViewModel and
DepositPaymentPageViewModel — it was populated from the DB on every
payment page load but never consumed after receipt_email was removed
from the Stripe PaymentIntent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:20:47 -04:00
spouliot 4ac62551f4 Fix online payment surcharge — input and validation based on total
The payment amount input was capped at BalanceDue (e.g. $5.00) but the
customer was being charged TotalWithSurcharge (e.g. $5.15), causing
validation to reject any attempt to pay the correct total amount.

Input now defaults to and accepts up to TotalWithSurcharge. On submit,
the JS back-calculates the base amount before sending to the server so
the server-side surcharge addition produces the same PaymentIntent total
that the Stripe Elements were initialized with — eliminating the
amount-mismatch error from Stripe on confirmation.

Also calls elements.update() when the amount changes so partial payments
don't cause an Elements/PaymentIntent amount mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:16:50 -04:00
5 changed files with 119 additions and 27 deletions
@@ -1480,6 +1480,7 @@ public class InvoicesController : Controller
} }
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var totalCreditCreated = 0m; // populated inside transaction, used in success message
await _unitOfWork.ExecuteInTransactionAsync(async () => await _unitOfWork.ExecuteInTransactionAsync(async () =>
{ {
@@ -1499,6 +1500,75 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
} }
// Re-release any deposits that were applied to this invoice so they can be
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
var totalDepositReleased = 0m;
foreach (var deposit in appliedDeposits)
{
deposit.AppliedToInvoiceId = null;
deposit.AppliedDate = null;
deposit.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Deposits.UpdateAsync(deposit);
totalDepositReleased += deposit.Amount;
}
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
// pattern as the rest of the void (regular payment GL entries are also left as-is).
if (totalDepositReleased > 0)
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
}
// Convert non-deposit payments (cash, card, check, online) to customer credits so
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
// Deposit record linked to the same job; it will auto-apply when the replacement
// invoice is created, exactly like a normal deposit.
var nonDepositPayments = invoice.Payments
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
.ToList();
if (nonDepositPayments.Any())
{
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existingNums = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber).ToList();
var maxNum = 0;
foreach (var rn in existingNums)
{
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
}
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
foreach (var payment in nonDepositPayments)
{
maxNum++;
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
{
CompanyId = invoice.CompanyId,
CustomerId = invoice.CustomerId,
JobId = invoice.JobId,
Amount = payment.Amount,
PaymentMethod = payment.PaymentMethod,
ReceivedDate = payment.PaymentDate,
Reference = payment.Reference,
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
CreatedAt = DateTime.UtcNow
});
totalCreditCreated += payment.Amount;
}
// CR CustomerDeposits to create the liability matching the cash already in Checking
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
}
// Void any gift certificates that were generated from this invoice. // Void any gift certificates that were generated from this invoice.
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it. // Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId); var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
@@ -1550,7 +1620,10 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided."; var creditMsg = totalCreditCreated > 0
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
: "";
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
catch (Exception ex) catch (Exception ex)
@@ -75,7 +75,6 @@ public class PaymentController : Controller
CustomerName = customer != null CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer", : "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName, CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue, BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total, InvoiceTotal = invoice.Total,
@@ -258,7 +257,6 @@ public class PaymentController : Controller
CustomerName = customer != null CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer", : quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName, CompanyName = company!.CompanyName,
DepositAmount = depositAmount, DepositAmount = depositAmount,
QuoteTotal = quote.Total, QuoteTotal = quote.Total,
@@ -937,7 +935,6 @@ public class PaymentPageViewModel
public int InvoiceId { get; set; } public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; } public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; } public decimal InvoiceTotal { get; set; }
@@ -958,7 +955,6 @@ public class DepositPaymentPageViewModel
public int QuoteId { get; set; } public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; } public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; } public decimal QuoteTotal { get; set; }
+5
View File
@@ -653,6 +653,11 @@ app.Use(async (context, next) =>
context.Response.Headers.Append("Permissions-Policy", context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=()"); "geolocation=(), microphone=(), camera=()");
// Prevent browsers from caching authenticated pages — avoids stale data and
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
if (context.User.Identity?.IsAuthenticated == true)
context.Response.Headers.Append("Cache-Control", "no-store");
await next(); await next();
}); });
@@ -28,6 +28,9 @@
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true; var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled; var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel; var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
var nonDepositPaymentTotal = Model.Payments
.Where(p => !(p.Reference ?? "").StartsWith("Deposit "))
.Sum(p => p.Amount);
} }
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -406,7 +409,7 @@
@if (!isVoided) @if (!isVoided)
{ {
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment" <button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), '@(p.Reference ?? "")', '@(p.Notes ?? "")', @(p.DepositAccountId?.ToString() ?? "null"))"> onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), @Json.Serialize(p.Reference ?? ""), @Json.Serialize(p.Notes ?? ""), @(p.DepositAccountId?.ToString() ?? "null"))">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id" <form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
@@ -694,7 +697,9 @@
@if (!isVoided && Model.Status != InvoiceStatus.Paid) @if (!isVoided && Model.Status != InvoiceStatus.Paid)
{ {
<form asp-action="Void" asp-route-id="@Model.Id" method="post" <form asp-action="Void" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Void this invoice? This will reverse the remaining balance on the customer account.')"> onsubmit="return confirm('@(nonDepositPaymentTotal > 0
? $"Void this invoice? {nonDepositPaymentTotal.ToString("C")} in payments will be converted to a customer credit and auto-applied to the next invoice."
: "Void this invoice? This will reverse the remaining balance on the customer account.")')">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning w-100"> <button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-x-circle me-2"></i>Void Invoice <i class="bi bi-x-circle me-2"></i>Void Invoice
@@ -75,11 +75,11 @@
<div class="input-group mb-1"> <div class="input-group mb-1">
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
<input type="number" id="paymentAmount" class="form-control" <input type="number" id="paymentAmount" class="form-control"
value="@Model.BalanceDue.ToString("F2")" value="@Model.TotalWithSurcharge.ToString("F2")"
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01" min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
oninput="updateSurcharge()" /> oninput="updateSurcharge()" />
</div> </div>
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div> <div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
</div> </div>
</div> </div>
@@ -116,18 +116,17 @@
const STRIPE_PK = '@Model.StripePublishableKey'; const STRIPE_PK = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId'; const ACCOUNT_ID = '@Model.StripeAccountId';
const TOKEN = '@Model.Token'; const TOKEN = '@Model.Token';
const MAX_AMOUNT = @Model.BalanceDue.ToString("F2"); const MAX_TOTAL = @Model.TotalWithSurcharge.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
const SURCHARGE_TYPE = '@Model.SurchargeType'; const SURCHARGE_TYPE = '@Model.SurchargeType';
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4"); const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);
const SUCCESS_URL = `/pay/${TOKEN}/success`; const SUCCESS_URL = `/pay/${TOKEN}/success`;
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID }); const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
let elements, paymentElement; let elements, paymentElement;
async function initStripe() { async function initStripe() {
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT; // Input value is the total the customer pays (including fee)
const surcharge = calcSurcharge(amount); const total = parseFloat(document.getElementById('paymentAmount').value) || MAX_TOTAL;
const total = Math.round((amount + surcharge) * 100) / 100;
elements = stripe.elements({ elements = stripe.elements({
mode: 'payment', mode: 'payment',
@@ -141,12 +140,15 @@
paymentElement.mount('#payment-element'); paymentElement.mount('#payment-element');
} }
function calcSurcharge(amount) { // Back-calculates the base invoice amount from a total that includes the fee.
// Server receives the base and adds surcharge on top — both paths arrive at the same
// PaymentIntent amount so Stripe.js never sees an amount mismatch.
function calcBaseFromTotal(total) {
if (SURCHARGE_TYPE === 'Percent') if (SURCHARGE_TYPE === 'Percent')
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100; return Math.round(total / (1 + SURCHARGE_VALUE / 100) * 100) / 100;
if (SURCHARGE_TYPE === 'Flat') if (SURCHARGE_TYPE === 'Flat')
return SURCHARGE_VALUE; return Math.max(0, Math.round((total - SURCHARGE_VALUE) * 100) / 100);
return 0; return total;
} }
function formatCurrency(val) { function formatCurrency(val) {
@@ -154,9 +156,9 @@
} }
function updateSurcharge() { function updateSurcharge() {
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT); const total = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_TOTAL);
const surcharge = calcSurcharge(amount); const base = calcBaseFromTotal(total);
const total = amount + surcharge; const surcharge = Math.round((total - base) * 100) / 100;
const surchargeEl = document.getElementById('surchargeDisplay'); const surchargeEl = document.getElementById('surchargeDisplay');
const totalEl = document.getElementById('totalWithSurchargeDisplay'); const totalEl = document.getElementById('totalWithSurchargeDisplay');
@@ -164,7 +166,10 @@
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge); if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
if (totalEl) totalEl.textContent = formatCurrency(total); if (totalEl) totalEl.textContent = formatCurrency(total);
if (btnAmtEl) btnAmtEl.textContent = surcharge > 0 ? formatCurrency(total) : formatCurrency(amount); if (btnAmtEl) btnAmtEl.textContent = formatCurrency(total);
// Keep Elements in sync so confirmPayment amount matches the PaymentIntent
if (elements) elements.update({ amount: Math.round(total * 100) });
} }
async function submitPayment() { async function submitPayment() {
@@ -172,13 +177,21 @@
const msgEl = document.getElementById('payment-message'); const msgEl = document.getElementById('payment-message');
const amountInput = document.getElementById('paymentAmount'); const amountInput = document.getElementById('paymentAmount');
const amount = parseFloat(amountInput.value); const total = parseFloat(amountInput.value);
if (!amount || amount <= 0 || amount > MAX_AMOUNT) { if (!total || total <= 0 || total > MAX_TOTAL) {
msgEl.textContent = 'Please enter a valid payment amount.'; msgEl.textContent = 'Please enter a valid payment amount.';
msgEl.style.display = ''; msgEl.style.display = '';
return; return;
} }
// Back-calculate base; server adds surcharge on top as usual
const base = calcBaseFromTotal(total);
if (base <= 0) {
msgEl.textContent = 'Payment amount must exceed the convenience fee.';
msgEl.style.display = '';
return;
}
btn.disabled = true; btn.disabled = true;
document.getElementById('submitText').style.display = 'none'; document.getElementById('submitText').style.display = 'none';
document.getElementById('submitSpinner').style.display = ''; document.getElementById('submitSpinner').style.display = '';
@@ -195,13 +208,13 @@
return; return;
} }
// Create PaymentIntent on server // Create PaymentIntent on server — send base amount (server adds surcharge)
let clientSecret; let clientSecret;
try { try {
const resp = await fetch(`/pay/${TOKEN}/intent`, { const resp = await fetch(`/pay/${TOKEN}/intent`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }) body: JSON.stringify({ amount: base })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {