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:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user