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