From 3a1928f9bf00b14a428995cbe333c2d167aaf1e3 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 10:45:40 -0400 Subject: [PATCH 1/6] Fix invoice creation from job: discount ignored, wrong due date, wrong terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead of from the payment terms string; now uses PaymentTermsParser throughout - Discount was never applied for direct jobs (PricingBreakdownJson was read for fees but DiscountAmount was silently skipped) - Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits made to the job after quote conversion; now prefers the job's pricing snapshot - Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms → company default, so the invoice reflects the agreed or customer-specific terms - EarlyPaymentDiscount fields now populated from inherited terms Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/InvoicesController.cs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 43c38ea..fdaf48b 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -340,13 +340,14 @@ public class InvoicesController : Controller var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); + var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30"; var dto = new CreateInvoiceDto { PreparedById = currentUser.Id, InvoiceDate = DateTime.Today, - DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30), + DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today), TaxPercent = costs?.TaxPercent ?? 0, - Terms = prefs?.DefaultPaymentTerms ?? "Net 30" + Terms = defaultTerms }; if (jobId.HasValue) @@ -378,6 +379,13 @@ public class InvoicesController : Controller var defaultRevenueAccount = await _unitOfWork.Accounts .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); + // Deserialize the job's pricing snapshot up front — it is authoritative for discount, + // tax, and fees for both quote-based and direct jobs, because it is recalculated on + // every save and reflects any edits made after quote conversion. + QuotePricingBreakdownDto? jobBreakdown = null; + if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) + jobBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); + // If the job came from a quote, load it so we can use the agreed pricing. // The quote stores the approved total including oven batch cost and shop supplies — // these are quote-level charges that are NOT stored on individual job items. @@ -461,17 +469,16 @@ public class InvoicesController : Controller }); } - // Use the quote's agreed tax rate and discount — not current company defaults - dto.TaxPercent = sourceQuote.TaxPercent; - dto.DiscountAmount = sourceQuote.DiscountAmount; + // Use the quote's agreed tax rate. For discount, prefer the job's current pricing + // snapshot — it is recalculated on every save and captures any post-conversion edits. + // Fall back to the original quote discount only if no snapshot exists. + dto.TaxPercent = sourceQuote.TaxPercent; + dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? sourceQuote.DiscountAmount; } else if (hadJobItems) { // Direct job — no source quote. Read all charges from the pricing snapshot so the // invoice always matches the total shown on the job's Pricing Summary card. - QuotePricingBreakdownDto? jobBreakdown = null; - if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) - jobBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); if (job.OvenBatchCost > 0.01m) { @@ -529,6 +536,22 @@ public class InvoicesController : Controller RevenueAccountId = defaultRevenueAccount?.Id }); } + + dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0; + } + + // Inherit payment terms from the source quote or the customer — more specific than + // the company-wide default set in the outer DTO. Quote terms take priority because + // they represent the agreed price; customer terms are next best for direct jobs. + var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms; + if (!string.IsNullOrWhiteSpace(inheritedTerms)) + { + dto.Terms = inheritedTerms; + dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today) + ?? dto.DueDate; + var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms); + dto.EarlyPaymentDiscountPercent = discPct; + dto.EarlyPaymentDiscountDays = discDays; } // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate From b2d6fae400efbfaf2dd5a837fc0c99071b2118ff Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 11:29:12 -0400 Subject: [PATCH 2/6] Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount The quote discount must come from the agreed quote price, not the job's pricing snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job snapshot fix only applies to direct jobs where no source quote exists. Co-Authored-By: Claude Sonnet 4.6 --- src/PowderCoating.Web/Controllers/InvoicesController.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index fdaf48b..c44f985 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -469,11 +469,10 @@ public class InvoicesController : Controller }); } - // Use the quote's agreed tax rate. For discount, prefer the job's current pricing - // snapshot — it is recalculated on every save and captures any post-conversion edits. - // Fall back to the original quote discount only if no snapshot exists. + // Use the quote's agreed tax rate and discount — these represent the customer-approved + // price and must not be recomputed from the job's current state. dto.TaxPercent = sourceQuote.TaxPercent; - dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? sourceQuote.DiscountAmount; + dto.DiscountAmount = sourceQuote.DiscountAmount; } else if (hadJobItems) { From 27aa4e0ea6bc0de92bd42e3035f2bc6ab04aa08e Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 11:41:47 -0400 Subject: [PATCH 3/6] Invoice create: show discount row in totals, allow negative line items - Add "Discount Applied" display row (red, hidden when zero) between subtotal and tax so users can see the discount being deducted at a glance - Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS template) so negative adjustment lines can be entered without form rejection Co-Authored-By: Claude Sonnet 4.6 --- .../Views/Invoices/Create.cshtml | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/PowderCoating.Web/Views/Invoices/Create.cshtml b/src/PowderCoating.Web/Views/Invoices/Create.cshtml index 44e67e5..fbc8ccc 100644 --- a/src/PowderCoating.Web/Views/Invoices/Create.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Create.cshtml @@ -283,13 +283,13 @@ @@ -371,6 +371,10 @@ +
+ Discount Applied + −$0.00 +
@@ -725,13 +729,13 @@ @@ -797,6 +801,15 @@ const total = taxableAmount + tax; document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal); + const discountRow = document.getElementById('discountRow'); + if (discountRow) { + if (discount > 0) { + document.getElementById('displayDiscount').textContent = '−' + formatCurrency(discount); + discountRow.classList.remove('d-none'); + } else { + discountRow.classList.add('d-none'); + } + } document.getElementById('displayTax').textContent = formatCurrency(tax); document.getElementById('displayTotal').textContent = formatCurrency(total); } From 36680eced9f9e6fcac363b5adbafffea02f7d09b Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 12:10:54 -0400 Subject: [PATCH 4/6] Add manual Log Material modal to job details page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PC users were blocked to QR scan only for logging material usage. Now a "Log Material" button opens an inline modal with: - Inventory item dropdown (name + unit of measure, current stock shown on select) - Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining) - Reason: Job Usage or Waste/Spillage - Notes field Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction, posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/JobsController.cs | 85 ++++++++++ .../Views/Jobs/Details.cshtml | 82 +++++++++- .../wwwroot/js/log-material.js | 150 ++++++++++++++++++ 3 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/PowderCoating.Web/wwwroot/js/log-material.js diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 3f20fc3..d1bcaf3 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -498,6 +498,13 @@ public class JobsController : Controller .OrderByDescending(t => t.TransactionDate).ToList(); ViewBag.MaterialsUsed = allJobTransactions; + // Inventory items for the manual log-material modal + var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync()) + .OrderBy(i => i.Name) + .Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand }) + .ToList(); + ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal); + // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) ViewBag.PreLoggedPowder = allJobTransactions .GroupBy(t => t.InventoryItemId) @@ -4080,9 +4087,87 @@ public class JobsController : Controller _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); } + + /// + /// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage + /// flow in InventoryController but returns JSON so the modal can close and refresh inline. + /// Quantity is always the amount USED (caller converts from remaining if needed). + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task LogMaterial([FromBody] LogMaterialRequest req) + { + try + { + if (req.QuantityUsed <= 0) + return Json(new { success = false, message = "Quantity used must be greater than zero." }); + + var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId); + if (item == null) return Json(new { success = false, message = "Inventory item not found." }); + + var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId); + if (job == null) return Json(new { success = false, message = "Job not found." }); + + var txnType = req.TransactionType == "Waste" + ? InventoryTransactionType.Waste + : InventoryTransactionType.JobUsage; + + item.QuantityOnHand -= req.QuantityUsed; + item.UpdatedAt = DateTime.UtcNow; + await _unitOfWork.InventoryItems.UpdateAsync(item); + + var txn = new PowderCoating.Core.Entities.InventoryTransaction + { + InventoryItemId = item.Id, + TransactionType = txnType, + Quantity = -req.QuantityUsed, + UnitCost = item.UnitCost, + TotalCost = req.QuantityUsed * item.UnitCost, + TransactionDate = DateTime.UtcNow, + BalanceAfter = item.QuantityOnHand, + JobId = req.JobId, + Reference = $"Job {job.JobNumber}", + Notes = req.Notes?.Trim(), + CompanyId = item.CompanyId, + CreatedAt = DateTime.UtcNow + }; + await _unitOfWork.InventoryTransactions.AddAsync(txn); + await _unitOfWork.CompleteAsync(); + + // GL: DR COGS, CR Inventory Asset + if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue) + { + var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost); + await _accountBalanceService.DebitAsync(item.CogsAccountId, cost); + await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost); + } + + return Json(new + { + success = true, + message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.", + newBalance = item.QuantityOnHand, + unitOfMeasure = item.UnitOfMeasure, + itemName = item.Name + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging material for job {JobId}", req.JobId); + return Json(new { success = false, message = "An error occurred. Please try again." }); + } + } } public class DeleteTimeEntryRequest { public int Id { get; set; } } +public class LogMaterialRequest +{ + public int JobId { get; set; } + public int InventoryItemId { get; set; } + public decimal QuantityUsed { get; set; } + public string TransactionType { get; set; } = "JobUsage"; + public string? Notes { get; set; } +} public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } } public class UpdateWorkerAssignmentRequest diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 9ddbb86..49b0b17 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -1016,9 +1016,12 @@ @materialsUsed.Count } - - - Log Material + + + +
@@ -1028,7 +1031,7 @@ {
No materials have been logged for this job yet. - Use the QR label on an inventory item to log usage. + Click Log Material above or scan the QR label on an inventory item.
} else @@ -1089,6 +1092,65 @@
+ + + @{ var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0; @@ -3082,6 +3144,18 @@ } } + + + +