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
|
var costs = await _unitOfWork.CompanyOperatingCosts
|
||||||
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
||||||
|
|
||||||
|
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
|
||||||
var dto = new CreateInvoiceDto
|
var dto = new CreateInvoiceDto
|
||||||
{
|
{
|
||||||
PreparedById = currentUser.Id,
|
PreparedById = currentUser.Id,
|
||||||
InvoiceDate = DateTime.Today,
|
InvoiceDate = DateTime.Today,
|
||||||
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
|
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
|
||||||
TaxPercent = costs?.TaxPercent ?? 0,
|
TaxPercent = costs?.TaxPercent ?? 0,
|
||||||
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
|
Terms = defaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jobId.HasValue)
|
if (jobId.HasValue)
|
||||||
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
|
|||||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
var defaultRevenueAccount = await _unitOfWork.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
.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.
|
// 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 —
|
// 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.
|
// 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
|
// 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.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? sourceQuote.DiscountAmount;
|
||||||
}
|
}
|
||||||
else if (hadJobItems)
|
else if (hadJobItems)
|
||||||
{
|
{
|
||||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
// 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.
|
// 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)
|
if (job.OvenBatchCost > 0.01m)
|
||||||
{
|
{
|
||||||
@@ -529,6 +536,22 @@ public class InvoicesController : Controller
|
|||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
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
|
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
||||||
|
|||||||
Reference in New Issue
Block a user