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>
378 lines
16 KiB
C#
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; }
|
|
}
|