From 3a1928f9bf00b14a428995cbe333c2d167aaf1e3 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 10:45:40 -0400 Subject: [PATCH] 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