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>
This commit is contained in:
2026-05-10 19:42:53 -04:00
parent 59beba2e15
commit feff0fa73d
12 changed files with 10850 additions and 60 deletions
@@ -56,7 +56,7 @@ public class BankReconciliationsController : Controller
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
@@ -65,7 +65,7 @@ public class BankReconciliationsController : Controller
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model)
{
@@ -171,7 +171,7 @@ public class BankReconciliationsController : Controller
/// Returns updated running totals as JSON.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared)
@@ -207,7 +207,7 @@ public class BankReconciliationsController : Controller
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference)
{
@@ -286,7 +286,7 @@ public class BankReconciliationsController : Controller
/// suggestions client-side by auto-checking the corresponding table rows.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AiSuggestMatches(int reconId)
{
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
@@ -58,13 +58,13 @@ public class BillsController : Controller
_usageLogger = usageLogger;
}
// ── Index ────────────────────────────────────────────────────────────────
// -- Index ----------------------------------------------------------------
/// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -112,7 +112,7 @@ public class BillsController : Controller
}));
}
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expSearch = search;
@@ -160,13 +160,13 @@ public class BillsController : Controller
return View(pagedEntries);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
// ── Create from Purchase Order ────────────────────────────────────────────
// -- Create from Purchase Order --------------------------------------------
/// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and
@@ -174,7 +174,7 @@ public class BillsController : Controller
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
/// first active Expense/COGS account when the vendor has no default configured.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{
var currentUser = await _userManager.GetUserAsync(User);
@@ -248,7 +248,7 @@ public class BillsController : Controller
return View("Create", dto);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
/// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
@@ -257,7 +257,7 @@ public class BillsController : Controller
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
/// so the double-entry pair is ready without manual lookup.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId)
{
var dto = new CreateBillDto
@@ -291,14 +291,14 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false,
DateTime? paymentDate = null,
@@ -322,7 +322,7 @@ public class BillsController : Controller
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check — block if the bill date is in a locked period
// Period lock check — block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -399,7 +399,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync();
});
// Receipt upload after the transaction commits — bill.Id is set and core data
// Receipt upload after the transaction commits — bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0)
{
@@ -428,7 +428,7 @@ public class BillsController : Controller
}
}
// ── Details ──────────────────────────────────────────────────────────────
// -- Details --------------------------------------------------------------
/// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form.
@@ -454,7 +454,7 @@ public class BillsController : Controller
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -464,7 +464,7 @@ public class BillsController : Controller
return View(dto);
}
// ── Edit ─────────────────────────────────────────────────────────────────
// -- Edit -----------------------------------------------------------------
/// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
@@ -472,7 +472,7 @@ public class BillsController : Controller
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
@@ -523,7 +523,7 @@ public class BillsController : Controller
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{
if (id != dto.Id) return NotFound();
@@ -620,7 +620,7 @@ public class BillsController : Controller
}
}
// ── Mark Open (Draft Open) ─────────────────────────────────────────────
// -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
@@ -631,7 +631,7 @@ public class BillsController : Controller
/// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
@@ -669,7 +669,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── Record Payment ───────────────────────────────────────────────────────
// -- Record Payment -------------------------------------------------------
/// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because
@@ -681,7 +681,7 @@ public class BillsController : Controller
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -752,7 +752,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Delete Payment ───────────────────────────────────────────────────────
// -- Delete Payment -------------------------------------------------------
/// <summary>
/// Reverses a previously recorded payment. All double-entry effects of
@@ -762,7 +762,7 @@ public class BillsController : Controller
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{
try
@@ -809,7 +809,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = billId });
}
// ── Edit Payment ─────────────────────────────────────────────────────────
// -- Edit Payment ---------------------------------------------------------
/// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
@@ -818,7 +818,7 @@ public class BillsController : Controller
/// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -863,11 +863,11 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Void ─────────────────────────────────────────────────────────────────
// -- Void -----------------------------------------------------------------
/// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -922,7 +922,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AJAX: Vendor default expense account ────────────────────────────────
// -- AJAX: Vendor default expense account --------------------------------
/// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
@@ -940,7 +940,7 @@ public class BillsController : Controller
});
}
// ── Helpers ──────────────────────────────────────────────────────────────
// -- Helpers --------------------------------------------------------------
/// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
@@ -979,7 +979,7 @@ public class BillsController : Controller
/// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// records are included in the scan so payment numbers are never reused.
/// </summary>
private async Task<string> GeneratePaymentNumberAsync()
@@ -994,7 +994,7 @@ public class BillsController : Controller
return $"{prefix}{next:D4}";
}
// ── Receipt File: Download / Remove ─────────────────────────────────────
// -- Receipt File: Download / Remove -------------------------------------
/// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
@@ -1022,7 +1022,7 @@ public class BillsController : Controller
/// window where the UI shows a broken attachment link.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
@@ -1039,7 +1039,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
// -- AI: Receipt Scanning -------------------------------------------------
/// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
@@ -1051,7 +1051,7 @@ public class BillsController : Controller
/// model can match categories to the company's specific chart of accounts.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{
@@ -1092,7 +1092,7 @@ public class BillsController : Controller
return Json(result);
}
// ── AI: Account Suggestion ────────────────────────────────────────────────
// -- AI: Account Suggestion ------------------------------------------------
/// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not
@@ -1103,7 +1103,7 @@ public class BillsController : Controller
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{
@@ -1136,16 +1136,16 @@ public class BillsController : Controller
return Json(result);
}
// ── AI: Recurring Bill Detection ──────────────────────────────────────────
// -- AI: Recurring Bill Detection ------------------------------------------
/// <summary>
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
@@ -1198,7 +1198,7 @@ public class BillsController : Controller
}
}
// ── Receipt File Helpers ──────────────────────────────────────────────────
// -- Receipt File Helpers --------------------------------------------------
/// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
CanManageVendors = forceAllPermissions || model.CanManageVendors,
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
CanViewReports = forceAllPermissions || model.CanViewReports
CanViewReports = forceAllPermissions || model.CanViewReports,
CanManageBills = forceAllPermissions || model.CanManageBills,
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
};
var result = await _userManager.CreateAsync(user, model.Password);
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly
};
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
CanManageVendors = user.CanManageVendors,
CanManageMaintenance = user.CanManageMaintenance,
CanManageInvoices = user.CanManageInvoices,
CanViewReports = user.CanViewReports
CanViewReports = user.CanViewReports,
CanManageBills = user.CanManageBills,
CanManageAccounting = user.CanManageAccounting
};
ViewBag.ReturnUrl = returnUrl;
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
user.CanViewReports = forceAllPermissions || model.CanViewReports;
user.CanManageBills = forceAllPermissions || model.CanManageBills;
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);