Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<QuotePricingBreakdownDto>(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<QuotePricingBreakdownDto>(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
|
||||
|
||||
Reference in New Issue
Block a user