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>
This commit is contained in:
@@ -427,6 +427,186 @@ public class AccountsController : Controller
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user