using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.AI; 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; private readonly IAccountingAiService _accountingAi; private readonly IAiUsageLogger _usageLogger; public BankReconciliationsController( IUnitOfWork unitOfWork, ITenantContext tenantContext, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _accountingAi = accountingAi; _usageLogger = usageLogger; } private bool AllowAccounting() => User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager"); // ── Index ──────────────────────────────────────────────────────────────── /// Lists all reconciliation sessions for the company, newest first. public async Task 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.CanManageAccounting)] public async Task Create() { if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); await PopulateAccountDropdownAsync(); return View(new BankReconciliation { StatementDate = DateTime.Today }); } [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)] [ValidateAntiForgeryToken] public async Task 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) ────────────────────────────────────────────── /// /// 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. /// public async Task 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) ───────────────────────────────────────────────── /// /// AJAX endpoint. Marks a Payment, BillPayment, or Expense as cleared/uncleared. /// Returns updated running totals as JSON. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)] [ValidateAntiForgeryToken] public async Task 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 ───────────────────────────────────────────────────────────── /// Completes the reconciliation. Only allowed when Difference == 0.00. [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)] [ValidateAntiForgeryToken] public async Task 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 ──────────────────────────────────────────────────────────────── /// Printable view of a completed reconciliation. public async Task 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(); (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); } // ── AI Auto-Match (AJAX) ────────────────────────────────────────────────── /// /// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items /// to mark as cleared. The controller assembles all three transaction types (deposits, /// bill payments, expenses) for the reconciliation's account, then delegates scoring to /// . The caller applies /// suggestions client-side by auto-checking the corresponding table rows. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)] [ValidateAntiForgeryToken] public async Task AiSuggestMatches(int reconId) { if (!AllowAccounting()) return Forbid(); var recon = (await _unitOfWork.BankReconciliations.FindAsync( br => br.Id == reconId, false, br => br.Account)) .FirstOrDefault(); if (recon == null) return NotFound(); var accountId = recon.AccountId; var statementDate = recon.StatementDate; var items = new List(); (await _unitOfWork.Payments.FindAsync( p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared)) .ToList() .ForEach(p => items.Add(new BankRecMatchItem { EntityType = "Payment", EntityId = p.Id, Date = p.PaymentDate.ToString("yyyy-MM-dd"), Reference = p.Reference ?? $"PMT-{p.Id}", Description = $"Payment #{p.InvoiceId}", Amount = p.Amount, Direction = "deposit" })); (await _unitOfWork.BillPayments.FindAsync( bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared)) .ToList() .ForEach(bp => items.Add(new BankRecMatchItem { EntityType = "BillPayment", EntityId = bp.Id, Date = bp.PaymentDate.ToString("yyyy-MM-dd"), Reference = bp.PaymentNumber, Description = bp.Memo ?? bp.BillId.ToString(), Amount = bp.Amount, Direction = "payment" })); (await _unitOfWork.Expenses.FindAsync( e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared)) .ToList() .ForEach(e => items.Add(new BankRecMatchItem { EntityType = "Expense", EntityId = e.Id, Date = e.Date.ToString("yyyy-MM-dd"), Reference = e.ExpenseNumber, Description = e.Memo ?? string.Empty, Amount = e.Amount, Direction = "payment" })); if (!items.Any()) return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." }); var request = new AutoMatchRequest { UnclearedItems = items, BeginningBalance = recon.BeginningBalance, StatementEndingBalance = recon.EndingBalance }; var result = await _accountingAi.AutoMatchReconciliationAsync(request); var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? ""; await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success); return Json(result); } // ── 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(); } } /// View model for a single reconcileable transaction row. 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; } }