456 lines
20 KiB
C#
456 lines
20 KiB
C#
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();
|
||
}
|
||
}
|