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
@@ -41,6 +41,8 @@ public class CompanyUserDto
public bool CanManageMaintenance { get; set; } public bool CanManageMaintenance { get; set; }
public bool CanManageInvoices { get; set; } public bool CanManageInvoices { get; set; }
public bool CanViewReports { get; set; } public bool CanViewReports { get; set; }
public bool CanManageBills { get; set; }
public bool CanManageAccounting { get; set; }
} }
/// <summary> /// <summary>
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
[Display(Name = "Can View Reports")] [Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; } public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
[Display(Name = "Send Welcome Email")] [Display(Name = "Send Welcome Email")]
public bool SendWelcomeEmail { get; set; } = true; public bool SendWelcomeEmail { get; set; } = true;
} }
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Can View Reports")] [Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; } public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
} }
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
public bool CanManageMaintenance { get; set; } = false; public bool CanManageMaintenance { get; set; } = false;
public bool CanManageInvoices { get; set; } = false; public bool CanManageInvoices { get; set; } = false;
public bool CanViewReports { get; set; } = false; public bool CanViewReports { get; set; } = false;
public bool CanManageBills { get; set; } = false;
public bool CanManageAccounting { get; set; } = false;
// Profile Photo (filesystem storage) // Profile Photo (filesystem storage)
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg") public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanManageAccounting",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanManageBills",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
// Grant both new permissions to all existing CompanyAdmin users so they don't lose access
migrationBuilder.Sql(@"
UPDATE AspNetUsers
SET CanManageBills = 1, CanManageAccounting = 1
WHERE CompanyRole = 'CompanyAdmin'
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CanManageAccounting",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "CanManageBills",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
}
}
}
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
identity.AddClaim(new Claim("Permission", "ViewReports")); identity.AddClaim(new Claim("Permission", "ViewReports"));
} }
if (user.CanManageBills)
{
identity.AddClaim(new Claim("Permission", "ManageBills"));
}
if (user.CanManageAccounting)
{
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
}
return identity; return identity;
} }
} }
@@ -31,6 +31,7 @@ public static class AppConstants
{ {
public const string CompanyAdmin = "CompanyAdmin"; public const string CompanyAdmin = "CompanyAdmin";
public const string Manager = "Manager"; public const string Manager = "Manager";
public const string Accountant = "Accountant";
public const string Worker = "Worker"; public const string Worker = "Worker";
public const string Viewer = "Viewer"; public const string Viewer = "Viewer";
} }
@@ -58,6 +59,8 @@ public static class AppConstants
public const string CanManageMaintenance = "CanManageMaintenance"; public const string CanManageMaintenance = "CanManageMaintenance";
public const string CanManageInvoices = "CanManageInvoices"; public const string CanManageInvoices = "CanManageInvoices";
public const string CanViewReports = "CanViewReports"; public const string CanViewReports = "CanViewReports";
public const string CanManageBills = "CanManageBills";
public const string CanManageAccounting = "CanManageAccounting";
} }
public static class FileUpload public static class FileUpload
@@ -56,7 +56,7 @@ public class BankReconciliationsController : Controller
// ── Create ─────────────────────────────────────────────────────────────── // ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
public async Task<IActionResult> Create() public async Task<IActionResult> Create()
{ {
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
@@ -65,7 +65,7 @@ public class BankReconciliationsController : Controller
} }
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model) public async Task<IActionResult> Create(BankReconciliation model)
{ {
@@ -171,7 +171,7 @@ public class BankReconciliationsController : Controller
/// Returns updated running totals as JSON. /// Returns updated running totals as JSON.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared( public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared) 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> /// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference) 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. /// suggestions client-side by auto-checking the corresponding table rows.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)] [Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AiSuggestMatches(int reconId) public async Task<IActionResult> AiSuggestMatches(int reconId)
{ {
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -58,13 +58,13 @@ public class BillsController : Controller
_usageLogger = usageLogger; _usageLogger = usageLogger;
} }
// ── Index ──────────────────────────────────────────────────────────────── // -- Index ----------------------------------------------------------------
/// <summary> /// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/> /// 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). /// 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 /// 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. /// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25) 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") if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{ {
var expSearch = search; var expSearch = search;
@@ -160,13 +160,13 @@ public class BillsController : Controller
return View(pagedEntries); return View(pagedEntries);
} }
// ── Create ─────────────────────────────────────────────────────────────── // -- Create ---------------------------------------------------------------
// ── Create from Purchase Order ──────────────────────────────────────────── // -- Create from Purchase Order --------------------------------------------
/// <summary> /// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in /// 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 /// 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. /// 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 /// 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 /// <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. /// first active Expense/COGS account when the vendor has no default configured.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId) public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
@@ -248,7 +248,7 @@ public class BillsController : Controller
return View("Create", dto); return View("Create", dto);
} }
// ── Create ─────────────────────────────────────────────────────────────── // -- Create ---------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the /// 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 /// 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. /// so the double-entry pair is ready without manual lookup.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId) public async Task<IActionResult> Create(int? vendorId)
{ {
var dto = new CreateBillDto 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 /// 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. /// 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 /// 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 /// 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 /// 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 /// 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. /// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile, public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false, bool payNow = false,
DateTime? paymentDate = null, DateTime? paymentDate = null,
@@ -322,7 +322,7 @@ public class BillsController : Controller
{ {
var currentUser = await _userManager.GetUserAsync(User); 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) if (currentUser != null)
{ {
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -399,7 +399,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync(); 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. // is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0) if (receiptFile != null && receiptFile.Length > 0)
{ {
@@ -428,7 +428,7 @@ public class BillsController : Controller
} }
} }
// ── Details ────────────────────────────────────────────────────────────── // -- Details --------------------------------------------------------------
/// <summary> /// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form. /// Displays full bill detail including line items, payments, and the payment entry form.
@@ -454,7 +454,7 @@ public class BillsController : Controller
.ToList(); .ToList();
ViewBag.BankAccounts = bankAccounts 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(); .ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>() ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -464,7 +464,7 @@ public class BillsController : Controller
return View(dto); return View(dto);
} }
// ── Edit ───────────────────────────────────────────────────────────────── // -- Edit -----------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is /// 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 /// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail. /// audit trail.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id) public async Task<IActionResult> Edit(int? id)
{ {
if (id == null) return NotFound(); 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. /// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile) public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{ {
if (id != dto.Id) return NotFound(); if (id != dto.Id) return NotFound();
@@ -620,7 +620,7 @@ public class BillsController : Controller
} }
} }
// ── Mark Open (Draft Open) ───────────────────────────────────────────── // -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary> /// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is /// 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. /// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id) public async Task<IActionResult> MarkOpen(int id)
{ {
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems); 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 }); return RedirectToAction(nameof(Details), new { id });
} }
// ── Record Payment ─────────────────────────────────────────────────────── // -- Record Payment -------------------------------------------------------
/// <summary> /// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because /// 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>. /// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto) public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -752,7 +752,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); return RedirectToAction(nameof(Details), new { id = dto.BillId });
} }
// ── Delete Payment ─────────────────────────────────────────────────────── // -- Delete Payment -------------------------------------------------------
/// <summary> /// <summary>
/// Reverses a previously recorded payment. All double-entry effects of /// 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. /// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId) public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{ {
try try
@@ -809,7 +809,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = billId }); return RedirectToAction(nameof(Details), new { id = billId });
} }
// ── Edit Payment ───────────────────────────────────────────────────────── // -- Edit Payment ---------------------------------------------------------
/// <summary> /// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and, /// 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. /// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto) public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -863,11 +863,11 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); return RedirectToAction(nameof(Details), new { id = dto.BillId });
} }
// ── Void ───────────────────────────────────────────────────────────────── // -- Void -----------------------------------------------------------------
/// <summary> /// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger. /// 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 /// 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 /// 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> /// 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 }); return RedirectToAction(nameof(Details), new { id });
} }
// ── AJAX: Vendor default expense account ──────────────────────────────── // -- AJAX: Vendor default expense account --------------------------------
/// <summary> /// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by /// 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> /// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors, /// 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> /// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>. /// 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. /// records are included in the scan so payment numbers are never reused.
/// </summary> /// </summary>
private async Task<string> GeneratePaymentNumberAsync() private async Task<string> GeneratePaymentNumberAsync()
@@ -994,7 +994,7 @@ public class BillsController : Controller
return $"{prefix}{next:D4}"; return $"{prefix}{next:D4}";
} }
// ── Receipt File: Download / Remove ───────────────────────────────────── // -- Receipt File: Download / Remove -------------------------------------
/// <summary> /// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense /// 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. /// window where the UI shows a broken attachment link.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id) public async Task<IActionResult> RemoveReceipt(int id)
{ {
var bill = await _unitOfWork.Bills.GetByIdAsync(id); var bill = await _unitOfWork.Bills.GetByIdAsync(id);
@@ -1039,7 +1039,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
// ── AI: Receipt Scanning ───────────────────────────────────────────────── // -- AI: Receipt Scanning -------------------------------------------------
/// <summary> /// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes /// 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. /// model can match categories to the company's specific chart of accounts.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage) public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{ {
@@ -1092,7 +1092,7 @@ public class BillsController : Controller
return Json(result); return Json(result);
} }
// ── AI: Account Suggestion ──────────────────────────────────────────────── // -- AI: Account Suggestion ------------------------------------------------
/// <summary> /// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not /// 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. /// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request) public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{ {
@@ -1136,16 +1136,16 @@ public class BillsController : Controller
return Json(result); return Json(result);
} }
// ── AI: Recurring Bill Detection ────────────────────────────────────────── // -- AI: Recurring Bill Detection ------------------------------------------
/// <summary> /// <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"/>. /// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary> /// </summary>
public IActionResult RecurringDetection() => View(); public IActionResult RecurringDetection() => View();
/// <summary> /// <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 /// 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. /// 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. /// 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> /// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path /// Uploads a receipt file to Azure Blob Storage under the path
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer AppConstants.CompanyRoles.Viewer
}; };
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
CanManageVendors = forceAllPermissions || model.CanManageVendors, CanManageVendors = forceAllPermissions || model.CanManageVendors,
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance, CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
CanManageInvoices = forceAllPermissions || model.CanManageInvoices, 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); var result = await _userManager.CreateAsync(user, model.Password);
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator, AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager, AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee, AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly _ => AppConstants.Roles.ReadOnly
}; };
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
CanManageVendors = user.CanManageVendors, CanManageVendors = user.CanManageVendors,
CanManageMaintenance = user.CanManageMaintenance, CanManageMaintenance = user.CanManageMaintenance,
CanManageInvoices = user.CanManageInvoices, CanManageInvoices = user.CanManageInvoices,
CanViewReports = user.CanViewReports CanViewReports = user.CanViewReports,
CanManageBills = user.CanManageBills,
CanManageAccounting = user.CanManageAccounting
}; };
ViewBag.ReturnUrl = returnUrl; ViewBag.ReturnUrl = returnUrl;
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer AppConstants.CompanyRoles.Viewer
}; };
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance; user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices; user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
user.CanViewReports = forceAllPermissions || model.CanViewReports; user.CanViewReports = forceAllPermissions || model.CanViewReports;
user.CanManageBills = forceAllPermissions || model.CanManageBills;
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user); var result = await _userManager.UpdateAsync(user);
+37 -3
View File
@@ -414,6 +414,9 @@ builder.Services.AddAuthorization(options =>
var user = context.User; var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin)) if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true; return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageVendors"); return user.HasClaim("Permission", "ManageVendors");
})); }));
@@ -425,7 +428,8 @@ builder.Services.AddAuthorization(options =>
return true; return true;
var companyRole = user.FindFirst("CompanyRole")?.Value; var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager) companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true; return true;
return user.HasClaim("Permission", "ManageInventory") || return user.HasClaim("Permission", "ManageInventory") ||
user.HasClaim("Permission", "ManageVendors"); user.HasClaim("Permission", "ManageVendors");
@@ -448,7 +452,8 @@ builder.Services.AddAuthorization(options =>
return true; return true;
var companyRole = user.FindFirst("CompanyRole")?.Value; var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager) companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true; return true;
return user.HasClaim("Permission", "ManageInvoices") || return user.HasClaim("Permission", "ManageInvoices") ||
user.HasClaim("Permission", "ManageJobs"); user.HasClaim("Permission", "ManageJobs");
@@ -462,11 +467,40 @@ builder.Services.AddAuthorization(options =>
return true; return true;
var companyRole = user.FindFirst("CompanyRole")?.Value; var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin || if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager) companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true; return true;
return user.HasClaim("Permission", "ViewReports"); return user.HasClaim("Permission", "ViewReports");
})); }));
options.AddPolicy("CanManageBills", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageBills");
}));
options.AddPolicy("CanManageAccounting", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageAccounting");
}));
options.AddPolicy("CanManageUsers", policy => options.AddPolicy("CanManageUsers", policy =>
policy.RequireAssertion(context => policy.RequireAssertion(context =>
{ {
@@ -78,6 +78,7 @@
<select asp-for="CompanyRole" class="form-select"> <select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option> <option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option> <option value="Worker">Worker</option>
<option value="Accountant">Accountant</option>
<option value="Manager">Manager</option> <option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option> <option value="CompanyAdmin">Company Admin</option>
</select> </select>
@@ -198,6 +199,19 @@
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label> <label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageBills" class="form-check-input permission-checkbox" />
<label asp-for="CanManageBills" class="form-check-label">Can Manage Bills &amp; AP</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageAccounting" class="form-check-input permission-checkbox" />
<label asp-for="CanManageAccounting" class="form-check-label">Can Manage Accounting</label>
<div class="form-text text-muted small">Chart of accounts, bank reconciliations, journal entries</div>
</div>
</div>
</div> </div>
<div class="d-flex gap-2 justify-content-end"> <div class="d-flex gap-2 justify-content-end">
@@ -223,19 +237,24 @@
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox'); const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
const adminAlert = document.getElementById('companyAdminAlert'); const adminAlert = document.getElementById('companyAdminAlert');
function updatePermissionState() { const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
function updatePermissionState() {
const role = roleSelect.value;
const isCompanyAdmin = role === 'CompanyAdmin';
const isAccountant = role === 'Accountant';
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none'; adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => { permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) { if (isCompanyAdmin) {
checkbox.checked = true; checkbox.checked = true;
checkbox.disabled = true; checkbox.disabled = true;
} else { } else {
checkbox.disabled = false; checkbox.disabled = false;
if (isAccountant) {
checkbox.checked = accountantDefaults.includes(checkbox.id);
}
} }
}); });
} }
@@ -90,6 +90,7 @@
<select asp-for="CompanyRole" class="form-select"> <select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option> <option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option> <option value="Worker">Worker</option>
<option value="Accountant">Accountant</option>
<option value="Manager">Manager</option> <option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option> <option value="CompanyAdmin">Company Admin</option>
</select> </select>
@@ -215,6 +216,19 @@
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label> <label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageBills" class="form-check-input permission-checkbox" />
<label asp-for="CanManageBills" class="form-check-label">Can Manage Bills &amp; AP</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageAccounting" class="form-check-input permission-checkbox" />
<label asp-for="CanManageAccounting" class="form-check-label">Can Manage Accounting</label>
<div class="form-text text-muted small">Chart of accounts, bank reconciliations, journal entries</div>
</div>
</div>
</div> </div>
<div class="d-flex gap-2 justify-content-end"> <div class="d-flex gap-2 justify-content-end">
@@ -249,26 +263,30 @@
const adminAlert = document.getElementById('companyAdminAlert'); const adminAlert = document.getElementById('companyAdminAlert');
const isSuperAdmin = @((ViewBag.IsSuperAdmin as bool? ?? false) ? "true" : "false"); const isSuperAdmin = @((ViewBag.IsSuperAdmin as bool? ?? false) ? "true" : "false");
const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
function updatePermissionState() { function updatePermissionState() {
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin'; const role = roleSelect.value;
const isCompanyAdmin = role === 'CompanyAdmin';
const isAccountant = role === 'Accountant';
if (isSuperAdmin) { if (isSuperAdmin) {
// SuperAdmins can always edit individual permissions
adminAlert.style.display = 'none'; adminAlert.style.display = 'none';
permissionCheckboxes.forEach(checkbox => { checkbox.disabled = false; }); permissionCheckboxes.forEach(checkbox => { checkbox.disabled = false; });
return; return;
} }
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none'; adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => { permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) { if (isCompanyAdmin) {
checkbox.checked = true; checkbox.checked = true;
checkbox.disabled = true; checkbox.disabled = true;
} else { } else {
checkbox.disabled = false; checkbox.disabled = false;
if (isAccountant) {
checkbox.checked = accountantDefaults.includes(checkbox.id);
}
} }
}); });
} }