From 8df37ca760d55d88670f24661022fc404ccfdd96 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 15 May 2026 16:15:43 -0400 Subject: [PATCH] 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 --- .../Controllers/JobsController.cs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 2fd3a07..2ba68e1 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -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 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. /// + /// + /// 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. + /// + private async Task 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 {