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>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages annual budgets. Each budget has one BudgetLine per active GL account with
|
||||
/// monthly amounts (Jan–Dec). The Budget vs. Actual report compares these to real activity.
|
||||
/// Only one budget per year is marked IsDefault — that one feeds the variance report automatically.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class BudgetsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public BudgetsController(IUnitOfWork unitOfWork, ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
// ── Index ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
||||
.OrderByDescending(b => b.FiscalYear)
|
||||
.ThenBy(b => b.Name)
|
||||
.ToList();
|
||||
|
||||
return View(budgets);
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
return View(new BudgetCreateVm
|
||||
{
|
||||
FiscalYear = DateTime.Now.Year,
|
||||
Lines = accounts.Select(a => new BudgetLineVm { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, AccountType = a.AccountType }).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BudgetCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// If this is marked default, clear the flag on other budgets for the same year
|
||||
if (vm.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: null);
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Name = vm.Name,
|
||||
FiscalYear = vm.FiscalYear,
|
||||
Notes = vm.Notes,
|
||||
IsDefault = vm.IsDefault,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(budget);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" created for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = budget.Id });
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
var lineMap = budget.Lines.ToDictionary(l => l.AccountId);
|
||||
|
||||
var vm = new BudgetCreateVm
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
FiscalYear = budget.FiscalYear,
|
||||
Notes = budget.Notes,
|
||||
IsDefault = budget.IsDefault,
|
||||
Lines = accounts.Select(a =>
|
||||
{
|
||||
lineMap.TryGetValue(a.Id, out var existing);
|
||||
return new BudgetLineVm
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
AccountType = a.AccountType,
|
||||
Jan = existing?.Jan ?? 0, Feb = existing?.Feb ?? 0, Mar = existing?.Mar ?? 0,
|
||||
Apr = existing?.Apr ?? 0, May = existing?.May ?? 0, Jun = existing?.Jun ?? 0,
|
||||
Jul = existing?.Jul ?? 0, Aug = existing?.Aug ?? 0, Sep = existing?.Sep ?? 0,
|
||||
Oct = existing?.Oct ?? 0, Nov = existing?.Nov ?? 0, Dec = existing?.Dec ?? 0
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, BudgetCreateVm vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (vm.IsDefault && !budget.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: id);
|
||||
|
||||
budget.Name = vm.Name;
|
||||
budget.Notes = vm.Notes;
|
||||
budget.IsDefault = vm.IsDefault;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Delete old lines and replace with new set (simpler than merge)
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
budget.Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" saved.";
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
// ── Copy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of an existing budget for a new fiscal year — common workflow for
|
||||
/// rolling forward last year's budget as a starting point.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Copy(int id, int newYear)
|
||||
{
|
||||
var source = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (source == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var copy = new Budget
|
||||
{
|
||||
Name = $"{source.Name} ({newYear})",
|
||||
FiscalYear = newYear,
|
||||
Notes = source.Notes,
|
||||
IsDefault = false,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = source.Lines.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(copy);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget copied to {newYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = copy.Id });
|
||||
}
|
||||
|
||||
// ── SetDefault ────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetDefault(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
await ClearDefaultFlagAsync(companyId, budget.FiscalYear, excludeId: null);
|
||||
|
||||
budget.IsDefault = true;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"\"{budget.Name}\" is now the default budget for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
await _unitOfWork.Budgets.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||
}
|
||||
|
||||
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||
{
|
||||
var others = await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||
foreach (var b in others)
|
||||
{
|
||||
b.IsDefault = false;
|
||||
b.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
if (others.Any())
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── View Models ───────────────────────────────────────────────────────────────
|
||||
|
||||
public class BudgetCreateVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; } = DateTime.Now.Year;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = true;
|
||||
public List<BudgetLineVm> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BudgetLineVm
|
||||
{
|
||||
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 Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
public bool HasAnyAmount => Annual != 0;
|
||||
}
|
||||
@@ -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