From fdac0240d19dede6388d69b5200a86db564ad07a Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 14:17:57 -0400 Subject: [PATCH 1/4] Fix Stripe receipt_email + online payment surcharge and hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Interfaces/IStripeConnectService.cs | 2 - .../Services/StripeConnectService.cs | 4 -- .../Controllers/PaymentController.cs | 9 ---- .../Views/Payment/Index.cshtml | 53 ++++++++++++------- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs b/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs index 9b7d162..432f48c 100644 --- a/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs +++ b/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs @@ -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); } diff --git a/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs b/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs index 4034057..caf1078 100644 --- a/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs +++ b/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs @@ -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 { @@ -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 { diff --git a/src/PowderCoating.Web/Controllers/PaymentController.cs b/src/PowderCoating.Web/Controllers/PaymentController.cs index abfd89e..8227eaf 100644 --- a/src/PowderCoating.Web/Controllers/PaymentController.cs +++ b/src/PowderCoating.Web/Controllers/PaymentController.cs @@ -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; } diff --git a/src/PowderCoating.Web/Views/Payment/Index.cshtml b/src/PowderCoating.Web/Views/Payment/Index.cshtml index 744bc6a..db4ca00 100644 --- a/src/PowderCoating.Web/Views/Payment/Index.cshtml +++ b/src/PowderCoating.Web/Views/Payment/Index.cshtml @@ -75,11 +75,11 @@
$
-
Max: @Model.BalanceDue.ToString("C")
+
Max: @Model.TotalWithSurcharge.ToString("C")
@@ -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) { From 3ad6b0d08f82e4461f0b7ff049d8e9f98615ce3b Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 13:27:10 -0400 Subject: [PATCH 2/4] 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 --- .../Controllers/InvoicesController.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index ddb3537..dc1233d 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -1480,6 +1480,29 @@ 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); + } + // 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); From cfe937c0c3d49826891c991cc05fce022ceb8041 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 13:42:54 -0400 Subject: [PATCH 3/4] 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 --- .../Controllers/InvoicesController.cs | 52 ++++++++++++++++++- .../Views/Invoices/Details.cshtml | 7 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index dc1233d..026b12e 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -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 () => { @@ -1503,6 +1504,52 @@ public class InvoicesController : Controller 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); @@ -1554,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) diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index f301a80..5c484f9 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -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); }
@@ -694,7 +697,9 @@ @if (!isVoided && Model.Status != InvoiceStatus.Paid) {
+ 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()