Fix two production billing bugs: invoice missing oven cost, quote stuck in Draft after send

Bug 1 — Invoice total didn't match job total for direct jobs:
- Root cause: all three item-save paths in JobsController passed null for
  ovenCostId, so FinalPrice/ShopSuppliesAmount were stored without oven cost
  while the Details page recalculated live with OvenCostId and showed higher.
- Add OvenBatchCost stored field to Job entity (migration AddJobOvenBatchCost,
  default 0 for existing rows).
- Fix Create, Edit, and UpdateItems to pass job.OvenCostId and save OvenBatchCost.
- Fix InvoicesController.Create GET for direct jobs to use stored OvenBatchCost
  and ShopSuppliesAmount as separate labeled lines instead of recalculating
  shop supplies from scratch (which excluded the oven cost base).

Bug 2 — Quote status stayed Draft after "Send Quote via Email":
- ResendQuote advanced the approval token and sent the email but never
  updated the status. Added Draft → Sent advancement (same guard used by
  the SMS send path) so the status updates on successful email send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 10:39:49 -04:00
parent 656f830898
commit 17da692dce
7 changed files with 10722 additions and 16 deletions
@@ -458,21 +458,36 @@ public class InvoicesController : Controller
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems && costs?.ShopSuppliesRate > 0)
else if (hadJobItems)
{
// Direct job — no source quote. Derive shop supplies from the items subtotal
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
// from the quote snapshot instead; this path only fires when there is no quote.)
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
if (shopSuppliesAmount > 0.01m)
// Direct job — no source quote. Use the stored job-level fees rather than
// recalculating, so the invoice always matches the total shown on the job page.
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
// OvenCostId) when job items are created or updated.
if (job.OvenBatchCost > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
Description = $"Oven Processing Fee",
Quantity = 1,
UnitPrice = shopSuppliesAmount,
TotalPrice = shopSuppliesAmount,
UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = Math.Round(job.OvenBatchCost, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
if (job.ShopSuppliesAmount > 0.01m)
{
var suppliesDesc = job.ShopSuppliesPercent > 0
? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)"
: "Shop Supplies";
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = suppliesDesc,
Quantity = 1,
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id
});
@@ -1170,9 +1170,10 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.UpdatedAt = DateTime.UtcNow;
@@ -1628,8 +1629,9 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
}
@@ -3038,9 +3040,10 @@ public class JobsController : Controller
// Calculate full total (overhead, margins, tax) to match what the wizard displays
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, null, 1, null);
model.TaxPercent, "None", 0, false, job.OvenCostId, 1, null);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.UpdatedAt = DateTime.UtcNow;
@@ -3059,6 +3059,18 @@ public class QuotesController : Controller
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
quote.ApprovalTokenUsedAt = null;
// Advance from Draft → Sent (mirrors the Create and SendSms paths)
var resendCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var resendStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(resendCompanyId);
var resendSentStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
var resendDraftStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (resendSentStatus != null && quote.QuoteStatusId == (resendDraftStatus?.Id ?? 0))
{
quote.QuoteStatusId = resendSentStatus.Id;
quote.SentDate ??= DateTime.UtcNow;
}
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();