Compare commits

..

6 Commits

Author SHA1 Message Date
spouliot 7020797a25 Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController)
- Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js)
- Fix quote preview not recalculating on customer change (Create.cshtml)
- Add AddQuotePricingSnapshotFields migration (missing from prior session)
- Fix intake button rendering ✓ as literal text (Html.Raw fix)
- Clean up corrupted Unicode box-drawing chars in Job Details view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:39 -04:00
spouliot 3b5511a703 Fix corrupted Unicode characters and intake button rendering in Job Details
- Replace mojibake box-drawing chars (U+2500 encoded as Windows-1252) with
  plain ASCII dashes throughout all comments in Details.cshtml
- Fix intake button showing literal '&#10003;' text: the entity was inside a
  C# string so Razor HTML-encoded the '&'; switched to Html.Raw() so the
  checkmark renders correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:36 -04:00
spouliot 8df37ca760 Fix tax-exempt customers charged tax on all job save paths
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:15:43 -04:00
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00
spouliot 09e077897b Fix quote preview not recalculating when tax-exempt customer is selected
When a customer was changed to/from a tax-exempt customer, the hidden
TaxPercent field was correctly updated to 0 but the live pricing preview
was not re-run, so the display showed a stale total with tax applied.
Selecting a tax-exempt customer now immediately triggers a recalc so
the on-screen total matches the amount that will be saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:58:20 -04:00
spouliot 051c86810e Add missing AddQuotePricingSnapshotFields migration
Seven new decimal columns on Quotes table that were added to the entity
in the pricing audit but the migration was never created (name collision
with a prior attempt in the previous session caused the scaffold to fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:46 -04:00
7 changed files with 10985 additions and 28 deletions
@@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddQuotePricingSnapshotFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadCost",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadRatePerHour",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "SubtotalAfterDiscount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FacilityOverheadCost",
table: "Quotes");
migrationBuilder.DropColumn(
name: "FacilityOverheadRatePerHour",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "SubtotalAfterDiscount",
table: "Quotes");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
}
}
@@ -6714,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6725,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6736,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -6983,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime2");
b.Property<decimal>("FacilityOverheadCost")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("FacilityOverheadRatePerHour")
.HasColumnType("decimal(18,2)");
b.Property<bool>("HideDiscountFromCustomer")
.HasColumnType("bit");
@@ -7028,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<decimal>("PricingTierDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("PricingTierDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ProfitMargin")
.HasColumnType("decimal(18,2)");
@@ -7067,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("QuoteDate")
.HasColumnType("datetime2");
b.Property<decimal>("QuoteDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("QuoteDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("QuoteNumber")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -7092,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("SubTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("SubtotalAfterDiscount")
.HasColumnType("decimal(18,2)");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
@@ -422,7 +422,7 @@ public class JobsController : Controller
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
// Display the pricing snapshot stored when items were last saved.
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
@@ -1130,7 +1130,7 @@ public class JobsController : Controller
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -1598,7 +1598,7 @@ public class JobsController : Controller
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
@@ -2930,7 +2930,7 @@ public class JobsController : Controller
JobId = job.Id,
JobNumber = job.JobNumber,
CustomerId = job.CustomerId,
TaxPercent = costs?.TaxPercent ?? 0m,
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
@@ -2967,7 +2967,7 @@ public class JobsController : Controller
{
ModelState.AddModelError("", "Please add at least one job item.");
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m;
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
@@ -3020,9 +3020,11 @@ public class JobsController : Controller
if (oven != null && oven.CompanyId == currentUser.CompanyId)
ovenRateOverride = oven.CostPerHour;
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -3043,7 +3045,7 @@ public class JobsController : Controller
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
TempData["Error"] = "An error occurred while saving job items.";
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m;
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
return View("EditItems", model);
}
@@ -3110,7 +3112,7 @@ public class JobsController : Controller
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m,
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -3239,6 +3241,21 @@ public class JobsController : Controller
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
/// </summary>
/// <summary>
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
/// are never charged tax when a job is saved or recalculated.
/// </summary>
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
{
if (customerId is > 0)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
if (customer?.IsTaxExempt == true) return 0m;
}
return companyDefaultRate;
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
+16 -16
View File
@@ -321,7 +321,7 @@
<div class="card-body">
@* ── Catalog Products ── *@
@* -- Catalog Products -- *@
@if (catalogItems.Any())
{
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
@@ -414,7 +414,7 @@
</div>
}
@* ── Custom Work ── *@
@* -- Custom Work -- *@
@if (customItems.Any())
{
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
@@ -565,7 +565,7 @@
</div>
}
@* ── Labor ── *@
@* -- Labor -- *@
@if (laborItems.Any())
{
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
@@ -616,7 +616,7 @@
</div>
}
@* ── Mobile cards ── *@
@* -- Mobile cards -- *@
<div class="d-lg-none mt-2">
@foreach (var item in Model.Items)
{
@@ -1310,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake &#10003;" : "Intake")
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake &#10003;" : "Intake")
</a>
}
@{
@@ -2332,7 +2332,7 @@
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
// ── Inline date editing ──────────────────────────────────────────────
// -- Inline date editing ----------------------------------------------
const jobId = @Model.Id;
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
@@ -2433,7 +2433,7 @@
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
// ── Auto-submit after wizard saves an item ────────────────────────
// -- Auto-submit after wizard saves an item ------------------------
let itemsModified = false;
// Wrap wizardSave to set a flag before the modal hides
@@ -2451,7 +2451,7 @@
}
});
// ── Delete confirmation modal ─────────────────────────────────────
// -- Delete confirmation modal -------------------------------------
let pendingDeleteItemId = -1;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
@@ -2489,7 +2489,7 @@
});
</script>
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
<!-- -- Rework / Warranty ---------------------------------------------- -->
<script>
const rework = (() => {
const jid = @Model.Id;
@@ -2645,7 +2645,7 @@
})();
</script>
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
<!-- -- Job Costing ---------------------------------------------------- -->
<script>
const costing = (() => {
const jid = @Model.Id;
@@ -2754,7 +2754,7 @@
})();
</script>
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
<!-- -- Time Tracking --------------------------------------------------- -->
<script>
const timeTracking = (() => {
const jid = @Model.Id;
@@ -2762,7 +2762,7 @@
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = [];
// ── Load ──────────────────────────────────────────────────────────
// -- Load ----------------------------------------------------------
async function load() {
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
entries = await r.json();
@@ -2810,7 +2810,7 @@
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
}
// ── Modal helpers ─────────────────────────────────────────────────
// -- Modal helpers -------------------------------------------------
function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0';
@@ -2917,7 +2917,7 @@
}
});
// ── Deposits ─────────────────────────────────────────────────────────────
// -- Deposits -------------------------------------------------------------
// Note: antiForgeryToken() is already defined above in this script block
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
@@ -2973,7 +2973,7 @@
}
}
// ── Collapsible sections ──────────────────────────────────────────────────
// -- Collapsible sections --------------------------------------------------
(function () {
const storageKey = 'jobDetailCollapse_@Model.Id';
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
@@ -3012,7 +3012,7 @@
});
})();
// ── Part Intake Modal ─────────────────────────────────────────────────────
// -- Part Intake Modal --------------------------------------------------
(function () {
const expectedCount = @intakeExpectedCount;
const partCountInput = document.getElementById('intakePartCount');
@@ -604,6 +604,8 @@
if (taxField) {
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
}
// Recalculate the live preview so it reflects the updated tax rate immediately
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
const noEmail = customerId > 0 && noEmailIds.has(customerId);
const emailSection = document.getElementById('emailNotifySection');
@@ -2904,7 +2904,8 @@ async function runAutoPricing() {
try {
// Collect current form meta
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
const _taxField = document.querySelector('[name="TaxPercent"]');
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
const isRushJob = document.getElementById('IsRushJob')?.checked || false;