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:
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -150,6 +154,10 @@ public class Expense : BaseEntity
|
||||
public string? Memo { get; set; }
|
||||
public string? ReceiptFilePath { get; set; }
|
||||
|
||||
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||
@@ -201,6 +209,27 @@ public class JournalEntryLine : BaseEntity
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bank reconciliation session for a single bank/cash account against a statement.
|
||||
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
|
||||
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
|
||||
/// </summary>
|
||||
public class BankReconciliation : BaseEntity
|
||||
{
|
||||
/// <summary>Must be a bank/cash subtype account.</summary>
|
||||
public int AccountId { get; set; }
|
||||
public DateTime StatementDate { get; set; }
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal EndingBalance { get; set; }
|
||||
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
|
||||
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
|
||||
|
||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
||||
/// </summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Invoice Invoice { get; set; } = null!;
|
||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||
|
||||
@@ -80,6 +80,12 @@ public enum AccountingMethod
|
||||
Accrual = 1
|
||||
}
|
||||
|
||||
public enum BankReconciliationStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Completed = 1
|
||||
}
|
||||
|
||||
public enum VendorCreditStatus
|
||||
{
|
||||
Open = 0,
|
||||
|
||||
@@ -100,6 +100,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
|
||||
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
|
||||
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
@@ -329,6 +329,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||||
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
@@ -631,6 +634,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Bank Reconciliation: tenant-filtered
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -663,6 +670,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(je => je.ReversalOfId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BankReconciliation → Account (no cascade)
|
||||
modelBuilder.Entity<BankReconciliation>()
|
||||
.HasOne(br => br.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(br => br.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCredit → APAccount (no cascade)
|
||||
modelBuilder.Entity<VendorCredit>()
|
||||
.HasOne(vc => vc.APAccount)
|
||||
|
||||
Generated
+10043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBankReconciliation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Payments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Payments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Expenses",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BankReconciliations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BankReconciliations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BankReconciliations_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BankReconciliations_AccountId",
|
||||
table: "BankReconciliations",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BankReconciliations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,6 +936,69 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BankReconciliation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("BeginningBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CompletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("EndingBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("StatementDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("BankReconciliations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BannedIp", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1149,6 +1212,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("CheckNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1164,6 +1230,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -2854,6 +2923,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -2879,6 +2951,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -5775,6 +5850,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("ClearedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5796,6 +5874,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("InvoiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsCleared")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -6206,7 +6287,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6217,7 +6298,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6228,7 +6309,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -8151,6 +8232,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Job");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BankReconciliation", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Bill", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "APAccount")
|
||||
|
||||
@@ -151,6 +151,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<VendorCreditLineItem>? _vendorCreditLineItems;
|
||||
private IRepository<VendorCreditApplication>? _vendorCreditApplications;
|
||||
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||
/// The context is shared across all repositories created by this instance so that
|
||||
@@ -544,6 +547,11 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<VendorCreditApplication> VendorCreditApplications =>
|
||||
_vendorCreditApplications ??= new Repository<VendorCreditApplication>(_context);
|
||||
|
||||
// Bank Reconciliation
|
||||
/// <summary>Repository for <see cref="BankReconciliation"/> sessions reconciling a bank account against a statement.</summary>
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user