diff --git a/src/PowderCoating.Web/Controllers/CreditMemosController.cs b/src/PowderCoating.Web/Controllers/CreditMemosController.cs new file mode 100644 index 0000000..4f39c2f --- /dev/null +++ b/src/PowderCoating.Web/Controllers/CreditMemosController.cs @@ -0,0 +1,355 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +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. +/// +[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; + + public CreditMemosController( + IUnitOfWork unitOfWork, + ITenantContext tenantContext, + UserManager userManager, + ILogger logger) + { + _unitOfWork = unitOfWork; + _tenantContext = tenantContext; + _userManager = userManager; + _logger = logger; + } + + /// 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); + } + + 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 customers = await _unitOfWork.Customers.GetAllAsync(); + 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; } +} diff --git a/src/PowderCoating.Web/Views/CreditMemos/Create.cshtml b/src/PowderCoating.Web/Views/CreditMemos/Create.cshtml new file mode 100644 index 0000000..1305084 --- /dev/null +++ b/src/PowderCoating.Web/Views/CreditMemos/Create.cshtml @@ -0,0 +1,92 @@ +@model PowderCoating.Web.Controllers.CreditMemoCreateVm +@{ + ViewData["Title"] = "Issue Credit Memo"; + var linkedInvoiceNumber = ViewBag.LinkedInvoiceNumber as string; + var customers = ViewBag.Customers as List ?? new(); +} + +
+

Issue Credit Memo

+ + Back to Credit Memos + +
+ +
+
+
+
Credit Memo Details
+
+
+ @Html.AntiForgeryToken() + + + @if (Model.OriginalInvoiceId.HasValue && !string.IsNullOrEmpty(linkedInvoiceNumber)) + { + +
+ + Linked to invoice @linkedInvoiceNumber +
+ } + +
+ + + +
+ +
+ +
+ $ + +
+ +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + Cancel +
+
+
+
+ +
+
+ + Issuing a credit memo immediately adds the amount to the customer's credit balance. + You can apply it to one or more open invoices from the Credit Memo Details page. +
+
+
+
diff --git a/src/PowderCoating.Web/Views/CreditMemos/Details.cshtml b/src/PowderCoating.Web/Views/CreditMemos/Details.cshtml new file mode 100644 index 0000000..5e078a5 --- /dev/null +++ b/src/PowderCoating.Web/Views/CreditMemos/Details.cshtml @@ -0,0 +1,307 @@ +@model PowderCoating.Core.Entities.CreditMemo +@using PowderCoating.Core.Entities +@using PowderCoating.Core.Enums +@{ + ViewData["Title"] = $"Credit Memo {Model.MemoNumber}"; + var applications = ViewBag.Applications as List ?? new(); + var openInvoices = ViewBag.OpenInvoices as List ?? new(); + bool canApply = ViewBag.CanApply; + + var (badgeClass, badgeLabel) = Model.Status switch + { + CreditMemoStatus.Active => ("bg-success-subtle text-success border border-success-subtle", "Active"), + CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning border border-warning-subtle", "Partially Applied"), + CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary border border-secondary-subtle", "Fully Applied"), + CreditMemoStatus.Voided => ("bg-danger-subtle text-danger border border-danger-subtle", "Voided"), + _ => ("bg-secondary-subtle text-secondary", Model.Status.ToString()) + }; +} + +@if (TempData["Success"] != null) +{ +
+ @TempData["Success"] + +
+} +@if (TempData["Error"] != null) +{ +
+ @TempData["Error"] + +
+} + +@* ── Header ────────────────────────────────────────────────────── *@ +
+ +
+ @if (canApply && openInvoices.Any()) + { + + } + @if (Model.Status != CreditMemoStatus.Voided) + { + + } + + Back + +
+
+ +
+ @* ── Left: memo details ──────────────────────────────────────── *@ +
+
+
Credit Memo Details
+
+ @* Balance summary *@ +
+
+
Total Credit
+
@Model.Amount.ToString("C")
+
+
+
Applied
+
@Model.AmountApplied.ToString("C")
+
+
+
Remaining
+
+ @Model.RemainingBalance.ToString("C") +
+
+
+ +
+
Issue Date
+
@Model.IssueDate.ToLocalTime().ToString("MMMM d, yyyy")
+ +
Expiry Date
+
+ @if (Model.ExpiryDate.HasValue) + { + var expired = Model.ExpiryDate.Value < DateTime.UtcNow; + + @Model.ExpiryDate.Value.ToLocalTime().ToString("MMMM d, yyyy") + @if (expired) { (Expired) } + + } + else + { + No expiry + } +
+ + @if (Model.OriginalInvoice != null) + { +
Original Invoice
+
+ @Model.OriginalInvoice.InvoiceNumber +
+ } + +
Issued By
+
@(Model.IssuedBy?.FullName ?? "System")
+ +
Reason
+
@Model.Reason
+ + @if (!string.IsNullOrWhiteSpace(Model.Notes)) + { +
Notes
+
@Model.Notes
+ } +
+
+
+
+ + @* ── Right: application history ──────────────────────────────── *@ +
+
+
+ Application History + @applications.Count applied +
+
+ @if (!applications.Any()) + { +
+ + This credit memo has not been applied to any invoice yet. + @if (canApply && openInvoices.Any()) + { + Use Apply to Invoice above to apply it. + } + else if (canApply && !openInvoices.Any()) + { + No open invoices with a balance due for this customer. + } +
+ } + else + { +
+ + + + + + + + + + + @foreach (var a in applications) + { + + + + + + + } + +
InvoiceDate AppliedAmountApplied By
+ + @(a.Invoice?.InvoiceNumber ?? $"#{a.InvoiceId}") + + @a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")@a.AmountApplied.ToString("C")@(a.AppliedBy?.FullName ?? "—")
+
+ } +
+
+
+
+ +@* ── Apply Modal ─────────────────────────────────────────────── *@ +@if (canApply) +{ + +} + +@* ── Void Confirm Modal ──────────────────────────────────────── *@ +@if (Model.Status != CreditMemoStatus.Voided) +{ + +} + +@section Scripts { + +} diff --git a/src/PowderCoating.Web/Views/CreditMemos/Index.cshtml b/src/PowderCoating.Web/Views/CreditMemos/Index.cshtml new file mode 100644 index 0000000..fc6cb94 --- /dev/null +++ b/src/PowderCoating.Web/Views/CreditMemos/Index.cshtml @@ -0,0 +1,166 @@ +@model List +@using PowderCoating.Core.Enums +@{ + ViewData["Title"] = "Credit Memos"; + var status = ViewBag.Status as string ?? ""; + var search = ViewBag.Search as string ?? ""; + int activeCount = ViewBag.ActiveCount; + decimal outstanding = ViewBag.OutstandingBalance; +} + +
+

Credit Memos

+ + Issue Credit Memo + +
+ +@if (TempData["Success"] != null) +{ +
+ @TempData["Success"] + +
+} +@if (TempData["Error"] != null) +{ +
+ @TempData["Error"] + +
+} + +@* ── Stats bar ─────────────────────────────────────────────────── *@ +
+
+
+
+
Active Memos
+
@activeCount
+
+
+
+
+
+
+
Outstanding Credit
+
@outstanding.ToString("C")
+
+
+
+
+
+
+
Total Memos
+
@Model.Count
+
+
+
+
+
+
+
Total Issued
+
@Model.Sum(m => m.Amount).ToString("C")
+
+
+
+
+ +@* ── Filters ──────────────────────────────────────────────────── *@ +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +@* ── Table ────────────────────────────────────────────────────── *@ +@if (!Model.Any()) +{ +
No credit memos found.
+} +else +{ +
+
+ + + + + + + + + + + + + + + + @foreach (var m in Model) + { + var rowClass = m.Status == CreditMemoStatus.Voided ? "text-muted" : ""; + var expired = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow + && m.Status != CreditMemoStatus.FullyApplied + && m.Status != CreditMemoStatus.Voided; + + + + + + + + + + + + } + +
Memo #CustomerAmountAppliedRemainingIssue DateExpiresStatus
+ + @m.MemoNumber + + @(string.IsNullOrWhiteSpace(m.Customer?.CompanyName) ? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim() : m.Customer.CompanyName)@m.Amount.ToString("C")@m.AmountApplied.ToString("C") + @m.RemainingBalance.ToString("C") + @m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy") + @(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—") + @if (expired) { (Expired) } + + @{ + var (badgeClass, badgeLabel) = m.Status switch + { + CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"), + CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"), + CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"), + CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"), + _ => ("bg-secondary-subtle text-secondary", m.Status.ToString()) + }; + } + @badgeLabel + + Details +
+
+
+} diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml index 99b19be..94ea8da 100644 --- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml @@ -1077,6 +1077,10 @@ Online Payments } + + + Credit Memos + Gift Certificates diff --git a/src/PowderCoating.Web/wwwroot/js/credit-memo.js b/src/PowderCoating.Web/wwwroot/js/credit-memo.js new file mode 100644 index 0000000..cd072ae --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/credit-memo.js @@ -0,0 +1,30 @@ +(function () { + 'use strict'; + + document.addEventListener('DOMContentLoaded', function () { + var modal = document.getElementById('applyModal'); + if (!modal) return; + + var remainingBalance = parseFloat(modal.dataset.remainingBalance || '0'); + var invoiceSelect = document.getElementById('applyInvoiceId'); + var amountInput = document.getElementById('applyAmount'); + var maxHint = document.getElementById('applyMaxHint'); + + if (!invoiceSelect) return; + + invoiceSelect.addEventListener('change', function () { + var selected = this.options[this.selectedIndex]; + if (!selected || !selected.value) { + amountInput.value = ''; + if (maxHint) maxHint.textContent = ''; + return; + } + + var invoiceBalance = parseFloat(selected.dataset.balance || '0'); + var max = Math.min(remainingBalance, invoiceBalance); + amountInput.value = max.toFixed(2); + amountInput.max = max.toFixed(2); + if (maxHint) maxHint.textContent = 'Max applicable: $' + max.toFixed(2); + }); + }); +})();