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,48 @@
@model PowderCoating.Core.Entities.BankReconciliation
@{
ViewData["Title"] = "Start Bank Reconciliation";
var accounts = ViewBag.AccountSelectList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
}
<div class="d-flex align-items-center mb-3 gap-2">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0 fw-semibold ms-2">Start Bank Reconciliation</h4>
</div>
<div class="card shadow-sm" style="max-width:600px">
<div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label class="form-label fw-semibold">Bank Account <span class="text-danger">*</span></label>
<select asp-for="AccountId" asp-items="accounts" class="form-select" required>
<option value="">— select account —</option>
</select>
<div class="form-text">Only Checking, Savings, and Cash accounts are listed.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Statement Date <span class="text-danger">*</span></label>
<input asp-for="StatementDate" type="date" class="form-control"
value="@Model.StatementDate.ToString("yyyy-MM-dd")" required />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Statement Ending Balance <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="EndingBalance" type="number" step="0.01" class="form-control" required />
</div>
<div class="form-text">Enter the closing balance from your bank statement.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Start Reconciliation</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@@ -0,0 +1,90 @@
@model IEnumerable<PowderCoating.Core.Entities.BankReconciliation>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bank Reconciliation";
}
<div class="d-flex align-items-center mb-3 gap-2">
<h4 class="mb-0 fw-semibold">Bank Reconciliation</h4>
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
<i class="bi bi-plus-lg me-1"></i>Start New Reconciliation
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Account</th>
<th>Statement Date</th>
<th class="text-end">Beginning Balance</th>
<th class="text-end">Ending Balance</th>
<th>Status</th>
<th>Completed By</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-4">
No reconciliations yet.
<a asp-action="Create">Start your first one.</a>
</td>
</tr>
}
@foreach (var br in Model)
{
<tr>
<td class="fw-semibold">@br.Account?.Name</td>
<td>@br.StatementDate.ToString("MMM d, yyyy")</td>
<td class="text-end">@br.BeginningBalance.ToString("C")</td>
<td class="text-end">@br.EndingBalance.ToString("C")</td>
<td>
@if (br.Status == BankReconciliationStatus.Completed)
{
<span class="badge bg-success">Completed</span>
}
else
{
<span class="badge bg-warning text-dark">In Progress</span>
}
</td>
<td class="text-muted small">
@if (br.CompletedAt.HasValue)
{
@($"{br.CompletedBy} on {br.CompletedAt.Value.ToLocalTime():MMM d, yyyy}")
}
</td>
<td class="text-end">
@if (br.Status == BankReconciliationStatus.Completed)
{
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Report
</a>
}
else
{
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-check2-square me-1"></i>Continue
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,189 @@
@using PowderCoating.Web.Controllers
@{
ViewData["Title"] = "Reconcile";
var recon = ViewBag.Recon as PowderCoating.Core.Entities.BankReconciliation;
var deposits = ViewBag.Deposits as List<ReconciliationItem> ?? new();
var payments = ViewBag.Payments as List<ReconciliationItem> ?? new();
}
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Reconciliations
</a>
<h4 class="mb-0 fw-semibold ms-2">Reconcile: @recon?.Account?.Name</h4>
<span class="text-muted small">Statement date: @recon?.StatementDate.ToString("MMM d, yyyy")</span>
</div>
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="row g-3 mb-3">
<div class="col-md-3 text-center">
<div class="card shadow-sm py-3">
<div class="fw-bold fs-5">@recon?.BeginningBalance.ToString("C")</div>
<div class="text-muted small">Beginning Balance</div>
</div>
</div>
<div class="col-md-3 text-center">
<div class="card shadow-sm py-3">
<div class="fw-bold fs-5">@recon?.EndingBalance.ToString("C")</div>
<div class="text-muted small">Statement Ending Balance</div>
</div>
</div>
<div class="col-md-3 text-center">
<div class="card shadow-sm py-3">
<div class="fw-bold fs-5" id="clearedBalance">@recon?.BeginningBalance.ToString("C")</div>
<div class="text-muted small">Cleared Balance</div>
</div>
</div>
<div class="col-md-3 text-center">
<div class="card shadow-sm py-3">
<div class="fw-bold fs-5" id="difference">—</div>
<div class="text-muted small">Difference</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Deposits / Credits</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
</thead>
<tbody>
@if (!deposits.Any())
{
<tr><td colspan="4" class="text-center text-muted py-3">No deposits found.</td></tr>
}
@foreach (var item in deposits.OrderBy(d => d.Date))
{
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
data-amount="@item.Amount.ToString("F2")" data-direction="deposit">
<td class="small">@item.Date.ToString("MMM d")</td>
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
<td class="text-end">@item.Amount.ToString("C")</td>
<td class="text-center">
<input type="checkbox" class="form-check-input cleared-checkbox"
@(item.IsCleared ? "checked" : "") />
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Payments / Debits</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
</thead>
<tbody>
@if (!payments.Any())
{
<tr><td colspan="4" class="text-center text-muted py-3">No payments found.</td></tr>
}
@foreach (var item in payments)
{
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
data-amount="@item.Amount.ToString("F2")" data-direction="payment">
<td class="small">@item.Date.ToString("MMM d")</td>
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
<td class="text-end">@item.Amount.ToString("C")</td>
<td class="text-center">
<input type="checkbox" class="form-check-input cleared-checkbox"
@(item.IsCleared ? "checked" : "") />
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<form asp-action="Complete" method="post" id="completeForm">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@recon?.Id" />
<input type="hidden" name="difference" id="differenceHidden" value="9999" />
<button type="submit" class="btn btn-success" id="completeBtn" disabled>
<i class="bi bi-check-circle me-1"></i>Complete Reconciliation
</button>
<span class="ms-2 text-muted small">Complete is enabled only when difference is $0.00</span>
</form>
@section Scripts {
<script>
(function() {
const reconId = @recon?.Id;
const beginning = @recon?.BeginningBalance.ToString("F2");
const ending = @recon?.EndingBalance.ToString("F2");
let token = document.querySelector('input[name="__RequestVerificationToken"]').value;
function recalculate() {
let clearedDeposits = 0, clearedPayments = 0;
document.querySelectorAll('.recon-row').forEach(row => {
const cb = row.querySelector('.cleared-checkbox');
const amt = parseFloat(row.dataset.amount);
if (!cb.checked) return;
if (row.dataset.direction === 'deposit') clearedDeposits += amt;
else clearedPayments += amt;
});
const cleared = beginning + clearedDeposits - clearedPayments;
const difference = ending - cleared;
document.getElementById('clearedBalance').textContent =
cleared.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const diffEl = document.getElementById('difference');
diffEl.textContent = difference.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
diffEl.className = Math.abs(difference) < 0.005 ? 'fw-bold fs-5 text-success' : 'fw-bold fs-5 text-danger';
document.getElementById('differenceHidden').value = difference.toFixed(2);
document.getElementById('completeBtn').disabled = Math.abs(difference) >= 0.005;
}
document.querySelectorAll('.cleared-checkbox').forEach(cb => {
cb.addEventListener('change', async function() {
const row = this.closest('.recon-row');
const type = row.dataset.type;
const id = row.dataset.id;
const cleared = this.checked;
try {
const resp = await fetch('/BankReconciliations/ToggleCleared', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': token
},
body: new URLSearchParams({
reconId, entityType: type, entityId: id, isCleared: cleared
})
});
if (!resp.ok) this.checked = !cleared; // revert on error
} catch {
this.checked = !cleared; // revert on network error
}
recalculate();
});
});
recalculate();
})();
</script>
}
@@ -0,0 +1,109 @@
@model PowderCoating.Core.Entities.BankReconciliation
@using PowderCoating.Web.Controllers
@{
ViewData["Title"] = $"Reconciliation Report {Model.Account?.Name}";
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
}
<div class="d-flex align-items-center mb-3 gap-2 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0 fw-semibold ms-2">Reconciliation Report</h4>
<button class="btn btn-sm btn-outline-secondary ms-auto" onclick="window.print()">
<i class="bi bi-printer me-1"></i>Print
</button>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="fw-semibold">@Model.Account?.Name</h5>
<p class="text-muted mb-0">Statement Date: @Model.StatementDate.ToString("MMMM d, yyyy")</p>
@if (Model.CompletedAt.HasValue)
{
<p class="text-muted small">Completed by @Model.CompletedBy on @Model.CompletedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</p>
}
</div>
<div class="col-md-6 text-md-end">
<table class="table table-sm table-borderless mb-0 ms-auto" style="width:auto">
<tr>
<td class="text-muted">Beginning Balance:</td>
<td class="fw-semibold text-end">@Model.BeginningBalance.ToString("C")</td>
</tr>
<tr>
<td class="text-muted">+ Cleared Deposits:</td>
<td class="fw-semibold text-end text-success">@clearedDeposits.Sum(p => p.Amount).ToString("C")</td>
</tr>
<tr>
<td class="text-muted"> Cleared Payments:</td>
<td class="fw-semibold text-end text-danger">@clearedPayments.Sum(p => p.Amount).ToString("C")</td>
</tr>
<tr class="border-top">
<td class="fw-semibold">Statement Ending Balance:</td>
<td class="fw-bold text-end">@Model.EndingBalance.ToString("C")</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Cleared Deposits (@clearedDeposits.Count())</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
<tbody>
@foreach (var p in clearedDeposits.OrderBy(p => p.PaymentDate))
{
<tr>
<td class="small">@p.PaymentDate.ToString("MMM d")</td>
<td class="small">@p.Reference</td>
<td class="text-end">@p.Amount.ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedDeposits.Sum(p=>p.Amount).ToString("C")</td></tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Cleared Payments (@clearedPayments.Count)</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
<tbody>
@foreach (var p in clearedPayments)
{
<tr>
<td class="small">@p.Date.ToString("MMM d")</td>
<td class="small">@p.Reference</td>
<td class="text-end">@p.Amount.ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedPayments.Sum(p=>p.Amount).ToString("C")</td></tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Notes))
{
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Notes</div>
<div class="card-body">@Model.Notes</div>
</div>
}
@@ -1138,6 +1138,10 @@
<i class="bi bi-arrow-return-left"></i>
<span>Vendor Credits</span>
</a>
<a asp-controller="BankReconciliations" asp-action="Index" class="nav-link">
<i class="bi bi-bank2"></i>
<span>Bank Reconciliation</span>
</a>
if (hasReports)
{
<a asp-controller="AccountingExport" asp-action="Index" class="nav-link">