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>
This commit is contained in:
2026-05-20 13:16:50 -04:00
parent 7fa385aeb8
commit 4ac62551f4
@@ -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,7 +116,7 @@
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");
const SURCHARGE_TYPE = '@Model.SurchargeType'; const SURCHARGE_TYPE = '@Model.SurchargeType';
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4"); const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4");
const SUCCESS_URL = `/pay/${TOKEN}/success`; const SUCCESS_URL = `/pay/${TOKEN}/success`;
@@ -125,9 +125,8 @@
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) {