Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user