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/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index ddb3537..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 () => { @@ -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) 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/Program.cs b/src/PowderCoating.Web/Program.cs index 473b941..acb97e9 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -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(); }); diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index f301a80..c0ea0c6 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); }
@@ -406,7 +409,7 @@ @if (!isVoided) {
+ 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()
@@ -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) {