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; /// /// 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. /// [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class CreditMemosController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IAccountBalanceService _accountBalanceService; public CreditMemosController( IUnitOfWork unitOfWork, ITenantContext tenantContext, UserManager userManager, ILogger logger, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _userManager = userManager; _logger = logger; _accountBalanceService = accountBalanceService; } /// Lists all credit memos for the current company with optional status and text filters. [HttpGet] public async Task 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(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()); } /// Shows a single credit memo with its full application history and an Apply modal for open invoices. [HttpGet] public async Task 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); } /// Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate. [HttpGet] public async Task 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 }); } /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] public async Task 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 }); } /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] public async Task 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 }); } /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] public async Task 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(); } /// /// Generates the next sequential memo number in CM-YYMM-#### format. /// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse. /// private async Task 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; } /// Optional link to the invoice that prompted this credit (price dispute, billing error, etc.). public int? OriginalInvoiceId { get; set; } }