Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AccountsController.cs
T
2026-04-23 21:38:24 -04:00

456 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class AccountsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AccountsController> _logger;
private readonly ISeedDataService _seedDataService;
private readonly ITenantContext _tenantContext;
private readonly ILedgerService _ledgerService;
private readonly IAccountBalanceService _accountBalanceService;
public AccountsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<AccountsController> 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;
}
/// <summary>
/// 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.
/// </summary>
// GET: /Accounts
public async Task<IActionResult> Index()
{
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
var dtos = _mapper.Map<List<AccountListDto>>(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);
}
/// <summary>
/// Returns the blank account creation form. Restricted to CompanyAdmin because adding
/// accounts affects the double-entry accounting structure for the entire company.
/// </summary>
// GET: /Accounts/Create
/// <summary>
/// Renders the account creation form. When <paramref name="inline"/> is true the layout is
/// stripped for modal embedding. <paramref name="preSubType"/> 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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.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);
}
/// <summary>
/// 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 <paramref name="inline"/> is true (quick-add modal path) returns JSON
/// {success, id, name} instead of a redirect so the caller can populate the originating select.
/// </summary>
// POST: /Accounts/Create
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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<Account>(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);
}
}
/// <summary>
/// Returns the edit form for an account. The account being edited is excluded from the
/// parent-account dropdown (via <paramref name="excludeId"/>) to prevent circular parent
/// references that would break account hierarchy traversal.
/// </summary>
// GET: /Accounts/Edit/5
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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<EditAccountDto>(account);
await PopulateDropdownsAsync(excludeId: id.Value);
return View(dto);
}
/// <summary>
/// Saves account edits. The duplicate-number check excludes the account being edited
/// (<c>a.Id != id</c>) 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.
/// </summary>
// POST: /Accounts/Edit/5
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Displays account detail including parent account and direct sub-accounts, giving a view
/// of where the account sits in the hierarchy.
/// </summary>
// GET: /Accounts/Details/5
public async Task<IActionResult> 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<AccountDto>(account));
}
/// <summary>
/// Soft-deletes a user-defined account. System accounts (seeded by the platform and marked
/// <c>IsSystem = true</c>) 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.
/// </summary>
// POST: /Accounts/Delete/5
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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));
}
/// <summary>
/// Seeds the standard chart of accounts for the current tenant. Delegates to
/// <see cref="ISeedDataService.SeedCompanyLookupsAsync"/> 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.
/// </summary>
// POST: /Accounts/SeedDefaultAccounts
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// </summary>
// 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<IActionResult> 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));
}
/// <summary>
/// Triggers a full recalculation of <c>CurrentBalance</c> for every account in the company
/// by replaying all transactions through <see cref="IAccountBalanceService.RecalculateAllAsync"/>.
/// 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.
/// </summary>
// POST: /Accounts/RecalculateBalances
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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 <see cref="ILedgerService.GetAccountLedgerAsync"/>
/// which assembles entries from bills, payments, expenses, invoices, and deposits.
/// </summary>
// GET: /Accounts/Ledger/5?from=2026-01-01&to=2026-03-31
public async Task<IActionResult> 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);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<AccountType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.ToList();
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.ToList();
}
}