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:
@@ -28,6 +28,7 @@ public class Job : BaseEntity
|
|||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal OvenBatchCost { get; set; }
|
||||||
public decimal ShopSuppliesAmount { get; set; }
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
public decimal ShopSuppliesPercent { get; set; }
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobOvenBatchCost : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "OvenBatchCost",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenBatchCost",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,6 +463,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("CanCreateQuotes")
|
b.Property<bool>("CanCreateQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageAccounting")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageBills")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("CanManageCalendar")
|
b.Property<bool>("CanManageCalendar")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -4183,6 +4189,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("OriginalJobId")
|
b.Property<int?>("OriginalJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("OvenBatchCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -6565,7 +6574,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966),
|
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6576,7 +6585,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974),
|
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6587,7 +6596,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976),
|
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -458,21 +458,36 @@ public class InvoicesController : Controller
|
|||||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
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
|
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||||
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
|
// recalculating, so the invoice always matches the total shown on the job page.
|
||||||
// from the quote snapshot instead; this path only fires when there is no quote.)
|
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||||
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
// OvenCostId) when job items are created or updated.
|
||||||
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
|
if (job.OvenBatchCost > 0.01m)
|
||||||
if (shopSuppliesAmount > 0.01m)
|
|
||||||
{
|
{
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
|
Description = $"Oven Processing Fee",
|
||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
UnitPrice = shopSuppliesAmount,
|
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||||
TotalPrice = shopSuppliesAmount,
|
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,
|
DisplayOrder = order,
|
||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1170,9 +1170,10 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
createCosts?.TaxPercent ?? 0m,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -1628,8 +1629,9 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
editCosts?.TaxPercent ?? 0m,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
}
|
}
|
||||||
@@ -3038,9 +3040,10 @@ public class JobsController : Controller
|
|||||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -3059,6 +3059,18 @@ public class QuotesController : Controller
|
|||||||
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
|
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
|
||||||
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
|
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
|
||||||
quote.ApprovalTokenUsedAt = null;
|
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.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user