c0d3a30176
Multi-tenant defense-in-depth sweep, FindAsync/FirstOrDefaultAsync vector. Adds explicit CompanyId predicates to list/index/validation queries that previously relied only on the global tenant filter (exposure: raw platform-admin sessions where the filter is bypassed). Done this pass: - Financial: Budgets, CreditMemos, FixedAssets, GiftCertificates, TaxRates, PricingTiers, VendorCredits, Accounts (year-end close), Invoices (tax-rate default, merchandise). - Operational: Inventory (bin/sample-panels/vendors/usage-edit), OvenScheduler (ovens/batches/queue), Customers (pricing tiers), InAppNotifications (mark-all-read), CatalogItems (by-category / merchandise / price-check lists). - AI: AiQuickQuote and Quotes (powder cost, predictions, walk-in customer, benchmark), Reports (budgets, 1099 vendors). Child-by-parent-FK and by-PK queries were left as-is (already scoped via the verified parent). Builds clean; 293 unit tests pass. REMAINING (next session): ReportsController.Analytics powder-usage query (line ~593) and the ~20 CompanySettings delete-protection Count/Any + dup-code checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
673 lines
30 KiB
C#
673 lines
30 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;
|
||
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<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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, 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();
|
||
|
||
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
|
||
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
|
||
|
||
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 = AccountClassification.TypeForSubType(preSubType.Value);
|
||
}
|
||
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.CompanyId == currentUser!.CompanyId && 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;
|
||
// Derive the parent type from the chosen sub-type so the two can never disagree —
|
||
// a mismatch would post with the wrong debit/credit sign (sign keys off sub-type).
|
||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||
|
||
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.CompanyId == account.CompanyId && 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);
|
||
// Keep type consistent with the chosen sub-type (see Create) so the sign convention,
|
||
// which keys off sub-type, can never be at odds with the displayed account type.
|
||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||
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>
|
||
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
|
||
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
|
||
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
|
||
/// accounts (Inventory sub-type first) so a company that classified its inventory account
|
||
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
|
||
/// </summary>
|
||
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
|
||
{
|
||
SelectListItem Item(Account a) => new($"{a.AccountNumber} – {a.Name}", a.Id.ToString());
|
||
|
||
ViewBag.DefaultRevenueAccounts = accounts
|
||
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
|
||
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||
|
||
ViewBag.DefaultCogsAccounts = accounts
|
||
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
|
||
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||
|
||
ViewBag.DefaultInventoryAccounts = accounts
|
||
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
|
||
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
|
||
|
||
var prefs = await _unitOfWork.CompanyPreferences
|
||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
|
||
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
|
||
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
|
||
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
|
||
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
|
||
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
|
||
/// company and to be of the expected account type before it is stored; an invalid or cleared
|
||
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
|
||
/// </summary>
|
||
// POST: /Accounts/SaveDefaultAccounts
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||
public async Task<IActionResult> SaveDefaultAccounts(
|
||
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
|
||
{
|
||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||
if (companyId == null)
|
||
{
|
||
TempData["Error"] = "Company not found.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
|
||
try
|
||
{
|
||
var prefs = await _unitOfWork.CompanyPreferences
|
||
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
|
||
if (prefs == null)
|
||
{
|
||
TempData["Error"] = "Company preferences not found.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
|
||
// Validate each pick belongs to this company, is active, and is of the right type.
|
||
// Explicit CompanyId predicate (defense in depth) alongside the global tenant filter.
|
||
async Task<int?> Validate(int? id, params AccountType[] allowed)
|
||
{
|
||
if (id == null) return null;
|
||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
|
||
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
|
||
}
|
||
|
||
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
|
||
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
|
||
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
|
||
|
||
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
|
||
TempData["Error"] = "An error occurred while saving the default accounts.";
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ── Year-End Close ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
[HttpGet]
|
||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||
public async Task<IActionResult> YearEndClose()
|
||
{
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, 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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||
public async Task<IActionResult> CloseYear(int year)
|
||
{
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
|
||
// Idempotency check
|
||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && 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.CompanyId == companyId && 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<JournalEntryLine>();
|
||
|
||
// 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 ──────────────────────────────────────────────────────────────
|
||
|
||
/// <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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (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.ToDisplayName(), ((int)t).ToString()))
|
||
.ToList();
|
||
|
||
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
|
||
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
|
||
.ToList();
|
||
}
|
||
}
|