Compare commits

...

10 Commits

Author SHA1 Message Date
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 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:18:04 -04:00
spouliot cfe937c0c3 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 14:18:03 -04:00
spouliot 3ad6b0d08f 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 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
spouliot 4a7087cc0c Fix NoExtraLayerCharge dropped in DeleteItem pricing recalculation
After deleting a job item, the remaining-items DTO projection was missing
NoExtraLayerCharge, causing PricingCalculationService to treat all coats as
extra-charge when recalculating the job total post-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:37:29 -04:00
spouliot 59b152c89f Fix noExtraLayerCharge missing from Job Details wizard item projection
WizardExistingItems coat serialization in Details GET omitted noExtraLayerCharge,
so editing a line item from the Details page always lost the no-charge flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:36:04 -04:00
spouliot 441898b52f Fix NoExtraLayerCharge not persisting on quotes and job EditItems reload
- QuotePricingAssemblyService.BuildQuoteItemCoat: map NoExtraLayerCharge from
  CreateQuoteItemCoatDto to QuoteItemCoat on every quote save (was always omitted)
- JobsController.EditItems GET: include NoExtraLayerCharge in coat mapping when
  reloading existing items for the wizard (was dropped, causing revert on second edit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:33:50 -04:00
spouliot 3e30397302 Sync master back to dev (IF EXISTS migration hotfix) 2026-05-19 18:24:39 -04:00
9 changed files with 124 additions and 39 deletions
@@ -20,7 +20,6 @@ public interface IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId);
@@ -33,7 +32,6 @@ public interface IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId);
}
@@ -264,6 +264,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId)
{
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string>
{
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId)
{
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string>
{
@@ -1461,6 +1461,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 () =>
{
@@ -1480,6 +1481,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);
@@ -1531,7 +1601,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)
@@ -477,6 +477,7 @@ public class JobsController : Controller
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
noExtraLayerCharge = c.NoExtraLayerCharge,
notes = c.Notes
}),
prepServices = ji.PrepServices.Select(ps => new {
@@ -2946,6 +2947,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -3126,7 +3128,8 @@ public class JobsController : Controller
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb
PowderCostPerLb = c.PowderCostPerLb,
NoExtraLayerCharge = c.NoExtraLayerCharge
}).ToList()
}).ToList();
@@ -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,
@@ -127,8 +126,6 @@ public class PaymentController : Controller
return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
@@ -136,7 +133,6 @@ public class PaymentController : Controller
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id);
@@ -261,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,
@@ -296,7 +291,6 @@ public class PaymentController : Controller
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
@@ -304,7 +298,6 @@ public class PaymentController : Controller
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customerEmail,
quoteNumber: quote.QuoteNumber,
quoteId: quote.Id);
@@ -942,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; }
@@ -963,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) {