Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AccountsController.cs
T
spouliot c0d3a30176 Add explicit CompanyId to tenant-scoped FindAsync queries (partial sweep)
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>
2026-06-20 16:53:29 -04:00

673 lines
30 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 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();
}
}