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:
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
||||
public bool CanManageMaintenance { get; set; }
|
||||
public bool CanManageInvoices { get; set; }
|
||||
public bool CanViewReports { get; set; }
|
||||
public bool CanManageBills { get; set; }
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
||||
[Display(Name = "Can View Reports")]
|
||||
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")]
|
||||
public bool SendWelcomeEmail { get; set; } = true;
|
||||
}
|
||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
||||
|
||||
[Display(Name = "Can View Reports")]
|
||||
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 CanManageInvoices { 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)
|
||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||
|
||||
Generated
+10591
File diff suppressed because it is too large
Load Diff
+90
@@ -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"));
|
||||
}
|
||||
|
||||
if (user.CanManageBills)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageBills"));
|
||||
}
|
||||
|
||||
if (user.CanManageAccounting)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public static class AppConstants
|
||||
{
|
||||
public const string CompanyAdmin = "CompanyAdmin";
|
||||
public const string Manager = "Manager";
|
||||
public const string Accountant = "Accountant";
|
||||
public const string Worker = "Worker";
|
||||
public const string Viewer = "Viewer";
|
||||
}
|
||||
@@ -58,6 +59,8 @@ public static class AppConstants
|
||||
public const string CanManageMaintenance = "CanManageMaintenance";
|
||||
public const string CanManageInvoices = "CanManageInvoices";
|
||||
public const string CanViewReports = "CanViewReports";
|
||||
public const string CanManageBills = "CanManageBills";
|
||||
public const string CanManageAccounting = "CanManageAccounting";
|
||||
}
|
||||
|
||||
public static class FileUpload
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -414,6 +414,9 @@ builder.Services.AddAuthorization(options =>
|
||||
var user = context.User;
|
||||
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||
return true;
|
||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole == AppConstants.CompanyRoles.Accountant)
|
||||
return true;
|
||||
return user.HasClaim("Permission", "ManageVendors");
|
||||
}));
|
||||
|
||||
@@ -425,7 +428,8 @@ builder.Services.AddAuthorization(options =>
|
||||
return true;
|
||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||
companyRole == AppConstants.CompanyRoles.Manager)
|
||||
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||
return true;
|
||||
return user.HasClaim("Permission", "ManageInventory") ||
|
||||
user.HasClaim("Permission", "ManageVendors");
|
||||
@@ -448,7 +452,8 @@ builder.Services.AddAuthorization(options =>
|
||||
return true;
|
||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||
companyRole == AppConstants.CompanyRoles.Manager)
|
||||
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||
return true;
|
||||
return user.HasClaim("Permission", "ManageInvoices") ||
|
||||
user.HasClaim("Permission", "ManageJobs");
|
||||
@@ -462,11 +467,40 @@ builder.Services.AddAuthorization(options =>
|
||||
return true;
|
||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||
companyRole == AppConstants.CompanyRoles.Manager)
|
||||
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||
return true;
|
||||
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 =>
|
||||
policy.RequireAssertion(context =>
|
||||
{
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<select asp-for="CompanyRole" class="form-select">
|
||||
<option value="Viewer">Viewer (Read-only)</option>
|
||||
<option value="Worker">Worker</option>
|
||||
<option value="Accountant">Accountant</option>
|
||||
<option value="Manager">Manager</option>
|
||||
<option value="CompanyAdmin">Company Admin</option>
|
||||
</select>
|
||||
@@ -198,6 +199,19 @@
|
||||
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
|
||||
</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 & 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 class="d-flex gap-2 justify-content-end">
|
||||
@@ -223,19 +237,24 @@
|
||||
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
|
||||
const adminAlert = document.getElementById('companyAdminAlert');
|
||||
|
||||
function updatePermissionState() {
|
||||
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
|
||||
const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
|
||||
|
||||
function updatePermissionState() {
|
||||
const role = roleSelect.value;
|
||||
const isCompanyAdmin = role === 'CompanyAdmin';
|
||||
const isAccountant = role === 'Accountant';
|
||||
|
||||
// Show/hide alert
|
||||
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
|
||||
|
||||
// Check all and disable if Company Admin, otherwise enable
|
||||
permissionCheckboxes.forEach(checkbox => {
|
||||
if (isCompanyAdmin) {
|
||||
checkbox.checked = true;
|
||||
checkbox.disabled = true;
|
||||
} else {
|
||||
checkbox.disabled = false;
|
||||
if (isAccountant) {
|
||||
checkbox.checked = accountantDefaults.includes(checkbox.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<select asp-for="CompanyRole" class="form-select">
|
||||
<option value="Viewer">Viewer (Read-only)</option>
|
||||
<option value="Worker">Worker</option>
|
||||
<option value="Accountant">Accountant</option>
|
||||
<option value="Manager">Manager</option>
|
||||
<option value="CompanyAdmin">Company Admin</option>
|
||||
</select>
|
||||
@@ -215,6 +216,19 @@
|
||||
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
|
||||
</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 & 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 class="d-flex gap-2 justify-content-end">
|
||||
@@ -249,26 +263,30 @@
|
||||
const adminAlert = document.getElementById('companyAdminAlert');
|
||||
const isSuperAdmin = @((ViewBag.IsSuperAdmin as bool? ?? false) ? "true" : "false");
|
||||
|
||||
const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
|
||||
|
||||
function updatePermissionState() {
|
||||
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
|
||||
const role = roleSelect.value;
|
||||
const isCompanyAdmin = role === 'CompanyAdmin';
|
||||
const isAccountant = role === 'Accountant';
|
||||
|
||||
if (isSuperAdmin) {
|
||||
// SuperAdmins can always edit individual permissions
|
||||
adminAlert.style.display = 'none';
|
||||
permissionCheckboxes.forEach(checkbox => { checkbox.disabled = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
// Show/hide alert
|
||||
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
|
||||
|
||||
// Check all and disable if Company Admin, otherwise enable
|
||||
permissionCheckboxes.forEach(checkbox => {
|
||||
if (isCompanyAdmin) {
|
||||
checkbox.checked = true;
|
||||
checkbox.disabled = true;
|
||||
} else {
|
||||
checkbox.disabled = false;
|
||||
if (isAccountant) {
|
||||
checkbox.checked = accountantDefaults.includes(checkbox.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user