Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,455 @@
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();
}
}