8acbc8605d
Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
4.8 KiB
C#
142 lines
4.8 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// Manages named tax rates used to pre-fill the tax percent field on invoices when a taxable
|
|
/// customer is selected. Only one rate may be marked as default at a time; that default is
|
|
/// auto-applied via the GetTaxRateForCustomer AJAX endpoint on the Invoice Create form.
|
|
/// </summary>
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public class TaxRatesController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ILogger<TaxRatesController> _logger;
|
|
|
|
public TaxRatesController(
|
|
IUnitOfWork unitOfWork,
|
|
ITenantContext tenantContext,
|
|
ILogger<TaxRatesController> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_tenantContext = tenantContext;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>Lists all tax rates for the current company.</summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
|
|
return View(rates.OrderBy(r => r.Name).ToList());
|
|
}
|
|
|
|
[HttpGet]
|
|
public IActionResult Create() => View(new TaxRate());
|
|
|
|
/// <summary>Creates a new tax rate. Enforces that only one rate is the default.</summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Create(TaxRate model)
|
|
{
|
|
if (!ModelState.IsValid) return View(model);
|
|
|
|
if (model.IsDefault)
|
|
await ClearOtherDefaultsAsync(0);
|
|
|
|
await _unitOfWork.TaxRates.AddAsync(model);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = $"Tax rate \"{model.Name}\" created.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
|
if (rate == null) return NotFound();
|
|
return View(rate);
|
|
}
|
|
|
|
/// <summary>Updates an existing tax rate. Clears default flag on other rates when IsDefault is set.</summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(int id, TaxRate model)
|
|
{
|
|
if (id != model.Id) return BadRequest();
|
|
if (!ModelState.IsValid) return View(model);
|
|
|
|
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
|
if (rate == null) return NotFound();
|
|
|
|
if (model.IsDefault && !rate.IsDefault)
|
|
await ClearOtherDefaultsAsync(id);
|
|
|
|
rate.Name = model.Name;
|
|
rate.Rate = model.Rate;
|
|
rate.State = model.State;
|
|
rate.Description = model.Description;
|
|
rate.IsDefault = model.IsDefault;
|
|
rate.IsActive = model.IsActive;
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = $"Tax rate \"{rate.Name}\" updated.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>Toggles IsActive without a full page reload.</summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ToggleActive(int id)
|
|
{
|
|
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
|
if (rate == null) return NotFound();
|
|
|
|
rate.IsActive = !rate.IsActive;
|
|
if (!rate.IsActive) rate.IsDefault = false;
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>Soft-deletes a tax rate. Blocked when the rate is currently the default.</summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Delete(int id)
|
|
{
|
|
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
|
if (rate == null) return NotFound();
|
|
|
|
if (rate.IsDefault)
|
|
{
|
|
TempData["Error"] = "Cannot delete the default tax rate. Set another rate as default first.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
await _unitOfWork.TaxRates.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = $"Tax rate \"{rate.Name}\" deleted.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears IsDefault on all rates except the one with <paramref name="exceptId"/>.
|
|
/// Called before saving a newly-designated default to enforce the single-default invariant.
|
|
/// </summary>
|
|
private async Task ClearOtherDefaultsAsync(int exceptId)
|
|
{
|
|
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
|
|
foreach (var r in others)
|
|
r.IsDefault = false;
|
|
}
|
|
}
|