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 JournalEntriesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly IAccountBalanceService _accountBalanceService; public JournalEntriesController( 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 ──────────────────────────────────────────────────────────────── public async Task Index(string? status) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var all = (await _unitOfWork.JournalEntries.FindAsync( je => je.CompanyId == companyId)) .OrderByDescending(je => je.EntryDate) .ThenByDescending(je => je.Id) .ToList(); var displayed = status switch { "Draft" => all.Where(je => je.Status == JournalEntryStatus.Draft).ToList(), "Posted" => all.Where(je => je.Status == JournalEntryStatus.Posted).ToList(), _ => all }; ViewBag.StatusFilter = status ?? "All"; ViewBag.TotalCount = all.Count; ViewBag.DraftCount = all.Count(je => je.Status == JournalEntryStatus.Draft); ViewBag.PostedCount = all.Count(je => je.Status == JournalEntryStatus.Posted); return View(displayed); } // ── Create ─────────────────────────────────────────────────────────────── [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public async Task Create() { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); await PopulateAccountDropdownAsync(); return View(new JournalEntry { EntryDate = DateTime.Today }); } [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Create( JournalEntry model, int[] lineAccountIds, decimal[] lineDebits, decimal[] lineCreditAmounts, string?[] lineDescriptions, int[] lineOrders) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var lines = BuildLines(lineAccountIds, lineDebits, lineCreditAmounts, lineDescriptions, lineOrders); if (!ValidateLines(lines, out string? error)) { TempData["Error"] = error; await PopulateAccountDropdownAsync(); return View(model); } var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; model.EntryNumber = await GenerateEntryNumberAsync(companyId); model.Status = JournalEntryStatus.Draft; model.Lines = lines; await _unitOfWork.JournalEntries.AddAsync(model); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Journal entry {model.EntryNumber} created as draft."; return RedirectToAction(nameof(Details), new { id = model.Id }); } // ── Details ────────────────────────────────────────────────────────────── public async Task Details(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var je = (await _unitOfWork.JournalEntries.FindAsync( e => e.Id == id, false, e => e.Lines)) .FirstOrDefault(); if (je == null) return NotFound(); // Load account names for lines var accountIds = je.Lines.Select(l => l.AccountId).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}"); // Reversal metadata if (je.ReversalOfId.HasValue) { var original = await _unitOfWork.JournalEntries.GetByIdAsync(je.ReversalOfId.Value); ViewBag.ReversalOfNumber = original?.EntryNumber; } var reversal = (await _unitOfWork.JournalEntries.FindAsync( r => r.ReversalOfId == je.Id && r.Status == JournalEntryStatus.Posted)) .FirstOrDefault(); ViewBag.ReversalEntryNumber = reversal?.EntryNumber; ViewBag.ReversalEntryId = reversal?.Id; return View(je); } // ── Post ───────────────────────────────────────────────────────────────── [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Post(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var entry = (await _unitOfWork.JournalEntries.FindAsync( je => je.Id == id, false, je => je.Lines)) .FirstOrDefault(); if (entry == null) return NotFound(); if (entry.Status != JournalEntryStatus.Draft) { TempData["Error"] = "Only draft entries can be posted."; return RedirectToAction(nameof(Details), new { id }); } var totalDebits = entry.Lines.Sum(l => l.DebitAmount); var totalCredits = entry.Lines.Sum(l => l.CreditAmount); if (totalDebits != totalCredits) { TempData["Error"] = $"Entry does not balance — debits {totalDebits:C} ≠ credits {totalCredits:C}."; return RedirectToAction(nameof(Details), new { id }); } // Period lock check — block posting if the entry date falls in a locked period var company = await _unitOfWork.Companies.GetByIdAsync(entry.CompanyId); if (Web.Helpers.AccountingPeriodValidator.IsLocked(entry.EntryDate, company?.BookLockedThrough)) { TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough); return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.ExecuteInTransactionAsync(async () => { entry.Status = JournalEntryStatus.Posted; entry.PostedAt = DateTime.UtcNow; entry.PostedBy = User.Identity?.Name; foreach (var line in entry.Lines) { if (line.DebitAmount > 0) await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount); if (line.CreditAmount > 0) await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount); } await _unitOfWork.CompleteAsync(); }); TempData["Success"] = $"Journal entry {entry.EntryNumber} posted successfully."; return RedirectToAction(nameof(Details), new { id }); } // ── Reverse ────────────────────────────────────────────────────────────── [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageJobs)] [ValidateAntiForgeryToken] public async Task Reverse(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var original = (await _unitOfWork.JournalEntries.FindAsync( je => je.Id == id, false, je => je.Lines)) .FirstOrDefault(); if (original == null) return NotFound(); if (original.Status != JournalEntryStatus.Posted) { TempData["Error"] = "Only posted entries can be reversed."; return RedirectToAction(nameof(Details), new { id }); } var existingReversal = (await _unitOfWork.JournalEntries.FindAsync( je => je.ReversalOfId == id)) .FirstOrDefault(); if (existingReversal != null) { TempData["Error"] = $"This entry was already reversed by {existingReversal.EntryNumber}."; return RedirectToAction(nameof(Details), new { id }); } var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; int newEntryId = 0; await _unitOfWork.ExecuteInTransactionAsync(async () => { var reversal = new JournalEntry { EntryNumber = await GenerateEntryNumberAsync(companyId), EntryDate = DateTime.Today, Reference = $"Reversal of {original.EntryNumber}", Description = $"Reversal of {original.EntryNumber}: {original.Description}", Status = JournalEntryStatus.Posted, IsReversal = true, ReversalOfId = original.Id, PostedAt = DateTime.UtcNow, PostedBy = User.Identity?.Name, Lines = original.Lines.Select((l, i) => new JournalEntryLine { AccountId = l.AccountId, DebitAmount = l.CreditAmount, CreditAmount = l.DebitAmount, Description = l.Description, LineOrder = l.LineOrder }).ToList() }; await _unitOfWork.JournalEntries.AddAsync(reversal); original.Status = JournalEntryStatus.Reversed; foreach (var line in reversal.Lines) { if (line.DebitAmount > 0) await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount); if (line.CreditAmount > 0) await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount); } await _unitOfWork.CompleteAsync(); newEntryId = reversal.Id; }); TempData["Success"] = "Reversal entry created and posted."; return RedirectToAction(nameof(Details), new { id = newEntryId }); } // ── Delete ─────────────────────────────────────────────────────────────── [HttpPost] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [ValidateAntiForgeryToken] public async Task Delete(int id) { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); var entry = await _unitOfWork.JournalEntries.GetByIdAsync(id); if (entry == null) return NotFound(); if (entry.Status != JournalEntryStatus.Draft) { TempData["Error"] = "Only draft entries can be deleted. Posted entries must be reversed."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.JournalEntries.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Journal entry {entry.EntryNumber} deleted."; return RedirectToAction(nameof(Index)); } // ── Helpers ────────────────────────────────────────────────────────────── private static List BuildLines( int[] accountIds, decimal[] debits, decimal[] credits, string?[] descriptions, int[] orders) { var lines = new List(); for (int i = 0; i < accountIds.Length; i++) { if (accountIds[i] == 0) continue; lines.Add(new JournalEntryLine { AccountId = accountIds[i], DebitAmount = i < debits.Length ? debits[i] : 0, CreditAmount = i < credits.Length ? credits[i] : 0, Description = i < descriptions.Length ? descriptions[i] : null, LineOrder = i < orders.Length ? orders[i] : i }); } return lines; } private static bool ValidateLines(List lines, out string? error) { if (lines.Count < 2) { error = "A journal entry must have at least two lines."; return false; } var totalDebits = lines.Sum(l => l.DebitAmount); var totalCredits = lines.Sum(l => l.CreditAmount); if (totalDebits == 0 && totalCredits == 0) { error = "At least one debit or credit amount must be non-zero."; return false; } if (totalDebits != totalCredits) { error = $"Debits ({totalDebits:C}) must equal credits ({totalCredits:C}) before saving."; return false; } error = null; return true; } /// /// Generates the next sequential entry number in the format JE-YYMM-####. /// Queries across soft-deleted entries to prevent number reuse after deletion. /// private async Task GenerateEntryNumberAsync(int companyId) { var prefix = $"JE-{DateTime.Now:yyMM}-"; var all = await _unitOfWork.JournalEntries.FindAsync( je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix), ignoreQueryFilters: true); int next = 1; if (all.Any()) { var nums = all .Select(je => je.EntryNumber[prefix.Length..]) .Select(s => int.TryParse(s, out int n) ? n : 0); next = nums.Max() + 1; } return $"{prefix}{next:D4}"; } private async Task PopulateAccountDropdownAsync() { var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); ViewBag.AccountSelectList = accounts .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem { Value = a.Id.ToString(), Text = $"{a.AccountNumber} – {a.Name}" }) .ToList(); } }