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 totalCreditCreated = 0m; // populated inside transaction, used in success message
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
@@ -1499,6 +1500,75 @@ public class InvoicesController : Controller
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.
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
@@ -1550,7 +1620,10 @@ public class InvoicesController : Controller
}); // 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 });
}
catch (Exception ex)
@@ -75,7 +75,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total,
@@ -258,7 +257,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName,
DepositAmount = depositAmount,
QuoteTotal = quote.Total,
@@ -937,7 +935,6 @@ public class PaymentPageViewModel
public int InvoiceId { get; set; }
public string Token { 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 decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; }
@@ -958,7 +955,6 @@ public class DepositPaymentPageViewModel
public int QuoteId { get; set; }
public string Token { 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 decimal DepositAmount { 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",
"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();
});
@@ -28,6 +28,9 @@
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
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">
@@ -406,7 +409,7 @@
@if (!isVoided)
{
<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>
</button>
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
@@ -694,7 +697,9 @@
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
{
<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()
<button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-x-circle me-2"></i>Void Invoice
@@ -75,11 +75,11 @@
<div class="input-group mb-1">
<span class="input-group-text">$</span>
<input type="number" id="paymentAmount" class="form-control"
value="@Model.BalanceDue.ToString("F2")"
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01"
value="@Model.TotalWithSurcharge.ToString("F2")"
min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
oninput="updateSurcharge()" />
</div>
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div>
<div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
</div>
</div>
@@ -116,18 +116,17 @@
const STRIPE_PK = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId';
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_VALUE = @Model.SurchargeValue.ToString("F4");
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);
const SUCCESS_URL = `/pay/${TOKEN}/success`;
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
let elements, paymentElement;
async function initStripe() {
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT;
const surcharge = calcSurcharge(amount);
const total = Math.round((amount + surcharge) * 100) / 100;
// Input value is the total the customer pays (including fee)
const total = parseFloat(document.getElementById('paymentAmount').value) || MAX_TOTAL;
elements = stripe.elements({
mode: 'payment',
@@ -141,12 +140,15 @@
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')
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100;
return Math.round(total / (1 + SURCHARGE_VALUE / 100) * 100) / 100;
if (SURCHARGE_TYPE === 'Flat')
return SURCHARGE_VALUE;
return 0;
return Math.max(0, Math.round((total - SURCHARGE_VALUE) * 100) / 100);
return total;
}
function formatCurrency(val) {
@@ -154,9 +156,9 @@
}
function updateSurcharge() {
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT);
const surcharge = calcSurcharge(amount);
const total = amount + surcharge;
const total = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_TOTAL);
const base = calcBaseFromTotal(total);
const surcharge = Math.round((total - base) * 100) / 100;
const surchargeEl = document.getElementById('surchargeDisplay');
const totalEl = document.getElementById('totalWithSurchargeDisplay');
@@ -164,7 +166,10 @@
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
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() {
@@ -172,13 +177,21 @@
const msgEl = document.getElementById('payment-message');
const amountInput = document.getElementById('paymentAmount');
const amount = parseFloat(amountInput.value);
if (!amount || amount <= 0 || amount > MAX_AMOUNT) {
const total = parseFloat(amountInput.value);
if (!total || total <= 0 || total > MAX_TOTAL) {
msgEl.textContent = 'Please enter a valid payment amount.';
msgEl.style.display = '';
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;
document.getElementById('submitText').style.display = 'none';
document.getElementById('submitSpinner').style.display = '';
@@ -195,13 +208,13 @@
return;
}
// Create PaymentIntent on server
// Create PaymentIntent on server — send base amount (server adds surcharge)
let clientSecret;
try {
const resp = await fetch(`/pay/${TOKEN}/intent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
body: JSON.stringify({ amount: base })
});
const data = await resp.json();
if (!resp.ok) {