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:
2026-05-10 13:01:56 -04:00
parent fde24b09c9
commit 4fd9c52aaf
19 changed files with 12527 additions and 3 deletions
@@ -2118,6 +2118,91 @@ public class ReportsController : Controller
}
}
// GET: /Reports/BudgetVsActual
/// <summary>
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&amp;L activity
/// for the same period. Actual figures come from the same GL queries used by the P&amp;L report.
/// If no budgetId is specified, the default budget for the selected year is used automatically.
/// </summary>
public async Task<IActionResult> BudgetVsActual(int? budgetId, int? year)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _bvaid) ? _bvaid : 0;
var reportYear = year ?? DateTime.Now.Year;
// Load all budgets for the year for the selector
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear))
.OrderBy(b => b.Name).ToList();
Core.Entities.Budget? budget = null;
if (budgetId.HasValue)
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
ViewBag.ReportYear = reportYear;
ViewBag.Budget = budget;
ViewBag.AllBudgets = allBudgets;
ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 4, 6).OrderDescending().ToList();
if (budget == null)
{
ViewBag.NoBudget = true;
return View(new List<BudgetVsActualRow>());
}
// Fetch one P&L per month (12 calls); build a flat dict: accountId → decimal[12]
var actualByAccount = new Dictionary<int, decimal[]>();
for (int m = 1; m <= 12; m++)
{
var mFrom = new DateTime(reportYear, m, 1);
var mTo = new DateTime(reportYear, m, DateTime.DaysInMonth(reportYear, m));
var mpl = await _financialReports.GetProfitAndLossAsync(companyId, mFrom, mTo);
var allPLLines = mpl.RevenueLines
.Concat(mpl.CogsLines)
.Concat(mpl.ExpenseLines);
foreach (var pll in allPLLines)
{
if (!actualByAccount.ContainsKey(pll.AccountId))
actualByAccount[pll.AccountId] = new decimal[12];
actualByAccount[pll.AccountId][m - 1] = pll.Amount;
}
}
// Load account metadata for budget lines
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)))
.ToDictionary(a => a.Id);
var rows = new List<BudgetVsActualRow>();
foreach (var line in budget.Lines)
{
if (!accounts.TryGetValue(line.AccountId, out var acct)) continue;
actualByAccount.TryGetValue(line.AccountId, out var monthlyActuals);
monthlyActuals ??= new decimal[12];
rows.Add(new BudgetVsActualRow
{
AccountId = acct.Id,
AccountNumber = acct.AccountNumber,
AccountName = acct.Name,
AccountType = acct.AccountType,
BudgetMonths = new[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec },
ActualMonths = monthlyActuals
});
}
return View(rows.OrderBy(r => r.AccountNumber).ToList());
}
// GET: /Reports/TaxReporting1099
/// <summary>
/// 1099-NEC report: sums all bill payments + expenses paid to vendors marked Is1099Vendor=true
@@ -2307,6 +2392,20 @@ public class AnalyticsDashboardViewModel
public int SelectedMonths { get; set; } = 6;
}
public class BudgetVsActualRow
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public decimal[] BudgetMonths { get; set; } = new decimal[12];
public decimal[] ActualMonths { get; set; } = new decimal[12];
public decimal BudgetAnnual => BudgetMonths.Sum();
public decimal ActualAnnual => ActualMonths.Sum();
public decimal VarianceAnnual => ActualAnnual - BudgetAnnual;
}
public class Vendor1099Row
{
public int VendorId { get; set; }