Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CreditMemosController.cs
T
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
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>
2026-05-17 18:04:22 -04:00

378 lines
16 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
/// customer.CreditBalance atomically inside a transaction.
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
/// a revenue deduction and an AR reduction.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CreditMemosController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<CreditMemosController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public CreditMemosController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ILogger<CreditMemosController> logger,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
[HttpGet]
public async Task<IActionResult> Index(string? status, string? search)
{
var memos = await _unitOfWork.CreditMemos.FindAsync(
m => true, false,
m => m.Customer);
if (!string.IsNullOrWhiteSpace(search))
memos = memos.Where(m =>
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
memos = memos.Where(m => m.Status == parsed).ToList();
ViewBag.Status = status ?? "";
ViewBag.Search = search ?? "";
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
ViewBag.OutstandingBalance = memos
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
.Sum(m => m.RemainingBalance);
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
}
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
[HttpGet]
public async Task<IActionResult> Details(int id)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
id, false,
m => m.Customer,
m => m.OriginalInvoice,
m => m.IssuedBy);
if (memo == null) return NotFound();
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
a => a.CreditMemoId == id, false,
a => a.Invoice,
a => a.AppliedBy);
var openInvoices = await _unitOfWork.Invoices.FindAsync(
i => i.CustomerId == memo.CustomerId
&& i.Status != InvoiceStatus.Paid
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff);
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
&& memo.RemainingBalance > 0;
return View(memo);
}
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
[HttpGet]
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
{
string? linkedInvoiceNumber = null;
if (invoiceId.HasValue)
{
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
if (inv != null)
{
linkedInvoiceNumber = inv.InvoiceNumber;
customerId ??= inv.CustomerId;
}
}
await PopulateCustomersAsync(customerId);
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
return View(new CreditMemoCreateVm
{
CustomerId = customerId ?? 0,
OriginalInvoiceId = invoiceId
});
}
/// <summary>
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
/// credit is visible on the customer account before it is applied to any specific invoice.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
{
if (!ModelState.IsValid)
{
await PopulateCustomersAsync(vm.CustomerId);
return View(vm);
}
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
if (customer == null)
{
ModelState.AddModelError("CustomerId", "Customer not found.");
await PopulateCustomersAsync(null);
return View(vm);
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var memoNumber = await GenerateMemoNumberAsync(companyId);
var currentUser = await _userManager.GetUserAsync(User);
var memo = new CreditMemo
{
MemoNumber = memoNumber,
CustomerId = vm.CustomerId,
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
Amount = vm.Amount,
AmountApplied = 0,
IssueDate = DateTime.UtcNow,
ExpiryDate = vm.ExpiryDate.HasValue
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
: null,
Reason = vm.Reason,
Notes = vm.Notes,
Status = CreditMemoStatus.Active,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.CreditMemos.AddAsync(memo);
customer.CreditBalance += vm.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
return RedirectToAction(nameof(Details), new { id = memo.Id });
}
/// <summary>
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
{
try
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
if (memo == null) return NotFound();
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
if (invoice == null)
{
TempData["Error"] = "Invoice not found.";
return RedirectToAction(nameof(Details), new { id });
}
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
{
TempData["Error"] = "Credit memo is not available to apply.";
return RedirectToAction(nameof(Details), new { id });
}
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
if (applyAmount <= 0)
{
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
{
CreditMemoId = id,
InvoiceId = invoiceId,
AmountApplied = applyAmount,
AppliedDate = DateTime.UtcNow,
AppliedById = currentUser?.Id,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
});
invoice.CreditApplied += applyAmount;
await _unitOfWork.Invoices.UpdateAsync(invoice);
memo.AmountApplied += applyAmount;
memo.Status = memo.AmountApplied >= memo.Amount
? CreditMemoStatus.FullyApplied
: CreditMemoStatus.PartiallyApplied;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
if (invoice.Customer != null)
{
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
{
invoice.Status = InvoiceStatus.Paid;
invoice.PaidDate = DateTime.UtcNow;
await _unitOfWork.Invoices.UpdateAsync(invoice);
}
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The dynamic report computation attributes credit memo applications to both
// accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive)
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive
&& a.Name.ToLower().Contains("discount"));
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
TempData["Error"] = "An error occurred applying the credit.";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
/// settled and form part of the immutable audit trail.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Void(int id)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
if (memo == null) return NotFound();
if (memo.Status == CreditMemoStatus.Voided)
{
TempData["Error"] = "Credit memo is already voided.";
return RedirectToAction(nameof(Details), new { id });
}
var remaining = memo.Amount - memo.AmountApplied;
memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
if (remaining > 0 && memo.Customer != null)
{
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
return RedirectToAction(nameof(Details), new { id });
}
private async Task PopulateCustomersAsync(int? selectedId)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
Selected = c.Id == selectedId
})
.ToList();
}
/// <summary>
/// Generates the next sequential memo number in CM-YYMM-#### format.
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
/// </summary>
private async Task<string> GenerateMemoNumberAsync(int companyId)
{
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = (await _unitOfWork.CreditMemos.FindAsync(
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
.Select(m => m.MemoNumber)
.ToList();
var maxNum = 0;
foreach (var num in existing)
{
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
maxNum = n;
}
return $"{prefix}{(maxNum + 1):D4}";
}
private static string DisplayName(Customer? c) =>
c == null ? string.Empty :
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
}
public class CreditMemoCreateVm
{
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
public int CustomerId { get; set; }
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
public decimal Amount { get; set; }
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
public string Reason { get; set; } = string.Empty;
[MaxLength(2000)]
public string? Notes { get; set; }
public DateTime? ExpiryDate { get; set; }
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
public int? OriginalInvoiceId { get; set; }
}