Add Credit Memos standalone management module

CreditMemosController with Index, Details, Create, Apply, and Void actions.
All business logic (atomic apply transaction, RemainingBalance cap,
customer.CreditBalance adjustment, auto-Paid invoice when BalanceDue hits zero)
mirrors the invoice-centric IssueCreditMemo/ApplyCredit/VoidCreditMemo actions in
InvoicesController but redirects back to the credit memo rather than an invoice.

Views: Index (stats bar, status+search filter, table), Details (two-col layout
with application history table and Bootstrap Apply/Void confirm modals),
Create (customer dropdown, amount, reason, notes, optional expiry).

Apply modal populates amount automatically from min(remaining credit,
invoice balance due) via credit-memo.js data-attribute wiring (no inline scripts).

Nav: Credit Memos added to Billing & Payments section in _Layout.

Build: 0 errors. Unit tests: 200/200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:48:35 -04:00
parent d94612cc9c
commit a255893ada
6 changed files with 954 additions and 0 deletions
@@ -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<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-journal-minus me-2 text-primary"></i>Issue Credit Memo</h4>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Credit Memos
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header fw-semibold">Credit Memo Details</div>
<div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<asp-validation-summary asp-validation-summary="All" class="alert alert-danger alert-permanent"></asp-validation-summary>
@if (Model.OriginalInvoiceId.HasValue && !string.IsNullOrEmpty(linkedInvoiceNumber))
{
<input type="hidden" asp-for="OriginalInvoiceId" />
<div class="alert alert-info alert-permanent py-2 mb-3 small">
<i class="bi bi-link-45deg me-1"></i>
Linked to invoice <strong>@linkedInvoiceNumber</strong>
</div>
}
<div class="mb-3">
<label asp-for="CustomerId" class="form-label">Customer <span class="text-danger">*</span></label>
<select asp-for="CustomerId" asp-items="customers" class="form-select">
<option value="0">— select customer —</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Amount" class="form-label">Credit Amount <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" step="0.01" min="0.01"
class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Reason" class="form-label">Reason <span class="text-danger">*</span></label>
<input asp-for="Reason" class="form-control"
placeholder="e.g. Price adjustment, billing error, goodwill credit…" />
<span asp-validation-for="Reason" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Notes" class="form-label">Internal Notes</label>
<textarea asp-for="Notes" class="form-control" rows="3"
placeholder="Additional context for your records (not shown to customer)"></textarea>
<span asp-validation-for="Notes" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="ExpiryDate" class="form-label">
Expiry Date
<span class="text-muted small ms-1">(optional — leave blank for no expiry)</span>
</label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Issue Credit Memo
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<div class="card mt-3 border-0 bg-light">
<div class="card-body py-2 small text-muted">
<i class="bi bi-info-circle me-1"></i>
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.
</div>
</div>
</div>
</div>
@@ -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<CreditMemoApplication> ?? new();
var openInvoices = ViewBag.OpenInvoices as List<Invoice> ?? 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)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@* ── Header ────────────────────────────────────────────────────── *@
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h4 class="mb-1">
<i class="bi bi-journal-minus me-2 text-primary"></i>@Model.MemoNumber
<span class="badge @badgeClass ms-2 fs-6">@badgeLabel</span>
</h4>
<div class="text-muted">
Customer: <a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId"
class="fw-semibold text-decoration-none">
@(string.IsNullOrWhiteSpace(Model.Customer?.CompanyName) ? $"{Model.Customer?.ContactFirstName} {Model.Customer?.ContactLastName}".Trim() : Model.Customer.CompanyName)
</a>
</div>
</div>
<div class="d-flex gap-2">
@if (canApply && openInvoices.Any())
{
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#applyModal">
<i class="bi bi-check2-circle me-1"></i>Apply to Invoice
</button>
}
@if (Model.Status != CreditMemoStatus.Voided)
{
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#voidModal">
<i class="bi bi-x-circle me-1"></i>Void
</button>
}
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<div class="row g-3">
@* ── Left: memo details ──────────────────────────────────────── *@
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header fw-semibold">Credit Memo Details</div>
<div class="card-body">
@* Balance summary *@
<div class="row text-center mb-4">
<div class="col-4 border-end">
<div class="small text-muted">Total Credit</div>
<div class="fs-5 fw-bold">@Model.Amount.ToString("C")</div>
</div>
<div class="col-4 border-end">
<div class="small text-muted">Applied</div>
<div class="fs-5 fw-bold text-secondary">@Model.AmountApplied.ToString("C")</div>
</div>
<div class="col-4">
<div class="small text-muted">Remaining</div>
<div class="fs-5 fw-bold @(Model.RemainingBalance > 0 ? "text-success" : "text-secondary")">
@Model.RemainingBalance.ToString("C")
</div>
</div>
</div>
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Issue Date</dt>
<dd class="col-7">@Model.IssueDate.ToLocalTime().ToString("MMMM d, yyyy")</dd>
<dt class="col-5 text-muted">Expiry Date</dt>
<dd class="col-7">
@if (Model.ExpiryDate.HasValue)
{
var expired = Model.ExpiryDate.Value < DateTime.UtcNow;
<span class="@(expired ? "text-danger fw-semibold" : "")">
@Model.ExpiryDate.Value.ToLocalTime().ToString("MMMM d, yyyy")
@if (expired) { <small>(Expired)</small> }
</span>
}
else
{
<span class="text-muted">No expiry</span>
}
</dd>
@if (Model.OriginalInvoice != null)
{
<dt class="col-5 text-muted">Original Invoice</dt>
<dd class="col-7">
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@Model.OriginalInvoiceId"
class="text-decoration-none">@Model.OriginalInvoice.InvoiceNumber</a>
</dd>
}
<dt class="col-5 text-muted">Issued By</dt>
<dd class="col-7">@(Model.IssuedBy?.FullName ?? "System")</dd>
<dt class="col-5 text-muted">Reason</dt>
<dd class="col-7">@Model.Reason</dd>
@if (!string.IsNullOrWhiteSpace(Model.Notes))
{
<dt class="col-5 text-muted">Notes</dt>
<dd class="col-7">@Model.Notes</dd>
}
</dl>
</div>
</div>
</div>
@* ── Right: application history ──────────────────────────────── *@
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
<span>Application History</span>
<span class="badge bg-secondary-subtle text-secondary">@applications.Count applied</span>
</div>
<div class="card-body p-0">
@if (!applications.Any())
{
<div class="p-4 text-muted text-center">
<i class="bi bi-info-circle me-1"></i>
This credit memo has not been applied to any invoice yet.
@if (canApply && openInvoices.Any())
{
<span>Use <strong>Apply to Invoice</strong> above to apply it.</span>
}
else if (canApply && !openInvoices.Any())
{
<span>No open invoices with a balance due for this customer.</span>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Invoice</th>
<th>Date Applied</th>
<th class="text-end">Amount</th>
<th>Applied By</th>
</tr>
</thead>
<tbody>
@foreach (var a in applications)
{
<tr>
<td>
<a asp-controller="Invoices" asp-action="Details"
asp-route-id="@a.InvoiceId" class="text-decoration-none">
@(a.Invoice?.InvoiceNumber ?? $"#{a.InvoiceId}")
</a>
</td>
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
<td class="small text-muted">@(a.AppliedBy?.FullName ?? "—")</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
@* ── Apply Modal ─────────────────────────────────────────────── *@
@if (canApply)
{
<div class="modal fade" id="applyModal" tabindex="-1"
data-remaining-balance="@Model.RemainingBalance.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="Apply" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<div class="modal-header">
<h5 class="modal-title">Apply Credit to Invoice</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info alert-permanent py-2 small">
<strong>Available credit: @Model.RemainingBalance.ToString("C")</strong>
</div>
@if (!openInvoices.Any())
{
<p class="text-muted">No open invoices with a balance due for this customer.</p>
}
else
{
<div class="mb-3">
<label class="form-label">Select Invoice</label>
<select name="invoiceId" id="applyInvoiceId" class="form-select" required>
<option value="">— choose invoice —</option>
@foreach (var inv in openInvoices)
{
<option value="@inv.Id"
data-balance="@inv.BalanceDue.ToString("F2")">
@inv.InvoiceNumber — Due @inv.BalanceDue.ToString("C")
@if (inv.DueDate.HasValue && inv.DueDate.Value < DateTime.UtcNow)
{ <text>(Overdue)</text> }
</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Amount to Apply</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="amount" id="applyAmount"
class="form-control" step="0.01" min="0.01"
max="@Model.RemainingBalance.ToString("F2")" required />
</div>
<div id="applyMaxHint" class="form-text text-muted"></div>
</div>
}
</div>
@if (openInvoices.Any())
{
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Apply Credit</button>
</div>
}
else
{
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
}
</form>
</div>
</div>
</div>
}
@* ── Void Confirm Modal ──────────────────────────────────────── *@
@if (Model.Status != CreditMemoStatus.Voided)
{
<div class="modal fade" id="voidModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="Void" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<div class="modal-header">
<h5 class="modal-title">Void Credit Memo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to void <strong>@Model.MemoNumber</strong>?</p>
@if (Model.RemainingBalance > 0)
{
<div class="alert alert-warning alert-permanent py-2">
The unapplied balance of <strong>@Model.RemainingBalance.ToString("C")</strong>
will be reversed from <strong>@(string.IsNullOrWhiteSpace(Model.Customer?.CompanyName) ? $"{Model.Customer?.ContactFirstName} {Model.Customer?.ContactLastName}".Trim() : Model.Customer?.CompanyName)</strong>'s credit balance.
</div>
}
@if (Model.AmountApplied > 0)
{
<p class="small text-muted mb-0">
The <strong>@Model.AmountApplied.ToString("C")</strong> already applied to invoices
will <em>not</em> be reversed.
</p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Void Credit Memo</button>
</div>
</form>
</div>
</div>
</div>
}
@section Scripts {
<script src="~/js/credit-memo.js"></script>
}
@@ -0,0 +1,166 @@
@model List<PowderCoating.Core.Entities.CreditMemo>
@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;
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-journal-minus me-2 text-primary"></i>Credit Memos</h4>
<a asp-action="Create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Issue Credit Memo
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@* ── Stats bar ─────────────────────────────────────────────────── *@
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-primary-subtle h-100">
<div class="card-body">
<div class="text-primary fw-semibold small">Active Memos</div>
<div class="fs-3 fw-bold">@activeCount</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-warning-subtle h-100">
<div class="card-body">
<div class="text-warning fw-semibold small">Outstanding Credit</div>
<div class="fs-3 fw-bold">@outstanding.ToString("C")</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-success-subtle h-100">
<div class="card-body">
<div class="text-success fw-semibold small">Total Memos</div>
<div class="fs-3 fw-bold">@Model.Count</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-secondary-subtle h-100">
<div class="card-body">
<div class="text-secondary fw-semibold small">Total Issued</div>
<div class="fs-3 fw-bold">@Model.Sum(m => m.Amount).ToString("C")</div>
</div>
</div>
</div>
</div>
@* ── Filters ──────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small mb-1">Search</label>
<input name="search" value="@search" class="form-control form-control-sm"
placeholder="Customer, memo #, or reason…" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="" selected="@(status == "")">All Statuses</option>
<option value="Active" selected="@(status == "Active")">Active</option>
<option value="PartiallyApplied" selected="@(status == "PartiallyApplied")">Partially Applied</option>
<option value="FullyApplied" selected="@(status == "FullyApplied")">Fully Applied</option>
<option value="Voided" selected="@(status == "Voided")">Voided</option>
</select>
</div>
<div class="col-md-auto">
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
</div>
</form>
</div>
</div>
@* ── Table ────────────────────────────────────────────────────── *@
@if (!Model.Any())
{
<div class="alert alert-info alert-permanent">No credit memos found.</div>
}
else
{
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Memo #</th>
<th>Customer</th>
<th class="text-end">Amount</th>
<th class="text-end">Applied</th>
<th class="text-end">Remaining</th>
<th>Issue Date</th>
<th>Expires</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@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;
<tr class="@rowClass">
<td>
<a asp-action="Details" asp-route-id="@m.Id" class="fw-semibold text-decoration-none">
@m.MemoNumber
</a>
</td>
<td>@(string.IsNullOrWhiteSpace(m.Customer?.CompanyName) ? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim() : m.Customer.CompanyName)</td>
<td class="text-end">@m.Amount.ToString("C")</td>
<td class="text-end">@m.AmountApplied.ToString("C")</td>
<td class="text-end @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "")">
@m.RemainingBalance.ToString("C")
</td>
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="@(expired ? "text-danger fw-semibold" : "")">
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
@if (expired) { <small>(Expired)</small> }
</td>
<td>
@{
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())
};
}
<span class="badge @badgeClass">@badgeLabel</span>
</td>
<td class="text-end">
<a asp-action="Details" asp-route-id="@m.Id"
class="btn btn-sm btn-outline-primary">Details</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@@ -1077,6 +1077,10 @@
<span>Online Payments</span>
</a>
}
<a asp-controller="CreditMemos" asp-action="Index" class="nav-link">
<i class="bi bi-journal-minus"></i>
<span>Credit Memos</span>
</a>
<a asp-controller="GiftCertificates" asp-action="Index" class="nav-link">
<i class="bi bi-gift"></i>
<span>Gift Certificates</span>