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>
This commit is contained in:
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId);
|
int invoiceId);
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId);
|
int quoteId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId)
|
int invoiceId)
|
||||||
{
|
{
|
||||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Invoice {invoiceNumber}",
|
Description = $"Invoice {invoiceNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId)
|
int quoteId)
|
||||||
{
|
{
|
||||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Deposit for quote {quoteNumber}",
|
Description = $"Deposit for quote {quoteNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1461,6 +1461,7 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var totalCreditCreated = 0m; // populated inside transaction, used in success message
|
||||||
|
|
||||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1480,6 +1481,75 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
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.
|
// 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.
|
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||||
@@ -1531,7 +1601,10 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // 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 });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: "Valued Customer",
|
: "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
BalanceDue = invoice.BalanceDue,
|
BalanceDue = invoice.BalanceDue,
|
||||||
InvoiceTotal = invoice.Total,
|
InvoiceTotal = invoice.Total,
|
||||||
@@ -127,8 +126,6 @@ public class PaymentController : Controller
|
|||||||
return BadRequest(new { error = "Invalid payment amount." });
|
return BadRequest(new { error = "Invalid payment amount." });
|
||||||
|
|
||||||
var surcharge = CalculateSurcharge(request.Amount, company!);
|
var surcharge = CalculateSurcharge(request.Amount, company!);
|
||||||
var customer = await _context.Customers.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreatePaymentIntentAsync(
|
await _stripeConnect.CreatePaymentIntentAsync(
|
||||||
@@ -136,7 +133,6 @@ public class PaymentController : Controller
|
|||||||
invoiceTotal: request.Amount,
|
invoiceTotal: request.Amount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customer?.Email ?? string.Empty,
|
|
||||||
invoiceNumber: invoice.InvoiceNumber,
|
invoiceNumber: invoice.InvoiceNumber,
|
||||||
invoiceId: invoice.Id);
|
invoiceId: invoice.Id);
|
||||||
|
|
||||||
@@ -261,7 +257,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: quote.ProspectContactName ?? "Valued Customer",
|
: quote.ProspectContactName ?? "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
DepositAmount = depositAmount,
|
DepositAmount = depositAmount,
|
||||||
QuoteTotal = quote.Total,
|
QuoteTotal = quote.Total,
|
||||||
@@ -296,7 +291,6 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
||||||
var surcharge = CalculateSurcharge(depositAmount, company!);
|
var surcharge = CalculateSurcharge(depositAmount, company!);
|
||||||
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
||||||
@@ -304,7 +298,6 @@ public class PaymentController : Controller
|
|||||||
depositAmount: depositAmount,
|
depositAmount: depositAmount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customerEmail,
|
|
||||||
quoteNumber: quote.QuoteNumber,
|
quoteNumber: quote.QuoteNumber,
|
||||||
quoteId: quote.Id);
|
quoteId: quote.Id);
|
||||||
|
|
||||||
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
|
|||||||
public int InvoiceId { get; set; }
|
public int InvoiceId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { 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 string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal BalanceDue { get; set; }
|
public decimal BalanceDue { get; set; }
|
||||||
public decimal InvoiceTotal { get; set; }
|
public decimal InvoiceTotal { get; set; }
|
||||||
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { 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 string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal DepositAmount { get; set; }
|
public decimal DepositAmount { get; set; }
|
||||||
public decimal QuoteTotal { get; set; }
|
public decimal QuoteTotal { get; set; }
|
||||||
|
|||||||
@@ -653,6 +653,11 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.Headers.Append("Permissions-Policy",
|
context.Response.Headers.Append("Permissions-Policy",
|
||||||
"geolocation=(), microphone=(), camera=()");
|
"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();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
||||||
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
||||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
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">
|
<div class="row justify-content-center">
|
||||||
@@ -406,7 +409,7 @@
|
|||||||
@if (!isVoided)
|
@if (!isVoided)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
<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>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
||||||
@@ -694,7 +697,9 @@
|
|||||||
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
||||||
{
|
{
|
||||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
<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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit" class="btn btn-outline-warning w-100">
|
<button type="submit" class="btn btn-outline-warning w-100">
|
||||||
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
||||||
|
|||||||
@@ -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,18 +116,17 @@
|
|||||||
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", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
const SURCHARGE_TYPE = '@Model.SurchargeType';
|
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 SUCCESS_URL = `/pay/${TOKEN}/success`;
|
||||||
|
|
||||||
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
|
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user