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 accounts = await _unitOfWork.Accounts.GetAllAsync(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(); 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 = preSubType.Value switch { AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset, AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability, AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity, AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue, AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods, _ => AccountType.Expense }; } 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.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; 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.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); 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)); } /// /// One-time data repair for companies whose chart of accounts was imported from QuickBooks /// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative /// numbers (e.g. Revenue accounts), but the application's convention is to store all opening /// balances as positive amounts with the credit/debit nature implied by account type. This /// action flips negative opening balances on Revenue, Liability, and Equity accounts to their /// absolute values. After running this, should be called to /// propagate the corrected opening balances into CurrentBalance. /// // POST: /Accounts/FixOpeningBalanceSigns // One-time fix: QB IIF imports store credit-normal accounts with negative opening balances. // This flips them to positive so the chart of accounts displays correctly. [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task FixOpeningBalanceSigns() { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) { TempData["Error"] = "Company not found."; return RedirectToAction(nameof(Index)); } try { var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive); int fixed_ = 0; foreach (var acct in accounts) { if (acct.OpeningBalance < 0 && acct.AccountType is Core.Enums.AccountType.Revenue or Core.Enums.AccountType.Liability or Core.Enums.AccountType.Equity) { acct.OpeningBalance = Math.Abs(acct.OpeningBalance); acct.CurrentBalance = Math.Abs(acct.CurrentBalance); await _unitOfWork.Accounts.UpdateAsync(acct); fixed_++; } } await _unitOfWork.CompleteAsync(); TempData["Success"] = fixed_ > 0 ? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance." : "No accounts needed fixing — all opening balances already have the correct sign."; } catch (Exception ex) { _logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId); TempData["Error"] = "An error occurred while fixing opening balances."; } 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 => true, 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.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.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 allAccounts = await _unitOfWork.Accounts.FindAsync(a => 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(); } }