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:
@@ -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&L activity
|
||||
/// for the same period. Actual figures come from the same GL queries used by the P&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; }
|
||||
|
||||
Reference in New Issue
Block a user