feff0fa73d
- 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>
396 lines
16 KiB
C#
396 lines
16 KiB
C#
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; }
|
||
}
|