From 7fa385aeb80a3942c32674a019be9a46c47b9e32 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 11:49:04 -0400 Subject: [PATCH] Inline item editing on details pages; fix Stripe receipt_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow description, quantity, and price to be edited inline on Quote, Job, and Invoice details pages without re-opening the wizard. Coating and prep service rows remain read-only by design. Invoice editing is gated to Draft/Sent/Overdue statuses; totals update live in the DOM. Remove receipt_email from Stripe PaymentIntent creation so customers can use any email they choose at checkout — Stripe validates format and sends the receipt to whatever the customer enters in the Payment Element, eliminating the risk of a stored email mismatch blocking a payment from processing. Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/IStripeConnectService.cs | 2 - .../Services/StripeConnectService.cs | 4 - .../Controllers/InvoicesController.cs | 52 ++++++ .../Controllers/JobsController.cs | 60 +++++++ .../Controllers/PaymentController.cs | 5 - .../Controllers/QuotesController.cs | 51 ++++++ .../Views/Invoices/Details.cshtml | 33 ++-- .../Views/Jobs/Details.cshtml | 45 +++-- .../Views/Quotes/Details.cshtml | 38 ++-- src/PowderCoating.Web/dotnet-tools.json | 2 +- src/PowderCoating.Web/wwwroot/css/site.css | 11 ++ .../wwwroot/js/inline-item-edit.js | 167 ++++++++++++++++++ 12 files changed, 418 insertions(+), 52 deletions(-) create mode 100644 src/PowderCoating.Web/wwwroot/js/inline-item-edit.js 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 8a6d39e..d6ad3ab 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -3035,6 +3035,50 @@ public class InvoicesController : Controller return false; } + /// + /// Inline-edits description, quantity, and unit price on a single invoice line item. + /// Blocked on paid/voided invoices (same gate as the full Edit action). + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchInvoiceItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId); + if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound(); + + if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue)) + return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." }); + + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.InvoiceItems.UpdateAsync(item); + + var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id); + var newSubTotal = allItems.Sum(i => i.TotalPrice); + invoice.SubTotal = newSubTotal; + invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2); + invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2); + await _unitOfWork.Invoices.UpdateAsync(invoice); + await _unitOfWork.CompleteAsync(); + + return Json(new { + lineTotal = item.TotalPrice, + subtotal = invoice.SubTotal, + taxAmount = invoice.TaxAmount, + total = invoice.Total, + balanceDue = invoice.BalanceDue + }); + } + /// /// Returns logo bytes and content type for PDF generation. /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData). @@ -3050,3 +3094,11 @@ public class InvoicesController : Controller return (company.LogoData, company.LogoContentType); } } + +public class PatchInvoiceItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index a09a1e0..2582725 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -4216,9 +4216,69 @@ public class JobsController : Controller return Json(new { success = false, message = "An error occurred. Please try again." }); } } + + /// + /// Inline-edits description, quantity, and unit price on a single job line item. + /// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta. + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchJobItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); + if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); + + var oldTotal = item.TotalPrice; + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.JobItems.UpdateAsync(item); + + var delta = item.TotalPrice - oldTotal; + job.FinalPrice = Math.Round(job.FinalPrice + delta, 2); + + // Keep the stored pricing snapshot in sync so the breakdown panel stays consistent + if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) + { + var pb = JsonSerializer.Deserialize(job.PricingBreakdownJson); + if (pb != null) + { + pb.ItemsSubtotal += delta; + pb.SubtotalBeforeDiscount += delta; + pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount; + pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2); + pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2); + job.FinalPrice = pb.Total; + job.PricingBreakdownJson = JsonSerializer.Serialize(pb); + } + } + + await _unitOfWork.Jobs.UpdateAsync(job); + await _unitOfWork.CompleteAsync(); + + return Json(new { + lineTotal = item.TotalPrice, + finalPrice = job.FinalPrice + }); + } } public class DeleteTimeEntryRequest { public int Id { get; set; } } +public class PatchJobItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} public class LogMaterialRequest { public int JobId { get; set; } diff --git a/src/PowderCoating.Web/Controllers/PaymentController.cs b/src/PowderCoating.Web/Controllers/PaymentController.cs index abfd89e..9e2b8be 100644 --- a/src/PowderCoating.Web/Controllers/PaymentController.cs +++ b/src/PowderCoating.Web/Controllers/PaymentController.cs @@ -127,8 +127,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 +134,6 @@ public class PaymentController : Controller invoiceTotal: request.Amount, surchargeAmount: surcharge, currency: "usd", - customerEmail: customer?.Email ?? string.Empty, invoiceNumber: invoice.InvoiceNumber, invoiceId: invoice.Id); @@ -296,7 +293,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 +300,6 @@ public class PaymentController : Controller depositAmount: depositAmount, surchargeAmount: surcharge, currency: "usd", - customerEmail: customerEmail, quoteNumber: quote.QuoteNumber, quoteId: quote.Id); diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 986ace2..879cd47 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -3824,6 +3824,49 @@ public class QuotesController : Controller } return (company.LogoData, company.LogoContentType); } + + /// + /// Inline-edits description, quantity, and unit price on a single quote line item. + /// Adjusts stored quote totals by the price delta so the sidebar stays accurate. + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchQuoteItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId); + if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound(); + + var oldTotal = item.TotalPrice; + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.QuoteItems.UpdateAsync(item); + + // Cascade delta through stored totals without re-running the pricing engine + var delta = item.TotalPrice - oldTotal; + quote.ItemsSubtotal += delta; + quote.SubTotal += delta; + quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount; + quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2); + quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2); + await _unitOfWork.Quotes.UpdateAsync(quote); + await _unitOfWork.CompleteAsync(); + + return Json(new { + lineTotal = item.TotalPrice, + subtotal = quote.SubTotal, + taxAmount = quote.TaxAmount, + total = quote.Total + }); + } } // Request model for AJAX pricing calculation @@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest public int QuoteId { get; set; } public int StatusId { get; set; } } + +public class PatchQuoteItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index f301a80..3079024 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -230,21 +230,21 @@ @foreach (var item in Model.InvoiceItems) { - + -
@item.Description
+ @item.Description @if (!string.IsNullOrWhiteSpace(item.ColorName)) { - @item.ColorName + @item.ColorName } @if (!string.IsNullOrWhiteSpace(item.Notes)) { @item.Notes } - @item.Quantity.ToString("G") - @item.UnitPrice.ToString("C") - @item.TotalPrice.ToString("C") + @item.Quantity.ToString("G") + @item.UnitPrice.ToString("C") + @item.TotalPrice.ToString("C") } @@ -257,7 +257,7 @@
Subtotal - @Model.SubTotal.ToString("C") + @Model.SubTotal.ToString("C")
@if (Model.DiscountAmount > 0) { @@ -276,12 +276,12 @@ @Model.SalesTaxAccountName } - @Model.TaxAmount.ToString("C") + @Model.TaxAmount.ToString("C")
}
Total - @Model.Total.ToString("C") + @Model.Total.ToString("C")
@if (Model.AmountPaid > 0) { @@ -291,7 +291,7 @@
Balance Due - @Model.BalanceDue.ToString("C") + @Model.BalanceDue.ToString("C")
} @@ -1442,6 +1442,19 @@ } @section Scripts { + + + + + +