From 4ac62551f476d7a4c2478dfac4bdc0bf05accf53 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 13:16:50 -0400 Subject: [PATCH] =?UTF-8?q?Fix=20online=20payment=20surcharge=20=E2=80=94?= =?UTF-8?q?=20input=20and=20validation=20based=20on=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Views/Payment/Index.cshtml | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/PowderCoating.Web/Views/Payment/Index.cshtml b/src/PowderCoating.Web/Views/Payment/Index.cshtml index 744bc6a..6f40a74 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,7 +116,7 @@ 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"); const SURCHARGE_TYPE = '@Model.SurchargeType'; const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4"); const SUCCESS_URL = `/pay/${TOKEN}/success`; @@ -125,9 +125,8 @@ 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) {