Inline item editing on details pages; fix Stripe receipt_email
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 <noreply@anthropic.com>
This commit is contained in:
@@ -3035,6 +3035,50 @@ public class InvoicesController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
}
|
||||
|
||||
@@ -4216,9 +4216,69 @@ public class JobsController : Controller
|
||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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<QuotePricingBreakdownDto>(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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -3824,6 +3824,49 @@ public class QuotesController : Controller
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user