4fd9c52aaf
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>
303 lines
12 KiB
C#
303 lines
12 KiB
C#
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;
|
||
}
|