Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/BudgetsController.cs
T
spouliot 4fd9c52aaf 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>
2026-05-10 13:01:56 -04:00

303 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (JanDec). 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;
}