using Microsoft.AspNetCore.Authorization; 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; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanViewData)] public class VendorCreditsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly IAccountBalanceService _accountBalanceService; public VendorCreditsController( IUnitOfWork unitOfWork, ITenantContext tenantContext, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _accountBalanceService = accountBalanceService; } private bool AllowAccounting() => User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager"); // ── Index ──────────────────────────────────────────────────────────────── /// Lists vendor credits grouped by status with unapplied balance summary. public async Task Index(string? status) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var all = (await _unitOfWork.VendorCredits.FindAsync( vc => vc.CompanyId == companyId, false, vc => vc.Vendor)) .OrderByDescending(vc => vc.CreditDate) .ThenByDescending(vc => vc.Id) .ToList(); var displayed = status switch { "Open" => all.Where(vc => vc.Status == VendorCreditStatus.Open).ToList(), "Partial" => all.Where(vc => vc.Status == VendorCreditStatus.PartiallyApplied).ToList(), "Applied" => all.Where(vc => vc.Status == VendorCreditStatus.Applied).ToList(), "Voided" => all.Where(vc => vc.Status == VendorCreditStatus.Voided).ToList(), _ => all }; ViewBag.StatusFilter = status ?? "All"; ViewBag.TotalCount = all.Count; ViewBag.OpenCount = all.Count(vc => vc.Status == VendorCreditStatus.Open); ViewBag.PartialCount = all.Count(vc => vc.Status == VendorCreditStatus.PartiallyApplied); ViewBag.TotalUnapplied = all .Where(vc => vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied) .Sum(vc => vc.RemainingAmount); return View(displayed); } // ── Create ─────────────────────────────────────────────────────────────── [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public async Task Create() { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); await PopulateDropdownsAsync(); return View(new VendorCredit { CreditDate = DateTime.Today }); } [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Create( VendorCredit model, int[] lineAccountIds, string[] lineDescriptions, decimal[] lineAmounts) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var lines = BuildLines(lineAccountIds, lineDescriptions, lineAmounts); if (lines.Count == 0) { TempData["Error"] = "At least one line item is required."; await PopulateDropdownsAsync(); return View(model); } var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; model.CreditNumber = await GenerateCreditNumberAsync(companyId); model.Status = VendorCreditStatus.Open; model.Total = lines.Sum(l => l.Amount); model.RemainingAmount = model.Total; model.LineItems = lines; await _unitOfWork.VendorCredits.AddAsync(model); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Vendor credit {model.CreditNumber} created."; return RedirectToAction(nameof(Details), new { id = model.Id }); } // ── Details ────────────────────────────────────────────────────────────── public async Task Details(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var vc = (await _unitOfWork.VendorCredits.FindAsync( v => v.Id == id, false, v => v.Vendor, v => v.APAccount, v => v.LineItems, v => v.Applications)) .FirstOrDefault(); if (vc == null) return NotFound(); // Load account names for lines var accountIds = vc.LineItems .Where(l => l.AccountId.HasValue) .Select(l => l.AccountId!.Value) .Distinct() .ToList(); var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)); ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}"); // Load bills referenced by applications if (vc.Applications.Any()) { var billIds = vc.Applications.Select(a => a.BillId).ToList(); var bills = await _unitOfWork.Bills.FindAsync(b => billIds.Contains(b.Id)); ViewBag.BillMap = bills.ToDictionary(b => b.Id, b => b.BillNumber); } else { ViewBag.BillMap = new Dictionary(); } // Unapplied bills from the same vendor for the "Apply" panel if (vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied) { var openBills = await _unitOfWork.Bills.FindAsync( b => b.VendorId == vc.VendorId && (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)); ViewBag.OpenBills = openBills.OrderBy(b => b.DueDate).ToList(); } return View(vc); } // ── Post ───────────────────────────────────────────────────────────────── /// /// Posts a vendor credit to the GL: /// DR Accounts Payable (APAccountId) — vendor owes us, reduces AP /// CR Expense/COGS accounts (each line) — reverses the original expense /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Post(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var vc = (await _unitOfWork.VendorCredits.FindAsync( v => v.Id == id, false, v => v.LineItems)) .FirstOrDefault(); if (vc == null) return NotFound(); if (vc.Status != VendorCreditStatus.Open) { TempData["Error"] = "Only open (unposted) credits can be posted."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.ExecuteInTransactionAsync(async () => { // DR AP (reduces what we owe the vendor) await _accountBalanceService.DebitAsync(vc.APAccountId, vc.Total); // CR each expense account (reverses original expense) foreach (var line in vc.LineItems) await _accountBalanceService.CreditAsync(line.AccountId, line.Amount); // Record posting date so Void() can reverse only if GL entries were actually made. vc.PostedDate = DateTime.UtcNow; await _unitOfWork.VendorCredits.UpdateAsync(vc); // Status stays Open — the credit is now in the GL but not yet applied to a bill await _unitOfWork.CompleteAsync(); }); TempData["Success"] = $"Vendor credit {vc.CreditNumber} posted to GL."; return RedirectToAction(nameof(Details), new { id }); } // ── Apply ───────────────────────────────────────────────────────────────── /// /// Applies a vendor credit against a vendor bill. Reduces Bill.AmountPaid (increasing balance) /// and VendorCredit.RemainingAmount. No additional GL posting — AP was already adjusted when /// the credit was posted. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Apply(int id, int billId, decimal amount) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id); var bill = await _unitOfWork.Bills.GetByIdAsync(billId); if (vc == null || bill == null) return NotFound(); if (amount <= 0 || amount > vc.RemainingAmount || amount > bill.BalanceDue) { TempData["Error"] = "Invalid application amount."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.ExecuteInTransactionAsync(async () => { var application = new VendorCreditApplication { VendorCreditId = vc.Id, BillId = bill.Id, Amount = amount, AppliedDate = DateTime.UtcNow }; await _unitOfWork.VendorCreditApplications.AddAsync(application); // Update bill — treated as a payment against the balance bill.AmountPaid += amount; if (bill.AmountPaid >= bill.Total) bill.Status = BillStatus.Paid; else if (bill.AmountPaid > 0) bill.Status = BillStatus.PartiallyPaid; // Update credit vc.RemainingAmount -= amount; vc.Status = vc.RemainingAmount <= 0 ? VendorCreditStatus.Applied : VendorCreditStatus.PartiallyApplied; await _unitOfWork.CompleteAsync(); }); TempData["Success"] = $"Applied {amount:C} of credit {vc.CreditNumber} to bill {bill.BillNumber}."; return RedirectToAction(nameof(Details), new { id }); } // ── Void ───────────────────────────────────────────────────────────────── /// /// Voids a vendor credit. If the credit was previously posted (PostedDate is set), reverses the /// original GL entries: CR Accounts Payable / DR each expense line item, restoring both balances. /// Only the unapplied RemainingAmount of AP is reversed — applied portions reduced bill balances /// that are already settled and remain part of the immutable audit trail. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [ValidateAntiForgeryToken] public async Task Void(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var vc = (await _unitOfWork.VendorCredits.FindAsync( v => v.Id == id, false, v => v.LineItems)) .FirstOrDefault(); if (vc == null) return NotFound(); if (vc.Status == VendorCreditStatus.Applied) { TempData["Error"] = "Fully applied credits cannot be voided."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.ExecuteInTransactionAsync(async () => { // Reverse GL only if Post() was previously called; unposted credits have no GL entries. if (vc.PostedDate.HasValue && vc.RemainingAmount > 0) { // CR AP for the unapplied amount (undoes the debit made at Post time) await _accountBalanceService.CreditAsync(vc.APAccountId, vc.RemainingAmount); // DR each expense line proportionally (unapplied fraction of each line) var applyRatio = vc.Total > 0 ? vc.RemainingAmount / vc.Total : 1m; foreach (var line in vc.LineItems) await _accountBalanceService.DebitAsync(line.AccountId, line.Amount * applyRatio); } vc.Status = VendorCreditStatus.Voided; vc.RemainingAmount = 0; await _unitOfWork.VendorCredits.UpdateAsync(vc); await _unitOfWork.CompleteAsync(); }); TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided."; return RedirectToAction(nameof(Index)); } // ── Helpers ────────────────────────────────────────────────────────────── private static List BuildLines( int[] accountIds, string[] descriptions, decimal[] amounts) { var lines = new List(); for (int i = 0; i < accountIds.Length; i++) { if (i < amounts.Length && amounts[i] > 0) lines.Add(new VendorCreditLineItem { AccountId = accountIds[i] > 0 ? accountIds[i] : null, Description = i < descriptions.Length ? descriptions[i] : string.Empty, Amount = amounts[i] }); } return lines; } /// /// Generates the next sequential credit number in the format VC-YYMM-####. /// Uses ignoreQueryFilters so voided/deleted records are included and numbers are never reused. /// private async Task GenerateCreditNumberAsync(int companyId) { var prefix = $"VC-{DateTime.Now:yyMM}-"; var all = await _unitOfWork.VendorCredits.FindAsync( vc => vc.CompanyId == companyId && vc.CreditNumber.StartsWith(prefix), ignoreQueryFilters: true); int next = 1; if (all.Any()) { var nums = all .Select(vc => vc.CreditNumber[prefix.Length..]) .Select(s => int.TryParse(s, out int n) ? n : 0); next = nums.Max() + 1; } return $"{prefix}{next:D4}"; } private async Task PopulateDropdownsAsync() { var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); ViewBag.VendorList = vendors .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem { Value = v.Id.ToString(), Text = v.CompanyName }) .ToList(); ViewBag.APAccountList = accounts .Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem { Value = a.Id.ToString(), Text = $"{a.AccountNumber} – {a.Name}" }) .ToList(); ViewBag.ExpenseAccountList = accounts .Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem { Value = a.Id.ToString(), Text = $"{a.AccountNumber} – {a.Name}" }) .ToList(); } }