4fd9c52aaf
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>
637 lines
28 KiB
C#
637 lines
28 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 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();
|
||
}
|
||
}
|