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:
@@ -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;
|
||||
|
||||
/// <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.
|
||||
/// </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;
|
||||
|
||||
public CreditMemosController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<CreditMemosController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user