Phase E: Add Bank Reconciliation

- IsCleared + ClearedDate added to Payment, BillPayment, Expense entities
- BankReconciliation entity (account, statement date, beginning/ending balance, status)
- BankReconciliationStatus enum (InProgress, Completed)
- Migration AddBankReconciliation: new BankReconciliations table + IsCleared/ClearedDate columns
- IUnitOfWork/UnitOfWork wired with BankReconciliations repo
- BankReconciliationsController: Index, Create, Reconcile, ToggleCleared (AJAX), Complete, Report
- Reconcile view: deposit/payment checkboxes with live running balance and difference via JS
- Complete is gated: only enabled when difference == $0.00
- Nav: Bank Reconciliation added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 00:10:38 -04:00
parent cf9dcfb4c1
commit 1229081436
15 changed files with 11111 additions and 3 deletions
@@ -0,0 +1,303 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class BankReconciliationsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
public BankReconciliationsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
}
private bool AllowAccounting() =>
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
// ── Index ────────────────────────────────────────────────────────────────
/// <summary>Lists all reconciliation sessions for the company, newest first.</summary>
public async Task<IActionResult> Index()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var all = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId,
false,
br => br.Account))
.OrderByDescending(br => br.StatementDate)
.ThenByDescending(br => br.Id)
.ToList();
return View(all);
}
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
await PopulateAccountDropdownAsync();
return View(new BankReconciliation { StatementDate = DateTime.Today });
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId
&& br.AccountId == model.AccountId
&& br.Status == BankReconciliationStatus.Completed))
.OrderByDescending(br => br.StatementDate)
.FirstOrDefault();
model.BeginningBalance = lastCompleted?.EndingBalance ?? 0;
model.Status = BankReconciliationStatus.InProgress;
await _unitOfWork.BankReconciliations.AddAsync(model);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Reconciliation started.";
return RedirectToAction(nameof(Reconcile), new { id = model.Id });
}
// ── Reconcile (Working View) ──────────────────────────────────────────────
/// <summary>
/// Main working view. Shows all uncleared transactions for the account up to StatementDate
/// in two sections (deposits/credits and payments/debits) with checkboxes.
/// Running cleared balance and difference update via JS as the user checks items.
/// </summary>
public async Task<IActionResult> Reconcile(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == id,
false,
br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
if (recon.Status == BankReconciliationStatus.Completed)
return RedirectToAction(nameof(Report), new { id });
var accountId = recon.AccountId;
var statementDate = recon.StatementDate;
// Customer payments deposited to this account
var deposits = (await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate))
.Select(p => new ReconciliationItem
{
EntityType = "Payment",
EntityId = p.Id,
Date = p.PaymentDate,
Reference = p.Reference ?? $"PMT-{p.Id}",
Description = $"Payment #{p.InvoiceId}",
Amount = p.Amount,
IsCleared = p.IsCleared
}).ToList();
// Bill payments out of this account (debits — shown as negative in deposits)
var billPayments = (await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate))
.Select(bp => new ReconciliationItem
{
EntityType = "BillPayment",
EntityId = bp.Id,
Date = bp.PaymentDate,
Reference = bp.PaymentNumber,
Description = bp.Memo ?? bp.BillId.ToString(),
Amount = bp.Amount,
IsCleared = bp.IsCleared
}).ToList();
// Direct expenses out of this account
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.Date <= statementDate))
.Select(e => new ReconciliationItem
{
EntityType = "Expense",
EntityId = e.Id,
Date = e.Date,
Reference = e.ExpenseNumber,
Description = e.Memo ?? string.Empty,
Amount = e.Amount,
IsCleared = e.IsCleared
}).ToList();
ViewBag.Recon = recon;
ViewBag.Deposits = deposits;
ViewBag.Payments = billPayments.Concat(expenses).OrderBy(p => p.Date).ToList();
return View();
}
// ── ToggleCleared (AJAX) ─────────────────────────────────────────────────
/// <summary>
/// AJAX endpoint. Marks a Payment, BillPayment, or Expense as cleared/uncleared.
/// Returns updated running totals as JSON.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared)
{
if (!AllowAccounting()) return Forbid();
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(reconId);
if (recon == null) return NotFound();
var now = isCleared ? DateTime.UtcNow : (DateTime?)null;
switch (entityType)
{
case "Payment":
var payment = await _unitOfWork.Payments.GetByIdAsync(entityId);
if (payment != null) { payment.IsCleared = isCleared; payment.ClearedDate = now; }
break;
case "BillPayment":
var bp = await _unitOfWork.BillPayments.GetByIdAsync(entityId);
if (bp != null) { bp.IsCleared = isCleared; bp.ClearedDate = now; }
break;
case "Expense":
var exp = await _unitOfWork.Expenses.GetByIdAsync(entityId);
if (exp != null) { exp.IsCleared = isCleared; exp.ClearedDate = now; }
break;
}
await _unitOfWork.CompleteAsync();
return Ok(new { success = true });
}
// ── Complete ─────────────────────────────────────────────────────────────
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
if (Math.Abs(difference) > 0.005m)
{
TempData["Error"] = $"Cannot complete: difference is {difference:C}. Must be $0.00.";
return RedirectToAction(nameof(Reconcile), new { id });
}
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(id);
if (recon == null) return NotFound();
recon.Status = BankReconciliationStatus.Completed;
recon.CompletedAt = DateTime.UtcNow;
recon.CompletedBy = User.Identity?.Name;
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Reconciliation completed.";
return RedirectToAction(nameof(Report), new { id });
}
// ── Report ────────────────────────────────────────────────────────────────
/// <summary>Printable view of a completed reconciliation.</summary>
public async Task<IActionResult> Report(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == id,
false,
br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
var accountId = recon.AccountId;
var clearedDeposits = (await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.IsCleared && p.PaymentDate <= recon.StatementDate))
.ToList();
var clearedPayments = new List<ReconciliationItem>();
(await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.IsCleared && bp.PaymentDate <= recon.StatementDate))
.ToList()
.ForEach(bp => clearedPayments.Add(new ReconciliationItem
{
EntityType = "BillPayment", EntityId = bp.Id, Date = bp.PaymentDate,
Reference = bp.PaymentNumber, Amount = bp.Amount, IsCleared = true
}));
(await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.IsCleared && e.Date <= recon.StatementDate))
.ToList()
.ForEach(e => clearedPayments.Add(new ReconciliationItem
{
EntityType = "Expense", EntityId = e.Id, Date = e.Date,
Reference = e.ExpenseNumber, Amount = e.Amount, IsCleared = true
}));
ViewBag.ClearedDeposits = clearedDeposits;
ViewBag.ClearedPayments = clearedPayments.OrderBy(p => p.Date).ToList();
return View(recon);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task PopulateAccountDropdownAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive
&& (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
{
Value = a.Id.ToString(),
Text = $"{a.AccountNumber} {a.Name}"
})
.ToList();
}
}
/// <summary>View model for a single reconcileable transaction row.</summary>
public class ReconciliationItem
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public DateTime Date { get; set; }
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public bool IsCleared { get; set; }
}