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
@@ -0,0 +1,124 @@
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = "Year-End Close";
ViewData["PageIcon"] = "bi-calendar-check";
var history = ViewBag.History as List<YearEndClose> ?? new();
var suggested = (int)ViewBag.SuggestedYear;
var closedYears = ViewBag.ClosedYears as HashSet<int> ?? new();
}
<div class="row justify-content-center">
<div class="col-lg-8">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar-check me-2 text-primary"></i>Close a Fiscal Year</h5>
</div>
<div class="card-body">
<div class="alert alert-warning py-2 mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
and Expense account balances into Retained Earnings — the standard accounting close.
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
A year can only be closed once.
</div>
<form asp-action="CloseYear" method="post"
onsubmit="return confirm('Close fiscal year ' + document.getElementById('closeYear').value + '? This will post a Journal Entry zeroing all Revenue and Expense balances into Retained Earnings. This cannot be undone.')">
@Html.AntiForgeryToken()
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label">Fiscal Year to Close <span class="text-danger">*</span></label>
<input type="number" name="year" id="closeYear" class="form-control"
value="@suggested" min="2000" max="@DateTime.Now.Year" required />
<div class="form-text">All entries for this year should be finalized before closing.</div>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-warning">
<i class="bi bi-calendar-check me-2"></i>Close Year
</button>
</div>
</div>
</form>
<div class="mt-3">
<h6 class="text-muted">Pre-close checklist:</h6>
<ul class="list-unstyled small">
<li><i class="bi bi-check2-square me-2 text-success"></i>All invoices for the year are sent and collected (or written off)</li>
<li><i class="bi bi-check2-square me-2 text-success"></i>All vendor bills are entered and paid</li>
<li><i class="bi bi-check2-square me-2 text-success"></i>Bank reconciliation is complete through December</li>
<li><i class="bi bi-check2-square me-2 text-success"></i>Depreciation is posted for all 12 months</li>
<li><i class="bi bi-check2-square me-2 text-success"></i>Trial Balance is in balance (debits = credits)</li>
<li><i class="bi bi-check2-square me-2 text-success"></i>Period is locked through December 31 in Company Settings</li>
</ul>
</div>
</div>
</div>
<!-- Close History -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-clock-history me-2 text-primary"></i>Close History</h5>
</div>
<div class="card-body p-0">
@if (!history.Any())
{
<div class="text-center text-muted py-4">
<p>No years have been closed yet.</p>
</div>
}
else
{
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Fiscal Year</th>
<th>Closed On</th>
<th>Closed By</th>
<th>Journal Entry</th>
</tr>
</thead>
<tbody>
@foreach (var c in history)
{
<tr>
<td class="fw-bold">@c.ClosedYear</td>
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
<td>@(c.ClosedBy ?? "—")</td>
<td>
@if (c.JournalEntry != null)
{
<a asp-controller="JournalEntries" asp-action="Details" asp-route-id="@c.JournalEntryId">
@c.JournalEntry.EntryNumber
</a>
}
else
{
<span class="text-muted">#@c.JournalEntryId</span>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
</div>
@@ -0,0 +1,118 @@
@using PowderCoating.Web.Controllers
@model BudgetCreateVm
@{
ViewData["Title"] = "New Budget";
ViewData["PageIcon"] = "bi-plus-circle";
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
}
<div class="mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Budgets
</a>
</div>
<form asp-action="Create" method="post" id="budgetForm">
@Html.AntiForgeryToken()
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-pie-chart me-2 text-primary"></i>Budget Details</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Budget Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" required placeholder="e.g., FY2026 Operating Budget" />
</div>
<div class="col-md-2">
<label class="form-label">Fiscal Year <span class="text-danger">*</span></label>
<input asp-for="FiscalYear" type="number" class="form-control" min="2000" max="2099" required />
</div>
<div class="col-md-4">
<label class="form-label">Notes</label>
<input asp-for="Notes" class="form-control" placeholder="Optional description" />
</div>
<div class="col-md-1 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
<label asp-for="IsDefault" class="form-check-label small">Default</label>
</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Monthly Amounts by Account</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="fillEvenlyBtn">
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0" id="budgetTable">
<thead class="table-light">
<tr>
<th style="min-width:180px">Account</th>
<th class="text-end" style="min-width:70px">Annual</th>
@foreach (var m in months)
{
<th class="text-end" style="min-width:75px">@m</th>
}
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Lines.Count; i++)
{
var line = Model.Lines[i];
<tr data-account-id="@line.AccountId">
<td>
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
<span class="text-muted ms-1">@line.AccountName</span>
</td>
<td class="text-end">
<span class="annual-total fw-semibold" data-row="@i">0.00</span>
</td>
@{
var fieldNames = new[] { "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" };
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
}
@for (int m = 0; m < 12; m++)
{
<td class="text-end p-0">
<input type="number" name="Lines[@i].@fieldNames[m]"
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
class="form-control form-control-sm text-end border-0 budget-cell"
step="0.01" min="0"
data-row="@i"
style="min-width:70px" />
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-2"></i>Create Budget
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
@section Scripts {
<script src="~/js/budget-edit.js" asp-append-version="true"></script>
}
@@ -0,0 +1,164 @@
@using PowderCoating.Web.Controllers
@model BudgetCreateVm
@{
ViewData["Title"] = $"Edit Budget — {Model.Name}";
ViewData["PageIcon"] = "bi-pencil";
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
}
<div class="mb-4 d-flex justify-content-between align-items-center">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Budgets
</a>
<a asp-controller="Reports" asp-action="BudgetVsActual" asp-route-budgetId="@Model.Id" class="btn btn-outline-primary btn-sm">
<i class="bi bi-bar-chart-line me-1"></i>View Budget vs. Actual
</a>
</div>
<form asp-action="Edit" asp-route-id="@Model.Id" method="post" id="budgetForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="FiscalYear" />
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name — @Model.FiscalYear
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Budget Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" required />
</div>
<div class="col-md-4">
<label class="form-label">Notes</label>
<input asp-for="Notes" class="form-control" />
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
<label asp-for="IsDefault" class="form-check-label">Make this the default budget for @Model.FiscalYear</label>
</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Monthly Amounts by Account</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="fillEvenlyBtn">
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
</button>
</div>
</div>
<div class="alert alert-info py-2 mx-3 mt-3 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
Enter monthly amounts for each Revenue and Expense account. Leave a row at zero to exclude that account from the budget. Amounts represent expected <strong>activity</strong> for the period (not running totals).
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0" id="budgetTable">
<thead class="table-light sticky-top">
<tr>
<th style="min-width:200px">Account</th>
<th class="text-end" style="min-width:80px">Annual</th>
@foreach (var m in months)
{
<th class="text-end" style="min-width:75px">@m</th>
}
</tr>
</thead>
<tbody>
@{
var revLines = Model.Lines.Where(l => l.AccountType == PowderCoating.Core.Enums.AccountType.Revenue).ToList();
var expLines = Model.Lines.Where(l => l.AccountType != PowderCoating.Core.Enums.AccountType.Revenue).ToList();
var fieldNames = new[] { "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" };
}
@if (revLines.Any())
{
<tr class="table-success">
<td colspan="14" class="fw-semibold py-1 ps-3 small">REVENUE</td>
</tr>
@for (int i = 0; i < Model.Lines.Count; i++)
{
var line = Model.Lines[i];
if (line.AccountType != PowderCoating.Core.Enums.AccountType.Revenue) continue;
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
<tr data-row-idx="@i">
<td>
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
<span class="text-muted ms-1">@line.AccountName</span>
</td>
<td class="text-end"><span class="annual-total fw-semibold text-success" data-row="@i">@line.Annual.ToString("N2")</span></td>
@for (int m = 0; m < 12; m++)
{
<td class="text-end p-0">
<input type="number" name="Lines[@i].@fieldNames[m]"
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
class="form-control form-control-sm text-end border-0 budget-cell"
step="0.01" min="0" data-row="@i" style="min-width:70px" />
</td>
}
</tr>
}
}
@if (expLines.Any())
{
<tr class="table-danger">
<td colspan="14" class="fw-semibold py-1 ps-3 small">EXPENSE</td>
</tr>
@for (int i = 0; i < Model.Lines.Count; i++)
{
var line = Model.Lines[i];
if (line.AccountType == PowderCoating.Core.Enums.AccountType.Revenue) continue;
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
<tr data-row-idx="@i">
<td>
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
<span class="text-muted ms-1">@line.AccountName</span>
</td>
<td class="text-end"><span class="annual-total fw-semibold text-danger" data-row="@i">@line.Annual.ToString("N2")</span></td>
@for (int m = 0; m < 12; m++)
{
<td class="text-end p-0">
<input type="number" name="Lines[@i].@fieldNames[m]"
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
class="form-control form-control-sm text-end border-0 budget-cell"
step="0.01" min="0" data-row="@i" style="min-width:70px" />
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-2"></i>Save Changes
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
@section Scripts {
<script src="~/js/budget-edit.js" asp-append-version="true"></script>
}
@@ -0,0 +1,152 @@
@using PowderCoating.Core.Entities
@model List<Budget>
@{
ViewData["Title"] = "Budgets";
ViewData["PageIcon"] = "bi-pie-chart";
var byYear = Model.GroupBy(b => b.FiscalYear).OrderByDescending(g => g.Key).ToList();
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div></div>
<div class="d-flex gap-2">
<a asp-controller="Reports" asp-action="BudgetVsActual" class="btn btn-outline-primary">
<i class="bi bi-bar-chart-line me-2"></i>Budget vs. Actual Report
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Budget
</a>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!Model.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-pie-chart display-4 d-block mb-3 opacity-25"></i>
<p class="mb-0">No budgets yet. <a asp-action="Create">Create your first budget</a> to start tracking variance against actual results.</p>
</div>
</div>
}
else
{
@foreach (var group in byYear)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar3 me-2 text-primary"></i>Fiscal Year @group.Key</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Budget Name</th>
<th class="text-center">Lines</th>
<th class="text-end">Total Revenue Budget</th>
<th class="text-end">Total Expense Budget</th>
<th class="text-center">Default</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var b in group.OrderBy(b => b.Name))
{
var revLines = b.Lines.Where(l => l.Account?.AccountType == PowderCoating.Core.Enums.AccountType.Revenue);
var expLines = b.Lines.Where(l => l.Account?.AccountType == PowderCoating.Core.Enums.AccountType.Expense);
<tr>
<td class="fw-semibold">
@b.Name
@if (!string.IsNullOrWhiteSpace(b.Notes))
{
<div class="text-muted small">@b.Notes</div>
}
</td>
<td class="text-center">@b.Lines.Count</td>
<td class="text-end text-success">@b.Lines.Sum(l => l.Annual).ToString("C")</td>
<td class="text-end text-danger">—</td>
<td class="text-center">
@if (b.IsDefault)
{
<span class="badge bg-primary">Default</span>
}
else
{
<form asp-action="SetDefault" asp-route-id="@b.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-secondary">Set Default</button>
</form>
}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a asp-action="Edit" asp-route-id="@b.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal" data-bs-target="#copyModal"
data-budget-id="@b.Id" data-budget-name="@b.Name">
<i class="bi bi-copy"></i>
</button>
<form asp-action="Delete" asp-route-id="@b.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete budget &quot;@b.Name&quot;? This cannot be undone.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
<!-- Copy Modal -->
<div class="modal fade" id="copyModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-copy me-2"></i>Copy Budget</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form asp-action="Copy" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="copyBudgetId" />
<div class="modal-body">
<p>Copy <strong id="copyBudgetName"></strong> to a new fiscal year as a starting point.</p>
<div class="mb-3">
<label class="form-label">New Fiscal Year</label>
<input type="number" name="newYear" class="form-control" value="@(DateTime.Now.Year + 1)" min="2000" max="2099" required />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-copy me-2"></i>Copy Budget</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/budget-index.js" asp-append-version="true"></script>
}
@@ -0,0 +1,235 @@
@using PowderCoating.Web.Controllers
@using PowderCoating.Core.Enums
@model List<BudgetVsActualRow>
@{
ViewData["Title"] = "Budget vs. Actual";
ViewData["PageIcon"] = "bi-bar-chart-line";
var reportYear = (int)ViewBag.ReportYear;
var budget = ViewBag.Budget as PowderCoating.Core.Entities.Budget;
var allBudgets = ViewBag.AllBudgets as List<PowderCoating.Core.Entities.Budget> ?? new();
var noBudget = ViewBag.NoBudget == true;
var availYears = ViewBag.AvailableYears as List<int> ?? new();
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
}
<div class="d-flex justify-content-between align-items-center mb-4">
<form method="get" asp-action="BudgetVsActual" class="d-flex align-items-center gap-2 flex-wrap">
<select name="year" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
@foreach (var y in availYears)
{
<option value="@y" selected="@(y == reportYear ? "selected" : null)">@y</option>
}
</select>
@if (allBudgets.Count > 1)
{
<select name="budgetId" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
@foreach (var b in allBudgets)
{
<option value="@b.Id" selected="@(budget?.Id == b.Id ? "selected" : null)">
@b.Name@(b.IsDefault ? " (default)" : "")
</option>
}
</select>
}
</form>
<div class="d-flex gap-2">
<a asp-controller="Budgets" asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pie-chart me-1"></i>Manage Budgets
</a>
<a asp-action="Landing" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Reports
</a>
</div>
</div>
@if (noBudget || budget == null)
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-bar-chart-line display-4 d-block mb-3 opacity-25"></i>
<p>No budget found for <strong>@reportYear</strong>.</p>
<a asp-controller="Budgets" asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Budget for @reportYear
</a>
</div>
</div>
}
else
{
var revRows = Model.Where(r => r.AccountType == AccountType.Revenue).ToList();
var expRows = Model.Where(r => r.AccountType != AccountType.Revenue).ToList();
var totalBudgetRev = revRows.Sum(r => r.BudgetAnnual);
var totalActualRev = revRows.Sum(r => r.ActualAnnual);
var totalBudgetExp = expRows.Sum(r => r.BudgetAnnual);
var totalActualExp = expRows.Sum(r => r.ActualAnnual);
var budgetNetIncome = totalBudgetRev - totalBudgetExp;
var actualNetIncome = totalActualRev - totalActualExp;
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Budget Revenue</div>
<div class="fs-4 fw-bold text-success">@totalBudgetRev.ToString("C")</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Actual Revenue</div>
<div class="fs-4 fw-bold @(totalActualRev >= totalBudgetRev ? "text-success" : "text-warning")">@totalActualRev.ToString("C")</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Budget Net Income</div>
<div class="fs-4 fw-bold">@budgetNetIncome.ToString("C")</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Actual Net Income</div>
<div class="fs-4 fw-bold @(actualNetIncome >= budgetNetIncome ? "text-success" : "text-danger")">@actualNetIncome.ToString("C")</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-bar-chart-line me-2 text-primary"></i>@budget.Name — @reportYear
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="min-width:200px">Account</th>
<th class="text-end">Budget Annual</th>
<th class="text-end">Actual Annual</th>
<th class="text-end">Variance</th>
@foreach (var m in months)
{
<th class="text-end small" style="min-width:60px">@m</th>
}
</tr>
</thead>
<tbody>
@if (revRows.Any())
{
<tr class="table-success">
<td colspan="@(4 + 12)" class="fw-bold py-1 ps-3 small">REVENUE</td>
</tr>
@foreach (var row in revRows)
{
var varA = row.VarianceAnnual;
<tr>
<td>
<span class="fw-semibold">@row.AccountNumber</span>
<span class="text-muted ms-1">@row.AccountName</span>
</td>
<td class="text-end">@row.BudgetAnnual.ToString("C")</td>
<td class="text-end">@row.ActualAnnual.ToString("C")</td>
<td class="text-end fw-semibold @(varA >= 0 ? "text-success" : "text-danger")">
@(varA >= 0 ? "+" : "")@varA.ToString("C")
</td>
@for (int m = 0; m < 12; m++)
{
var varM = row.ActualMonths[m] - row.BudgetMonths[m];
<td class="text-end small @(varM < 0 ? "text-danger" : "")">
@row.ActualMonths[m].ToString("N0")
</td>
}
</tr>
}
<tr class="fw-bold table-success">
<td>Total Revenue</td>
<td class="text-end">@totalBudgetRev.ToString("C")</td>
<td class="text-end">@totalActualRev.ToString("C")</td>
<td class="text-end @((totalActualRev - totalBudgetRev) >= 0 ? "text-success" : "text-danger")">
@{var revVar = totalActualRev - totalBudgetRev;}
@(revVar >= 0 ? "+" : "")@revVar.ToString("C")
</td>
@for (int m = 0; m < 12; m++)
{
<td class="text-end small">@revRows.Sum(r => r.ActualMonths[m]).ToString("N0")</td>
}
</tr>
}
@if (expRows.Any())
{
<tr class="table-danger">
<td colspan="@(4 + 12)" class="fw-bold py-1 ps-3 small">EXPENSE</td>
</tr>
@foreach (var row in expRows)
{
var varA = row.BudgetAnnual - row.ActualAnnual; // favorable if under budget
<tr>
<td>
<span class="fw-semibold">@row.AccountNumber</span>
<span class="text-muted ms-1">@row.AccountName</span>
</td>
<td class="text-end">@row.BudgetAnnual.ToString("C")</td>
<td class="text-end">@row.ActualAnnual.ToString("C")</td>
<td class="text-end fw-semibold @(varA >= 0 ? "text-success" : "text-danger")">
@(varA >= 0 ? "+" : "")@varA.ToString("C")
</td>
@for (int m = 0; m < 12; m++)
{
var varM = row.BudgetMonths[m] - row.ActualMonths[m];
<td class="text-end small @(varM < 0 ? "text-danger" : "")">
@row.ActualMonths[m].ToString("N0")
</td>
}
</tr>
}
<tr class="fw-bold table-danger">
<td>Total Expenses</td>
<td class="text-end">@totalBudgetExp.ToString("C")</td>
<td class="text-end">@totalActualExp.ToString("C")</td>
<td class="text-end @((totalBudgetExp - totalActualExp) >= 0 ? "text-success" : "text-danger")">
@{var expVar = totalBudgetExp - totalActualExp;}
@(expVar >= 0 ? "+" : "")@expVar.ToString("C")
</td>
@for (int m = 0; m < 12; m++)
{
<td class="text-end small">@expRows.Sum(r => r.ActualMonths[m]).ToString("N0")</td>
}
</tr>
}
<!-- Net Income row -->
<tr class="fw-bold border-top" style="border-top-width:2px!important">
<td>Net Income</td>
<td class="text-end">@budgetNetIncome.ToString("C")</td>
<td class="text-end @(actualNetIncome >= 0 ? "text-success" : "text-danger")">@actualNetIncome.ToString("C")</td>
<td class="text-end @((actualNetIncome - budgetNetIncome) >= 0 ? "text-success" : "text-danger")">
@{var netVar = actualNetIncome - budgetNetIncome;}
@(netVar >= 0 ? "+" : "")@netVar.ToString("C")
</td>
@for (int m = 0; m < 12; m++)
{
var mActualNet = revRows.Sum(r => r.ActualMonths[m]) - expRows.Sum(r => r.ActualMonths[m]);
<td class="text-end small @(mActualNet < 0 ? "text-danger" : "")">@mActualNet.ToString("N0")</td>
}
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3 text-muted small">
<i class="bi bi-info-circle me-1"></i>
Variance is shown as <strong>favorable</strong> (positive) when revenue exceeds budget or expenses are under budget.
Actual figures reflect the P&amp;L for each calendar month.
</div>
}
@@ -220,6 +220,14 @@
<p>Payments to 1099-eligible vendors by calendar year — flags those exceeding the $600 reporting threshold.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="BudgetVsActual" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
<i class="bi bi-bar-chart-line"></i>
</div>
<h5>Budget vs. Actual</h5>
<p>Compare monthly budgeted amounts against real P&amp;L activity — revenue, expenses, and net income variance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}
@@ -1150,6 +1150,14 @@
<i class="bi bi-building-gear"></i>
<span>Fixed Assets</span>
</a>
<a asp-controller="Budgets" asp-action="Index" class="nav-link">
<i class="bi bi-pie-chart"></i>
<span>Budgets</span>
</a>
<a asp-controller="Accounts" asp-action="YearEndClose" class="nav-link">
<i class="bi bi-calendar-check"></i>
<span>Year-End Close</span>
</a>
<a asp-controller="RecurringTemplates" asp-action="Index" class="nav-link">
<i class="bi bi-arrow-repeat"></i>
<span>Recurring Transactions</span>