Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AccountsController.cs
T
spouliot 4fd9c52aaf Phase G: Add Budgeting and Year-End Close
Budgeting:
- Budget + BudgetLine entities with Jan–Dec monthly columns per GL account
- BudgetsController: Index, Create, Edit, SetDefault, Copy, Delete
- Copy action rolls a budget forward to a new fiscal year
- Budget vs. Actual report (BudgetVsActual): compares monthly budget amounts to
  real P&L by calling GetProfitAndLossAsync once per month; variance shown as
  favorable/unfavorable; year + budget selectors in header
- Views: Budgets/Index, Create, Edit with inline annual totals via budget-edit.js
- Nav link + report card on Landing

Year-End Close:
- YearEndClose entity records each closed year + JE reference for audit trail
- AccountsController.YearEndClose GET (history + form) + CloseYear POST
- Close zeroes all Revenue and Expense/COGS account balances into Retained Earnings
  via IAccountBalanceService and posts a supporting JE dated Dec 31
- Idempotency: rejects attempt to close an already-closed year
- Pre-close checklist in view to guide the workflow
- Nav link under Finance

Migration AddBudgetsAndYearEndClose applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:01:56 -04:00

637 lines
28 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;
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 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.Cash or 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);
}
// ── 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 => true, 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.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.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 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.ToDisplayName(), ((int)t).ToString()))
.ToList();
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
.ToList();
}
}