Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/BankReconciliationsController.cs
T
spouliot feff0fa73d Add Accountant role and CanManageBills/CanManageAccounting permissions
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and
  CanManageAccounting to Policies
- ApplicationUser: add CanManageBills and CanManageAccounting bool fields
- UserManagementDtos: expose new fields in all three DTOs
- ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims
- Program.cs: add CanManageBills and CanManageAccounting policies;
  update CanManageInvoices, CanViewReports, CanManagePurchaseOrders,
  and CanManageVendors to auto-pass for Accountant role
- BillsController: replace CanManageInventory with CanManageBills on
  all write actions (correct policy — bills are not inventory)
- BankReconciliationsController: replace CanManageJobs with
  CanManageAccounting on write actions
- CompanyUsersController: add Accountant to validCompanyRoles (both
  Create/Edit), legacyRole switch, and all permission assignment blocks
- Create/Edit views: add Accountant option to role dropdown; add
  CanManageBills and CanManageAccounting checkboxes; JS auto-checks
  financial permissions when Accountant role is selected
- Migration AddAccountantRolePermissions: adds columns + backfills
  CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:42:53 -04:00

396 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ────────────────────────────────────────────────────────────────
/// <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.CanManageAccounting)]
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.CanManageAccounting)]
[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.CanManageAccounting)]
[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.CanManageAccounting)]
[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);
}
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
/// <summary>
/// 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
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
/// suggestions client-side by auto-checking the corresponding table rows.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<BankRecMatchItem>();
(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();
}
}
/// <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; }
}