using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Helpers; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanViewData)] public class AccountsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly ISeedDataService _seedDataService; private readonly ITenantContext _tenantContext; private readonly ILedgerService _ledgerService; private readonly IAccountBalanceService _accountBalanceService; public AccountsController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, ISeedDataService seedDataService, ITenantContext tenantContext, ILedgerService ledgerService, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _seedDataService = seedDataService; _tenantContext = tenantContext; _ledgerService = ledgerService; _accountBalanceService = accountBalanceService; } /// /// Displays the chart of accounts grouped by account type (Asset, Liability, Equity, Revenue, /// Expense, CostOfGoods) in ascending account-number order within each group. Grouping is done /// in memory after a single DB fetch to avoid multiple queries. The parent account navigation /// property is eagerly loaded so the view can display the account hierarchy without lazy /// loading. /// // GET: /Accounts public async Task Index() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount); var dtos = _mapper.Map>(accounts.OrderBy(a => a.AccountNumber).ToList()); // Group by AccountType for display var grouped = dtos .GroupBy(a => a.AccountType) .OrderBy(g => (int)g.Key) .ToList(); // Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts. await PopulateDefaultAccountViewDataAsync(companyId, accounts); return View(grouped); } /// /// Returns the blank account creation form. Restricted to CompanyAdmin because adding /// accounts affects the double-entry accounting structure for the entire company. /// // GET: /Accounts/Create /// /// Renders the account creation form. When is true the layout is /// stripped for modal embedding. pre-selects the AccountSubType /// (and derives AccountType) so context-specific modals open with the right type already chosen — /// e.g. preSubType=4 (Inventory) from asset pickers, preSubType=40 (CostOfGoodsSold) from COGS pickers. /// [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Create(bool inline = false, AccountSubType? preSubType = null) { await PopulateDropdownsAsync(); var dto = new CreateAccountDto { IsActive = true }; if (preSubType.HasValue) { dto.AccountSubType = preSubType.Value; dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value); } ViewBag.Inline = inline; if (inline) return PartialView(dto); return View(dto); } /// /// Persists a new account. Account number uniqueness is enforced within the company's chart /// of accounts to prevent duplicate ledger entries. The check is performed after model /// validation so the user sees both validation errors and duplicate-number errors in the same /// form round-trip. When is true (quick-add modal path) returns JSON /// {success, id, name} instead of a redirect so the caller can populate the originating select. /// // POST: /Accounts/Create [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Create(CreateAccountDto dto, bool inline = false) { if (!ModelState.IsValid) { if (inline) { var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage); return Json(new { success = false, errors }); } await PopulateDropdownsAsync(); return View(dto); } try { var currentUser = await _userManager.GetUserAsync(User); // Check for duplicate account number var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.AccountNumber == dto.AccountNumber); if (existing.Any()) { ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); if (inline) return Json(new { success = false, errors = new[] { "An account with this number already exists." } }); await PopulateDropdownsAsync(); return View(dto); } var account = _mapper.Map(dto); account.CompanyId = currentUser!.CompanyId; account.CreatedBy = currentUser.Email; // Derive the parent type from the chosen sub-type so the two can never disagree — // a mismatch would post with the wrong debit/credit sign (sign keys off sub-type). account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType); await _unitOfWork.Accounts.AddAsync(account); await _unitOfWork.CompleteAsync(); if (inline) return Json(new { success = true, id = account.Id, name = $"{account.AccountNumber} – {account.Name}" }); TempData["Success"] = $"Account '{account.AccountNumber} – {account.Name}' created."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error creating account"); if (inline) return Json(new { success = false, errors = new[] { "An error occurred while saving." } }); ModelState.AddModelError(string.Empty, "An error occurred while saving."); await PopulateDropdownsAsync(); return View(dto); } } /// /// Returns the edit form for an account. The account being edited is excluded from the /// parent-account dropdown (via ) to prevent circular parent /// references that would break account hierarchy traversal. /// // GET: /Accounts/Edit/5 [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Edit(int? id) { if (id == null) return NotFound(); var account = await _unitOfWork.Accounts.GetByIdAsync(id.Value); if (account == null) return NotFound(); var dto = _mapper.Map(account); await PopulateDropdownsAsync(excludeId: id.Value); return View(dto); } /// /// Saves account edits. The duplicate-number check excludes the account being edited /// (a.Id != id) so users can save without changing the number. Account type and /// sub-type changes are allowed on non-system accounts; changing them on an account with /// existing transactions will affect how those transactions are presented in reports, so this /// should be used with care. /// // POST: /Accounts/Edit/5 [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Edit(int id, EditAccountDto dto) { if (id != dto.Id) return NotFound(); if (!ModelState.IsValid) { await PopulateDropdownsAsync(excludeId: id); return View(dto); } try { var account = await _unitOfWork.Accounts.GetByIdAsync(id); if (account == null) return NotFound(); // Check duplicate number (excluding self) var existing = await _unitOfWork.Accounts.FindAsync( a => a.CompanyId == account.CompanyId && a.AccountNumber == dto.AccountNumber && a.Id != id); if (existing.Any()) { ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); await PopulateDropdownsAsync(excludeId: id); return View(dto); } _mapper.Map(dto, account); // Keep type consistent with the chosen sub-type (see Create) so the sign convention, // which keys off sub-type, can never be at odds with the displayed account type. account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType); account.UpdatedAt = DateTime.UtcNow; account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email; await _unitOfWork.Accounts.UpdateAsync(account); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Account '{account.AccountNumber} – {account.Name}' updated."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error updating account {Id}", id); ModelState.AddModelError(string.Empty, "An error occurred while saving."); await PopulateDropdownsAsync(excludeId: id); return View(dto); } } /// /// Displays account detail including parent account and direct sub-accounts, giving a view /// of where the account sits in the hierarchy. /// // GET: /Accounts/Details/5 public async Task Details(int? id) { if (id == null) return NotFound(); var account = await _unitOfWork.Accounts.GetByIdAsync(id.Value, false, a => a.ParentAccount, a => a.SubAccounts); if (account == null) return NotFound(); return View(_mapper.Map(account)); } /// /// Soft-deletes a user-defined account. System accounts (seeded by the platform and marked /// IsSystem = true) cannot be deleted because removing them would break core accounting /// rules (e.g. the Accounts Receivable or Accounts Payable accounts used by the invoice and /// bill modules). No balance-reversal is performed here; the caller is expected to reassign /// any transactions before deleting an account. /// // POST: /Accounts/Delete/5 [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Delete(int id) { var account = await _unitOfWork.Accounts.GetByIdAsync(id); if (account == null) return NotFound(); if (account.IsSystem) { TempData["Error"] = "System accounts cannot be deleted."; return RedirectToAction(nameof(Index)); } await _unitOfWork.Accounts.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Account '{account.AccountNumber} – {account.Name}' deleted."; return RedirectToAction(nameof(Index)); } /// /// Seeds the standard chart of accounts for the current tenant. Delegates to /// which is idempotent — it checks for /// existing accounts and reports zero items seeded rather than creating duplicates. This is /// the only supported path for new companies to bootstrap accounting; seeding is not automatic /// on company creation so that admins can opt for a manual or imported chart of accounts /// instead. /// // POST: /Accounts/SeedDefaultAccounts [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task SeedDefaultAccounts() { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["Error"] = "Company not found."; return RedirectToAction(nameof(Index)); } try { var result = await _seedDataService.SeedCompanyLookupsAsync(companyId.Value); if (result.Success && result.ItemsSeeded > 0) TempData["Success"] = $"Default chart of accounts created successfully ({result.ItemsSeeded} accounts added)."; else if (result.Success) TempData["Error"] = "Accounts already exist — nothing was seeded."; else TempData["Error"] = result.Message; } catch (Exception ex) { _logger.LogError(ex, "Error seeding default chart of accounts for company {CompanyId}", companyId); TempData["Error"] = "An error occurred while creating the default accounts."; } return RedirectToAction(nameof(Index)); } /// /// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected /// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and /// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset /// accounts (Inventory sub-type first) so a company that classified its inventory account /// differently can still pick it. Reuses the already-loaded list. /// private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable accounts) { SelectListItem Item(Account a) => new($"{a.AccountNumber} – {a.Name}", a.Id.ToString()); ViewBag.DefaultRevenueAccounts = accounts .Where(a => a.IsActive && a.AccountType == AccountType.Revenue) .OrderBy(a => a.AccountNumber).Select(Item).ToList(); ViewBag.DefaultCogsAccounts = accounts .Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods) .OrderBy(a => a.AccountNumber).Select(Item).ToList(); ViewBag.DefaultInventoryAccounts = accounts .Where(a => a.IsActive && a.AccountType == AccountType.Asset) .OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory) .ThenBy(a => a.AccountNumber).Select(Item).ToList(); var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId; ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId; ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId; } /// /// Saves the company's default Revenue, COGS, and Inventory accounts to CompanyPreferences. /// These are used as the fallback when an item leaves its account field blank: invoice lines fall /// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled /// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the /// company and to be of the expected account type before it is stored; an invalid or cleared /// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company. /// // POST: /Accounts/SaveDefaultAccounts [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task SaveDefaultAccounts( int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId) { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["Error"] = "Company not found."; return RedirectToAction(nameof(Index)); } try { var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted); if (prefs == null) { TempData["Error"] = "Company preferences not found."; return RedirectToAction(nameof(Index)); } // Validate each pick belongs to this company, is active, and is of the right type. // Explicit CompanyId predicate (defense in depth) alongside the global tenant filter. async Task Validate(int? id, params AccountType[] allowed) { if (id == null) return null; var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive); return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null; } prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue); prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods); prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset); await _unitOfWork.CompanyPreferences.UpdateAsync(prefs); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen."; } catch (Exception ex) { _logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId); TempData["Error"] = "An error occurred while saving the default accounts."; } return RedirectToAction(nameof(Index)); } /// /// Triggers a full recalculation of CurrentBalance for every account in the company /// by replaying all transactions through . /// This is a corrective tool for situations where balances have drifted due to data migrations, /// IIF imports, or manual database edits. It is safe to run repeatedly; the resulting balances /// will always match the sum of all associated transaction amounts from opening balance. /// // POST: /Accounts/RecalculateBalances [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task RecalculateBalances() { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["Error"] = "Company not found."; return RedirectToAction(nameof(Index)); } try { await _accountBalanceService.RecalculateAllAsync(companyId.Value); TempData["Success"] = "Account balances recalculated successfully."; } catch (Exception ex) { _logger.LogError(ex, "Error recalculating account balances for company {CompanyId}", companyId); TempData["Error"] = "An error occurred while recalculating balances."; } return RedirectToAction(nameof(Index)); } /// /// Displays the account ledger — a date-ranged list of all transactions that touched the /// given account, with running balances. Defaults to the last 3 months when no date range is /// supplied. The ledger is computed by /// which assembles entries from bills, payments, expenses, invoices, and deposits. /// // GET: /Accounts/Ledger/5?from=2026-01-01&to=2026-03-31 public async Task Ledger(int? id, DateTime? from, DateTime? to) { if (id == null) return NotFound(); var fromDate = from ?? DateTime.UtcNow.AddMonths(-3); var toDate = to ?? DateTime.UtcNow; var ledger = await _ledgerService.GetAccountLedgerAsync(id.Value, fromDate, toDate); if (ledger == null) return NotFound(); return View(ledger); } // ── Year-End Close ──────────────────────────────────────────────────────── /// /// GET: landing page showing close history and a form to initiate the current year close. /// Companyid is resolved from tenant context; year defaults to the prior fiscal year /// (the most common use case — close last year after final entries are posted). /// [HttpGet] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task YearEndClose() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, false, y => y.JournalEntry)) .OrderByDescending(y => y.ClosedYear) .ToList(); ViewBag.History = history; ViewBag.SuggestedYear = DateTime.Now.Year - 1; ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet(); return View(); } /// /// POST: executes the year-end close for the specified fiscal year. /// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances /// (debit-normal), computes net income, posts a JE that zeroes them into Retained /// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has /// already been closed is rejected. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task CloseYear(int year) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Idempotency check var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && y.ClosedYear == year)).FirstOrDefault(); if (existing != null) { TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId})."; return RedirectToAction(nameof(YearEndClose)); } // Load all active accounts with balances var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive)).ToList(); var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList(); var expenseAccounts = accounts.Where(a => a.AccountType == AccountType.Expense || a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList(); // Find or locate the Retained Earnings account var retainedEarnings = accounts.FirstOrDefault(a => a.AccountSubType == AccountSubType.RetainedEarnings); if (retainedEarnings == null) { TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first."; return RedirectToAction(nameof(YearEndClose)); } // Net income = total revenue credits − total expense debits var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance); var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance); var netIncome = totalRevenue - totalExpenses; if (totalRevenue == 0 && totalExpenses == 0) { TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close."; return RedirectToAction(nameof(YearEndClose)); } int newJeId = 0; await _unitOfWork.ExecuteInTransactionAsync(async () => { var lines = new List(); // Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0) foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0)) { lines.Add(new JournalEntryLine { AccountId = acct.Id, DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0, CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0, Description = $"Close {year} — {acct.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }); await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0); if (acct.CurrentBalance < 0) await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance)); } // Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0) foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0)) { lines.Add(new JournalEntryLine { AccountId = acct.Id, DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0, CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0, Description = $"Close {year} — {acct.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }); await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0); if (acct.CurrentBalance < 0) await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance)); } // Plug the net into Retained Earnings: CR if profit, DR if loss if (netIncome > 0) { lines.Add(new JournalEntryLine { AccountId = retainedEarnings.Id, CreditAmount = netIncome, Description = $"Net income {year} → Retained Earnings", CompanyId = companyId, CreatedAt = DateTime.UtcNow }); await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome); } else if (netIncome < 0) { lines.Add(new JournalEntryLine { AccountId = retainedEarnings.Id, DebitAmount = Math.Abs(netIncome), Description = $"Net loss {year} → Retained Earnings", CompanyId = companyId, CreatedAt = DateTime.UtcNow }); await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome)); } // Post the JE var prefix = $"JE-{year % 100:D2}12-"; var existing2 = await _unitOfWork.JournalEntries.FindAsync( je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix), ignoreQueryFilters: true); int next = existing2.Any() ? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1 : 1; var je = new JournalEntry { EntryNumber = $"{prefix}{next:D4}", EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc), Description = $"Year-end close — {year}", Reference = $"CLOSE-{year}", Status = JournalEntryStatus.Posted, PostedBy = User.Identity?.Name, PostedAt = DateTime.UtcNow, CompanyId = companyId, CreatedAt = DateTime.UtcNow, Lines = lines }; await _unitOfWork.JournalEntries.AddAsync(je); await _unitOfWork.CompleteAsync(); // Record the close var close = new YearEndClose { ClosedYear = year, ClosedAt = DateTime.UtcNow, ClosedBy = User.Identity?.Name, JournalEntryId = je.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.YearEndCloses.AddAsync(close); await _unitOfWork.CompleteAsync(); newJeId = je.Id; }); TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " + $"See Journal Entry for details."; return RedirectToAction(nameof(YearEndClose)); } // ── Helpers ────────────────────────────────────────────────────────────── /// /// Loads Create/Edit form dropdowns: the parent account list (optionally excluding the /// account being edited to prevent circular references), account type enum values, and /// account sub-type enum values. /// private async Task PopulateDropdownsAsync(int? excludeId = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value)); ViewBag.ParentAccounts = allAccounts .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem( $"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); ViewBag.AccountTypes = Enum.GetValues() .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString())) .ToList(); ViewBag.AccountSubTypes = Enum.GetValues() .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString())) .ToList(); } }