Compare commits

...

8 Commits

Author SHA1 Message Date
spouliot 81dc34bab4 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:03 -04:00
spouliot b9e9449c8b 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 <noreply@anthropic.com>
2026-05-20 13:42:54 -04:00
spouliot fd38785942 Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:27:10 -04:00
spouliot 33277de727 Payment hardening: InvariantCulture on JS literals, remove dead CustomerEmail
Razor numeric expressions emitted into JS literals (MAX_TOTAL,
SURCHARGE_VALUE) now use InvariantCulture, matching the pattern already
used on the deposit page. Without this, a server culture with comma
decimal separators would silently truncate values like 2.5% to 2.

CustomerEmail removed from PaymentPageViewModel and
DepositPaymentPageViewModel — it was populated from the DB on every
payment page load but never consumed after receipt_email was removed
from the Stripe PaymentIntent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:20:47 -04:00
spouliot 4ac62551f4 Fix online payment surcharge — input and validation based on total
The payment amount input was capped at BalanceDue (e.g. $5.00) but the
customer was being charged TotalWithSurcharge (e.g. $5.15), causing
validation to reject any attempt to pay the correct total amount.

Input now defaults to and accepts up to TotalWithSurcharge. On submit,
the JS back-calculates the base amount before sending to the server so
the server-side surcharge addition produces the same PaymentIntent total
that the Stripe Elements were initialized with — eliminating the
amount-mismatch error from Stripe on confirmation.

Also calls elements.update() when the amount changes so partial payments
don't cause an Elements/PaymentIntent amount mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:16:50 -04:00
spouliot 7fa385aeb8 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>
2026-05-20 11:49:04 -04:00
spouliot 8452ea3fcd Merge remote-tracking branch 'origin/master' into dev 2026-05-20 10:37:45 -04:00
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
14 changed files with 537 additions and 79 deletions
@@ -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);
}
@@ -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<string, string>
{
@@ -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<string, string>
{
@@ -1480,6 +1480,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 () =>
{
@@ -1499,6 +1500,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);
@@ -1550,7 +1620,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)
@@ -3035,6 +3108,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 +3167,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; }
@@ -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; }
@@ -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; }
}
+5
View File
@@ -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();
});
@@ -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);
}
<div class="row justify-content-center">
@@ -230,21 +233,21 @@
<tbody>
@foreach (var item in Model.InvoiceItems)
{
<tr>
<tr data-item-id="@item.Id">
<td>
<div class="fw-semibold">@item.Description</div>
<span class="fw-semibold" data-inline-field="description" data-raw-value="@item.Description">@item.Description</span>
@if (!string.IsNullOrWhiteSpace(item.ColorName))
{
<small class="text-muted">@item.ColorName</small>
<small class="text-muted d-block">@item.ColorName</small>
}
@if (!string.IsNullOrWhiteSpace(item.Notes))
{
<small class="text-muted d-block">@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity.ToString("G")</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity.ToString("G")</span></td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
</tr>
}
</tbody>
@@ -257,7 +260,7 @@
<div class="col-md-4">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
<span id="inv-subtotal">@Model.SubTotal.ToString("C")</span>
</div>
@if (Model.DiscountAmount > 0)
{
@@ -276,12 +279,12 @@
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
}
</span>
<span>@Model.TaxAmount.ToString("C")</span>
<span id="inv-tax">@Model.TaxAmount.ToString("C")</span>
</div>
}
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
<span>Total</span>
<span>@Model.Total.ToString("C")</span>
<span id="inv-total">@Model.Total.ToString("C")</span>
</div>
@if (Model.AmountPaid > 0)
{
@@ -291,7 +294,7 @@
</div>
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
<span>Balance Due</span>
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
<span id="inv-balance" class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
</div>
}
</div>
@@ -406,7 +409,7 @@
@if (!isVoided)
{
<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>
</button>
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
@@ -694,7 +697,9 @@
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
{
<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()
<button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-x-circle me-2"></i>Void Invoice
@@ -1442,6 +1447,19 @@
}
@section Scripts {
<script src="~/js/inline-item-edit.js"></script>
<script>
window.inlineItemEdit = {
patchUrl: '@Url.Action("PatchItem", "Invoices")',
canEdit: @Json.Serialize(canEdit),
totals: {
subtotal: '#inv-subtotal',
tax: '#inv-tax',
total: '#inv-total',
balance: '#inv-balance'
}
};
</script>
<script>
function submitSendInvoice(sendEmail, sendSms) {
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
+28 -17
View File
@@ -341,9 +341,9 @@
@foreach (var item in catalogItems)
{
var catIdx = allItems.IndexOf(item);
<tr>
<tr data-item-id="@item.Id">
<td>
<strong>@item.Description</strong>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (item.Coats != null && item.Coats.Any())
{
<br />
@@ -399,9 +399,9 @@
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
@@ -440,6 +440,7 @@
{
var custIdx = allItems.IndexOf(item);
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
// Note: row has data-item-id for inline editing
decimal totalPowderNeeded = 0;
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
{
@@ -458,9 +459,9 @@
{
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
}
<tr>
<tr data-item-id="@item.Id">
<td>
<strong>@item.Description</strong>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (item.RequiresSandblasting || item.RequiresMasking)
{
<br />
@@ -528,7 +529,7 @@
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td class="text-center">
@if (item.SurfaceAreaSqFt > 0)
{
@@ -553,8 +554,8 @@
}
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
@@ -590,15 +591,15 @@
@foreach (var item in laborItems)
{
var labIdx = allItems.IndexOf(item);
<tr>
<tr data-item-id="@item.Id">
<td>
<strong>@item.Description</strong>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (!string.IsNullOrEmpty(item.Notes))
{
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
}
</td>
<td class="text-center">@item.Quantity</td>
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
{
@@ -606,8 +607,8 @@
}
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
@@ -1688,7 +1689,7 @@
}
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
<span>Total</span>
<span>@jobPb.Total.ToString("C")</span>
<span class="job-final-price-display">@jobPb.Total.ToString("C")</span>
</div>
@{
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
@@ -1717,7 +1718,7 @@
}
<div>
<label class="text-muted small mb-1">Final Price</label>
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
<h3 class="mb-0 text-primary job-final-price-display">@Model.FinalPrice.ToString("C")</h3>
</div>
}
</div>
@@ -2415,6 +2416,16 @@
<link rel="stylesheet" href="~/css/job-photos.css" />
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
<script>
window.inlineItemEdit = {
patchUrl: '@Url.Action("PatchItem", "Jobs")',
canEdit: true,
totals: {
finalPrice: '.job-final-price-display'
}
};
</script>
<script>
// -- Inline date editing ----------------------------------------------
const jobId = @Model.Id;
@@ -75,11 +75,11 @@
<div class="input-group mb-1">
<span class="input-group-text">$</span>
<input type="number" id="paymentAmount" class="form-control"
value="@Model.BalanceDue.ToString("F2")"
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01"
value="@Model.TotalWithSurcharge.ToString("F2")"
min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
oninput="updateSurcharge()" />
</div>
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div>
<div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
</div>
</div>
@@ -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) {
@@ -276,14 +276,14 @@
<tbody>
@foreach (var item in catalogItems)
{
<tr>
<tr data-item-id="@item.Id">
<td>
@{
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
? (item.CatalogItemName ?? "Catalog Item")
: item.Description;
}
<strong>@displayDescription</strong>
<span data-inline-field="description" data-raw-value="@displayDescription"><strong>@displayDescription</strong></span>
@if (item.CatalogItemId.HasValue &&
item.Description != "Product Item" &&
!string.IsNullOrWhiteSpace(item.Description))
@@ -354,9 +354,9 @@
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
}
</td>
<td>@item.Quantity</td>
<td>@item.UnitPrice.ToString("C")</td>
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
</tr>
}
</tbody>
@@ -394,9 +394,9 @@
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
var powderOnPart = totalSqFt / coverageRate;
var totalPowderNeeded = powderOnPart / transferEff;
<tr>
<tr data-item-id="@item.Id">
<td>
<strong>@item.Description</strong>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@* Display coating layers *@
@if (item.Coats != null && item.Coats.Any())
@@ -474,7 +474,7 @@
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
}
</td>
<td>@item.Quantity</td>
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
<td>
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
<br /><small class="text-muted">per item</small>
@@ -487,8 +487,8 @@
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
<br /><small class="text-muted">total batch</small>
</td>
<td>@item.UnitPrice.ToString("C")</td>
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
</tr>
}
</tbody>
@@ -1023,7 +1023,7 @@
<div class="d-flex justify-content-between mb-2">
<span>Subtotal:</span>
<strong>@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
<strong id="quote-subtotal">@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
</div>
@if (Model.PricingBreakdown.DiscountAmount > 0)
@@ -1075,7 +1075,7 @@
{
<div class="d-flex justify-content-between mb-2">
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
<strong>@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
<strong id="quote-tax">@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
</div>
}
@@ -1083,7 +1083,7 @@
<div class="d-flex justify-content-between mb-0">
<h5>Total:</h5>
<h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
<h5 class="text-primary"><strong id="quote-total">@Model.Total.ToString("C")</strong></h5>
</div>
@if (Model.PricingBreakdown.DiscountAmount > 0)
@@ -2262,6 +2262,18 @@
@section Scripts {
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
<script>
window.inlineItemEdit = {
patchUrl: '@Url.Action("PatchItem", "Quotes")',
canEdit: true,
totals: {
subtotal: '#quote-subtotal',
tax: '#quote-tax',
total: '#quote-total'
}
};
</script>
<script>
function sendQuoteToAdHocEmail(quoteId) {
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
+1 -1
View File
@@ -10,4 +10,4 @@
"rollForward": false
}
}
}
}
@@ -1152,3 +1152,14 @@ a.tag-index-badge:hover {
}
}
.mw-lg { max-width: 640px; }
/* ── Inline item edit ───────────────────────────────────────── */
.inline-editable:hover {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.inline-edit-input {
display: inline-block;
padding: 1px 4px;
height: auto;
}
@@ -0,0 +1,167 @@
/// <summary>
/// Shared inline-edit behaviour for quote, job, and invoice item rows.
/// Activated when the page sets window.inlineItemEdit = { patchUrl, canEdit, totals }.
/// totals: { subtotal, tax, total, finalPrice, balance } — CSS selectors, any subset.
/// </summary>
(function () {
'use strict';
const cfg = window.inlineItemEdit;
if (!cfg || !cfg.canEdit) return;
function fmt(val) {
return val.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
function csrfToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
function showError(msg) {
const el = document.createElement('div');
el.className = 'alert alert-danger alert-permanent position-fixed bottom-0 end-0 m-3 shadow';
el.style.zIndex = '9999';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function updateTotals(data) {
const t = cfg.totals || {};
[
[t.subtotal, data.subtotal],
[t.tax, data.taxAmount],
[t.total, data.total],
[t.finalPrice, data.finalPrice],
[t.balance, data.balanceDue],
].forEach(([sel, val]) => {
if (sel && val !== undefined && val !== null) {
document.querySelectorAll(sel).forEach(el => { el.textContent = fmt(val); });
}
});
}
function makeEditable(span) {
const field = span.dataset.inlineField;
const row = span.closest('tr[data-item-id]');
if (!row) return;
const itemId = row.dataset.itemId;
const rawVal = span.dataset.rawValue ?? span.textContent.trim();
const input = document.createElement('input');
input.className = 'form-control form-control-sm inline-edit-input';
if (field === 'description') {
input.type = 'text';
input.style.minWidth = '140px';
} else {
input.type = 'number';
input.step = '0.01';
input.min = '0';
input.style.width = '80px';
}
input.value = rawVal;
// Stash current rendered markup so we can revert
const savedHTML = span.innerHTML;
span.innerHTML = '';
span.appendChild(input);
input.focus();
input.select();
let committed = false;
function revert() {
span.innerHTML = savedHTML;
attachListeners(span);
}
async function commit() {
if (committed) return;
committed = true;
const newVal = input.value.trim();
if (newVal === '' || (field !== 'description' && isNaN(parseFloat(newVal)))) {
revert();
return;
}
// Read sibling raw values from other editable cells in the same row
function siblingRaw(f) {
const s = row.querySelector(`[data-inline-field="${f}"]`);
if (!s) return null;
// If that sibling is currently showing an input (concurrent edit, unlikely), fall back
const inp = s.querySelector('input.inline-edit-input');
if (inp) return inp.value;
return s.dataset.rawValue ?? s.textContent.trim();
}
const description = field === 'description' ? newVal : (siblingRaw('description') ?? '');
const quantity = parseFloat(field === 'quantity' ? newVal : (siblingRaw('quantity') ?? '1'));
const unitPrice = parseFloat(field === 'unitPrice' ? newVal : (siblingRaw('unitPrice') ?? '0'));
try {
const resp = await fetch(cfg.patchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': csrfToken()
},
body: JSON.stringify({ itemId: parseInt(itemId, 10), description, quantity, unitPrice })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showError(err.error ?? 'Could not save &mdash; try again.');
revert();
return;
}
const data = await resp.json();
// Update this span's display and stored raw value
if (field === 'description') {
const strong = span.querySelector('strong');
if (strong) { strong.textContent = newVal; }
else { span.innerHTML = `<strong>${newVal}</strong>`; }
span.dataset.rawValue = newVal;
} else if (field === 'quantity') {
span.dataset.rawValue = quantity;
span.textContent = quantity % 1 === 0 ? quantity.toFixed(0) : quantity.toString();
} else if (field === 'unitPrice') {
span.dataset.rawValue = unitPrice;
span.textContent = fmt(unitPrice);
}
// Update line total cell
const totalCell = row.querySelector('[data-line-total]');
if (totalCell) totalCell.textContent = fmt(data.lineTotal);
// Update document-level totals
updateTotals(data);
// Re-attach click listener for next edit
attachListeners(span);
} catch {
showError('Could not save &mdash; check your connection and try again.');
revert();
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { committed = true; revert(); }
});
}
function attachListeners(span) {
span.style.cursor = 'text';
span.title = 'Click to edit';
span.classList.add('inline-editable');
span.addEventListener('click', () => makeEditable(span), { once: true });
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-inline-field]').forEach(attachListeners);
});
})();