From cfe937c0c3d49826891c991cc05fce022ceb8041 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 13:42:54 -0400 Subject: [PATCH] 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()