feff0fa73d
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and CanManageAccounting to Policies - ApplicationUser: add CanManageBills and CanManageAccounting bool fields - UserManagementDtos: expose new fields in all three DTOs - ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims - Program.cs: add CanManageBills and CanManageAccounting policies; update CanManageInvoices, CanViewReports, CanManagePurchaseOrders, and CanManageVendors to auto-pass for Accountant role - BillsController: replace CanManageInventory with CanManageBills on all write actions (correct policy — bills are not inventory) - BankReconciliationsController: replace CanManageJobs with CanManageAccounting on write actions - CompanyUsersController: add Accountant to validCompanyRoles (both Create/Edit), legacyRole switch, and all permission assignment blocks - Create/Edit views: add Accountant option to role dropdown; add CanManageBills and CanManageAccounting checkboxes; JS auto-checks financial permissions when Accountant role is selected - Migration AddAccountantRolePermissions: adds columns + backfills CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1219 lines
53 KiB
C#
1219 lines
53 KiB
C#
using AutoMapper;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using PowderCoating.Shared.Constants;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.RateLimiting;
|
||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Options;
|
||
using PowderCoating.Application.Configuration;
|
||
using PowderCoating.Application.Services;
|
||
using PowderCoating.Application.DTOs.Accounting;
|
||
using PowderCoating.Application.DTOs.AI;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Enums;
|
||
using PowderCoating.Core.Interfaces;
|
||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||
using PowderCoating.Web.Helpers;
|
||
|
||
namespace PowderCoating.Web.Controllers;
|
||
|
||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||
public class BillsController : Controller
|
||
{
|
||
private static readonly string[] AllowedReceiptTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" };
|
||
private const long MaxReceiptBytes = 10 * 1024 * 1024;
|
||
|
||
private readonly IUnitOfWork _unitOfWork;
|
||
private readonly IMapper _mapper;
|
||
private readonly UserManager<ApplicationUser> _userManager;
|
||
private readonly ILogger<BillsController> _logger;
|
||
private readonly IAccountBalanceService _accountBalanceService;
|
||
private readonly IAccountingAiService _accountingAi;
|
||
private readonly IAzureBlobStorageService _blobStorage;
|
||
private readonly StorageSettings _storageSettings;
|
||
private readonly IAiUsageLogger _usageLogger;
|
||
|
||
public BillsController(
|
||
IUnitOfWork unitOfWork,
|
||
IMapper mapper,
|
||
UserManager<ApplicationUser> userManager,
|
||
ILogger<BillsController> logger,
|
||
IAccountBalanceService accountBalanceService,
|
||
IAccountingAiService accountingAi,
|
||
IAzureBlobStorageService blobStorage,
|
||
IOptions<StorageSettings> storageSettings,
|
||
IAiUsageLogger usageLogger)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_mapper = mapper;
|
||
_userManager = userManager;
|
||
_logger = logger;
|
||
_accountBalanceService = accountBalanceService;
|
||
_accountingAi = accountingAi;
|
||
_blobStorage = blobStorage;
|
||
_storageSettings = storageSettings.Value;
|
||
_usageLogger = usageLogger;
|
||
}
|
||
|
||
// -- 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.
|
||
/// 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)
|
||
{
|
||
var entries = new List<BillExpenseListDto>();
|
||
|
||
// Pre-parse search as amount once so both queries can use it
|
||
decimal? searchAmount = null;
|
||
if (!string.IsNullOrEmpty(search))
|
||
{
|
||
var normalized = search.TrimStart('$').Replace(",", "");
|
||
if (decimal.TryParse(normalized, System.Globalization.NumberStyles.Any,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||
searchAmount = parsed;
|
||
}
|
||
|
||
// Bills
|
||
if (type == null || type == "Bill")
|
||
{
|
||
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount);
|
||
|
||
entries.AddRange(bills.Select(b => new BillExpenseListDto
|
||
{
|
||
EntryType = "Bill",
|
||
Id = b.Id,
|
||
Number = b.BillNumber,
|
||
Date = b.BillDate,
|
||
VendorName = b.Vendor?.CompanyName,
|
||
Memo = b.Memo,
|
||
Total = b.Total,
|
||
BalanceDue = b.BalanceDue,
|
||
DueDate = b.DueDate,
|
||
IsOverdue = b.Status != BillStatus.Paid && b.Status != BillStatus.Voided
|
||
&& b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today,
|
||
HasReceipt = !string.IsNullOrEmpty(b.ReceiptFilePath),
|
||
StatusLabel = b.Status.ToString(),
|
||
StatusColor = b.Status switch
|
||
{
|
||
BillStatus.Open => "primary",
|
||
BillStatus.PartiallyPaid => "warning",
|
||
BillStatus.Paid => "success",
|
||
BillStatus.Voided => "danger",
|
||
_ => "secondary"
|
||
}
|
||
}));
|
||
}
|
||
|
||
// 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;
|
||
var expAmount = searchAmount;
|
||
var expenseList = await _unitOfWork.Expenses.FindAsync(
|
||
e => string.IsNullOrEmpty(expSearch) ||
|
||
e.ExpenseNumber.Contains(expSearch) ||
|
||
(e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) ||
|
||
(e.Memo != null && e.Memo.Contains(expSearch)) ||
|
||
(expAmount.HasValue && e.Amount == expAmount.Value),
|
||
false,
|
||
e => e.Vendor!, e => e.ExpenseAccount!);
|
||
var expenses = expenseList.OrderByDescending(e => e.Date).ToList();
|
||
|
||
entries.AddRange(expenses.Select(e => new BillExpenseListDto
|
||
{
|
||
EntryType = "Expense",
|
||
Id = e.Id,
|
||
Number = e.ExpenseNumber,
|
||
Date = e.Date,
|
||
VendorName = e.Vendor?.CompanyName,
|
||
Memo = e.Memo,
|
||
AccountName = e.ExpenseAccount?.Name,
|
||
Total = e.Amount,
|
||
BalanceDue = 0,
|
||
HasReceipt = !string.IsNullOrEmpty(e.ReceiptFilePath),
|
||
StatusLabel = "Paid",
|
||
StatusColor = "success"
|
||
}));
|
||
}
|
||
|
||
entries = entries.OrderByDescending(e => e.Date).ToList();
|
||
|
||
ViewBag.TypeFilter = type;
|
||
ViewBag.Search = search;
|
||
ViewBag.StatusFilter = status;
|
||
ViewBag.TotalOwed = entries.Where(e => e.EntryType == "Bill" && e.BalanceDue > 0)
|
||
.Sum(e => e.BalanceDue);
|
||
ViewBag.TotalCount = entries.Count;
|
||
ViewBag.Page = page;
|
||
ViewBag.PageSize = pageSize;
|
||
ViewBag.TotalPages = (int)Math.Ceiling(entries.Count / (double)pageSize);
|
||
|
||
var pagedEntries = entries.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||
return View(pagedEntries);
|
||
}
|
||
|
||
// -- Create ---------------------------------------------------------------
|
||
|
||
// -- 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
|
||
/// 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
|
||
/// shipping cost is appended as an additional line item if non-zero. The vendor's
|
||
/// <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.CanManageBills)]
|
||
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
||
{
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
if (currentUser == null) return Unauthorized();
|
||
|
||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId);
|
||
|
||
if (po == null) return NotFound();
|
||
|
||
if (po.Status != PurchaseOrderStatus.Received && po.Status != PurchaseOrderStatus.PartiallyReceived)
|
||
{
|
||
TempData["Error"] = "Bills can only be created for Received or Partially Received purchase orders.";
|
||
return RedirectToAction("Details", "PurchaseOrders", new { id = purchaseOrderId });
|
||
}
|
||
|
||
if (po.BillId.HasValue)
|
||
{
|
||
TempData["Info"] = "A bill already exists for this purchase order.";
|
||
return RedirectToAction(nameof(Details), new { id = po.BillId });
|
||
}
|
||
|
||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||
|
||
// Vendor default expense account, fall back to first expense/COGS account
|
||
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
|
||
if (!defaultExpenseAccountId.HasValue)
|
||
{
|
||
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
||
defaultExpenseAccountId = fallbackAccount?.Id;
|
||
}
|
||
|
||
var lineItems = po.Items
|
||
.OrderBy(i => i.Id)
|
||
.Select((item, idx) => new CreateBillLineItemDto
|
||
{
|
||
AccountId = defaultExpenseAccountId ?? 0,
|
||
Description = item.InventoryItem?.Name ?? item.Description ?? string.Empty,
|
||
Quantity = item.QuantityOrdered,
|
||
UnitPrice = item.UnitCost,
|
||
DisplayOrder = idx
|
||
}).ToList();
|
||
|
||
if (po.ShippingCost > 0)
|
||
{
|
||
lineItems.Add(new CreateBillLineItemDto
|
||
{
|
||
AccountId = defaultExpenseAccountId ?? 0,
|
||
Description = "Shipping",
|
||
Quantity = 1,
|
||
UnitPrice = po.ShippingCost,
|
||
DisplayOrder = lineItems.Count
|
||
});
|
||
}
|
||
|
||
var dto = new CreateBillDto
|
||
{
|
||
VendorId = po.VendorId,
|
||
APAccountId = apAccount?.Id ?? 0,
|
||
BillDate = DateTime.Today,
|
||
Memo = $"From PO: {po.PoNumber}",
|
||
PurchaseOrderId = purchaseOrderId,
|
||
LineItems = lineItems
|
||
};
|
||
|
||
ViewBag.FromPoNumber = po.PoNumber;
|
||
ViewBag.FromPoId = purchaseOrderId;
|
||
|
||
await PopulateDropdownsAsync();
|
||
return View("Create", dto);
|
||
}
|
||
|
||
// -- Create ---------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
||
/// vendor is pre-selected and, if that vendor has a <c>DefaultExpenseAccountId</c>, a single
|
||
/// pre-categorised line item is added automatically so the user only needs to fill in the
|
||
/// 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.CanManageBills)]
|
||
public async Task<IActionResult> Create(int? vendorId)
|
||
{
|
||
var dto = new CreateBillDto
|
||
{
|
||
BillDate = DateTime.Today,
|
||
VendorId = vendorId ?? 0
|
||
};
|
||
|
||
// Pre-fill AP account
|
||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||
dto.APAccountId = apAccount?.Id ?? 0;
|
||
|
||
// Pre-fill default expense account for vendor
|
||
if (vendorId.HasValue)
|
||
{
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId.Value);
|
||
if (vendor?.DefaultExpenseAccountId != null)
|
||
dto.LineItems.Add(new CreateBillLineItemDto { AccountId = vendor.DefaultExpenseAccountId.Value, Quantity = 1 });
|
||
}
|
||
|
||
if (!dto.LineItems.Any())
|
||
dto.LineItems.Add(new CreateBillLineItemDto { Quantity = 1 });
|
||
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persists a new vendor bill. Bills are created in <c>Draft</c> status so the user can
|
||
/// 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> —
|
||
/// 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.CanManageBills)]
|
||
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
||
bool payNow = false,
|
||
DateTime? paymentDate = null,
|
||
int? paymentMethod = null,
|
||
int? bankAccountId = null,
|
||
string? checkNumber = null,
|
||
string? paymentMemo = null)
|
||
{
|
||
// Remove empty line items before validation
|
||
dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
|
||
if (!dto.LineItems.Any())
|
||
ModelState.AddModelError("LineItems", "At least one line item is required.");
|
||
|
||
if (!ModelState.IsValid)
|
||
{
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
|
||
try
|
||
{
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
// Period lock check — block if the bill date is in a locked period
|
||
if (currentUser != null)
|
||
{
|
||
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
|
||
{
|
||
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
Bill? bill = null;
|
||
|
||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||
// atomically so a payNow failure cannot leave a bill with no payment record.
|
||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||
{
|
||
bill = _mapper.Map<Bill>(dto);
|
||
bill.BillNumber = await GenerateBillNumberAsync();
|
||
bill.Status = BillStatus.Open;
|
||
bill.CompanyId = currentUser!.CompanyId;
|
||
bill.CreatedBy = currentUser.Email;
|
||
|
||
// Calculate financials
|
||
int order = 0;
|
||
foreach (var li in bill.LineItems)
|
||
{
|
||
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
|
||
li.DisplayOrder = order++;
|
||
li.CompanyId = currentUser.CompanyId;
|
||
}
|
||
|
||
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
|
||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||
|
||
await _unitOfWork.Bills.AddAsync(bill);
|
||
await _unitOfWork.CompleteAsync(); // flush to get bill.Id
|
||
|
||
// Link bill back to source PO
|
||
if (dto.PurchaseOrderId > 0)
|
||
{
|
||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||
if (po != null)
|
||
{
|
||
po.BillId = bill.Id;
|
||
po.UpdatedAt = DateTime.UtcNow;
|
||
}
|
||
}
|
||
|
||
// Record payment immediately if "already paid" was checked
|
||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
|
||
{
|
||
var payment = new BillPayment
|
||
{
|
||
BillId = bill.Id,
|
||
VendorId = bill.VendorId,
|
||
PaymentNumber = await GeneratePaymentNumberAsync(),
|
||
PaymentDate = paymentDate ?? DateTime.Today,
|
||
Amount = bill.Total,
|
||
PaymentMethod = (PaymentMethod)paymentMethod.Value,
|
||
BankAccountId = bankAccountId.Value,
|
||
CheckNumber = checkNumber,
|
||
Memo = paymentMemo,
|
||
CompanyId = bill.CompanyId,
|
||
CreatedBy = currentUser.Email
|
||
};
|
||
|
||
bill.AmountPaid = payment.Amount;
|
||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||
}
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
});
|
||
|
||
// 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)
|
||
{
|
||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||
if (receiptValid)
|
||
{
|
||
bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||
await _unitOfWork.CompleteAsync();
|
||
}
|
||
else
|
||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||
}
|
||
|
||
TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
|
||
? $"Bill {bill!.BillNumber} saved and marked as paid."
|
||
: $"Bill {bill!.BillNumber} created.";
|
||
return RedirectToAction(nameof(Details), new { id = bill!.Id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error creating bill");
|
||
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
// -- Details --------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Displays full bill detail including line items, payments, and the payment entry form.
|
||
/// Bank account and payment method dropdowns are populated here (not in a separate action)
|
||
/// because the partial payment modal lives inline on the details page.
|
||
/// </summary>
|
||
public async Task<IActionResult> Details(int? id)
|
||
{
|
||
if (id == null) return NotFound();
|
||
|
||
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
|
||
if (bill == null) return NotFound();
|
||
|
||
var dto = _mapper.Map<BillDto>(bill);
|
||
|
||
// Payment form defaults
|
||
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||
a => a.AccountSubType == AccountSubType.Cash ||
|
||
a.AccountSubType == AccountSubType.Checking ||
|
||
a.AccountSubType == AccountSubType.Savings ||
|
||
a.AccountSubType == AccountSubType.CreditCard))
|
||
.OrderBy(a => a.AccountNumber)
|
||
.ToList();
|
||
|
||
ViewBag.BankAccounts = bankAccounts
|
||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||
.ToList();
|
||
|
||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
|
||
.ToList();
|
||
|
||
return View(dto);
|
||
}
|
||
|
||
// -- Edit -----------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
||
/// <c>Open</c> its account-balance side effects have been applied and edits would create
|
||
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
||
/// audit trail.
|
||
/// </summary>
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> Edit(int? id)
|
||
{
|
||
if (id == null) return NotFound();
|
||
|
||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
|
||
if (bill == null) return NotFound();
|
||
|
||
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
|
||
{
|
||
TempData["Error"] = "Paid and voided bills cannot be edited.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var dto = new EditBillDto
|
||
{
|
||
Id = bill.Id,
|
||
VendorId = bill.VendorId,
|
||
APAccountId = bill.APAccountId,
|
||
VendorInvoiceNumber = bill.VendorInvoiceNumber,
|
||
BillDate = bill.BillDate,
|
||
DueDate = bill.DueDate,
|
||
Terms = bill.Terms,
|
||
Memo = bill.Memo,
|
||
TaxPercent = bill.TaxPercent,
|
||
ReceiptFilePath = bill.ReceiptFilePath,
|
||
LineItems = bill.LineItems
|
||
.OrderBy(li => li.DisplayOrder)
|
||
.Select(li => new CreateBillLineItemDto
|
||
{
|
||
AccountId = li.AccountId,
|
||
JobId = li.JobId,
|
||
Description = li.Description,
|
||
Quantity = li.Quantity,
|
||
UnitPrice = li.UnitPrice,
|
||
DisplayOrder = li.DisplayOrder
|
||
}).ToList()
|
||
};
|
||
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Saves bill edits. Line items are replaced using the soft-delete + re-insert pattern so
|
||
/// that historical <c>BillLineItem</c> records are preserved (required for audit log
|
||
/// integrity). Financials (SubTotal, TaxAmount, Total) are recalculated from the new line
|
||
/// items on every save. An optional receipt file replaces the previous attachment in blob
|
||
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
||
{
|
||
if (id != dto.Id) return NotFound();
|
||
|
||
dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
|
||
if (!dto.LineItems.Any())
|
||
ModelState.AddModelError("LineItems", "At least one line item is required.");
|
||
|
||
if (!ModelState.IsValid)
|
||
{
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
|
||
try
|
||
{
|
||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id);
|
||
|
||
if (bill == null) return NotFound();
|
||
|
||
if (bill.Status != BillStatus.Draft)
|
||
{
|
||
TempData["Error"] = "Only Draft bills can be edited.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
// Soft-delete old line items
|
||
foreach (var li in bill.LineItems)
|
||
{
|
||
li.IsDeleted = true;
|
||
li.UpdatedAt = DateTime.UtcNow;
|
||
}
|
||
|
||
bill.VendorId = dto.VendorId;
|
||
bill.APAccountId = dto.APAccountId;
|
||
bill.VendorInvoiceNumber = dto.VendorInvoiceNumber;
|
||
bill.BillDate = dto.BillDate;
|
||
bill.DueDate = dto.DueDate;
|
||
bill.Terms = dto.Terms;
|
||
bill.Memo = dto.Memo;
|
||
bill.TaxPercent = dto.TaxPercent;
|
||
bill.UpdatedAt = DateTime.UtcNow;
|
||
bill.UpdatedBy = currentUser?.Email;
|
||
|
||
int order = 0;
|
||
var newLineItems = dto.LineItems.Select(li => new BillLineItem
|
||
{
|
||
BillId = bill.Id,
|
||
AccountId = li.AccountId,
|
||
JobId = li.JobId,
|
||
Description = li.Description,
|
||
Quantity = li.Quantity,
|
||
UnitPrice = li.UnitPrice,
|
||
Amount = Math.Round(li.Quantity * li.UnitPrice, 2),
|
||
DisplayOrder = order++,
|
||
CompanyId = bill.CompanyId
|
||
}).ToList();
|
||
|
||
bill.SubTotal = newLineItems.Sum(li => li.Amount);
|
||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||
|
||
await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems);
|
||
|
||
// Handle receipt file replacement
|
||
if (receiptFile != null && receiptFile.Length > 0)
|
||
{
|
||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||
if (receiptValid)
|
||
{
|
||
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
|
||
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
|
||
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, bill.CompanyId);
|
||
}
|
||
else
|
||
{
|
||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||
}
|
||
}
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"Bill {bill.BillNumber} updated.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error updating bill {Id}", id);
|
||
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
|
||
await PopulateDropdownsAsync();
|
||
return View(dto);
|
||
}
|
||
}
|
||
|
||
// -- Mark Open (Draft ? Open) ---------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
||
/// the commit point for double-entry accounting: the AP account is credited for the full
|
||
/// bill total (increasing the liability), and each expense line item's account is debited
|
||
/// (recognising the cost). The vendor's <c>CurrentBalance</c> is also incremented so the
|
||
/// vendor aging report reflects the new obligation. These side effects are intentionally
|
||
/// deferred from bill creation to give users a review window without polluting the ledger.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> MarkOpen(int id)
|
||
{
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
||
if (bill == null) return NotFound();
|
||
|
||
if (bill.Status != BillStatus.Draft)
|
||
{
|
||
TempData["Error"] = "Only Draft bills can be opened.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
bill.Status = BillStatus.Open;
|
||
bill.UpdatedAt = DateTime.UtcNow;
|
||
bill.UpdatedBy = currentUser?.Email;
|
||
|
||
// Add to vendor's balance
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
|
||
if (vendor != null)
|
||
{
|
||
vendor.CurrentBalance += bill.Total;
|
||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||
}
|
||
|
||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||
|
||
// Update account balances: credit AP, debit each expense account
|
||
await _accountBalanceService.CreditAsync(bill.APAccountId, bill.Total);
|
||
foreach (var li in bill.LineItems.Where(l => !l.IsDeleted && l.AccountId.HasValue))
|
||
await _accountBalanceService.DebitAsync(li.AccountId!.Value, li.Amount);
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"Bill {bill.BillNumber} is now Open.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
// -- Record Payment -------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
||
/// it would produce a negative balance due and make the AP aging report unreliable.
|
||
/// Double-entry effect: the bank account (asset) is credited, and the AP account (liability)
|
||
/// is debited, reducing the outstanding obligation. The vendor's <c>CurrentBalance</c> is
|
||
/// decremented by the payment amount (floored at zero to absorb any floating-point drift).
|
||
/// Bill status advances to <c>Paid</c> only when <c>BalanceDue</c> drops to zero or below;
|
||
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
||
{
|
||
if (!ModelState.IsValid)
|
||
{
|
||
TempData["Error"] = string.Join(" ", ModelState.Values
|
||
.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
try
|
||
{
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(dto.BillId);
|
||
if (bill == null) return NotFound();
|
||
|
||
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
|
||
{
|
||
TempData["Error"] = "Cannot record payment on a Paid or Voided bill.";
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
if (dto.Amount > bill.BalanceDue)
|
||
{
|
||
TempData["Error"] = $"Payment amount ({dto.Amount:C}) exceeds balance due ({bill.BalanceDue:C}).";
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
var payment = _mapper.Map<BillPayment>(dto);
|
||
payment.PaymentNumber = await GeneratePaymentNumberAsync();
|
||
payment.VendorId = bill.VendorId;
|
||
payment.CompanyId = bill.CompanyId;
|
||
payment.CreatedBy = currentUser?.Email;
|
||
|
||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||
|
||
// Update bill financials
|
||
bill.AmountPaid += dto.Amount;
|
||
bill.Status = bill.BalanceDue <= 0 ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||
bill.UpdatedAt = DateTime.UtcNow;
|
||
bill.UpdatedBy = currentUser?.Email;
|
||
|
||
// Update vendor balance
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
|
||
if (vendor != null)
|
||
{
|
||
vendor.CurrentBalance -= dto.Amount;
|
||
if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
|
||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||
}
|
||
|
||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||
|
||
// Update account balances: credit bank account, debit AP
|
||
await _accountBalanceService.CreditAsync(payment.BankAccountId, dto.Amount);
|
||
await _accountBalanceService.DebitAsync(bill.APAccountId, dto.Amount);
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"Payment of {dto.Amount:C} recorded.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error recording payment for bill {BillId}", dto.BillId);
|
||
TempData["Error"] = "An error occurred while recording the payment.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
// -- Delete Payment -------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Reverses a previously recorded payment. All double-entry effects of
|
||
/// <see cref="RecordPayment"/> are unwound: the bank account is debited and the AP account
|
||
/// is credited to restore the outstanding balance. The vendor's <c>CurrentBalance</c> is
|
||
/// incremented by the reversed amount, and the bill status reverts to <c>Open</c> or
|
||
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
||
{
|
||
try
|
||
{
|
||
var payment = await _unitOfWork.BillPayments.GetByIdAsync(paymentId);
|
||
if (payment == null) return NotFound();
|
||
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(billId);
|
||
if (bill == null) return NotFound();
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
// Reverse bill financials
|
||
bill.AmountPaid -= payment.Amount;
|
||
bill.Status = bill.AmountPaid <= 0 ? BillStatus.Open : BillStatus.PartiallyPaid;
|
||
bill.UpdatedAt = DateTime.UtcNow;
|
||
bill.UpdatedBy = currentUser?.Email;
|
||
|
||
// Reverse vendor balance
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
|
||
if (vendor != null)
|
||
{
|
||
vendor.CurrentBalance += payment.Amount;
|
||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||
}
|
||
|
||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||
|
||
// Reverse account balances: debit bank account, credit AP
|
||
await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
|
||
await _accountBalanceService.CreditAsync(bill.APAccountId, payment.Amount);
|
||
|
||
await _unitOfWork.BillPayments.SoftDeleteAsync(paymentId);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Payment deleted and bill balance restored.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error deleting payment {PaymentId}", paymentId);
|
||
TempData["Error"] = "An error occurred while deleting the payment.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(Details), new { id = billId });
|
||
}
|
||
|
||
// -- Edit Payment ---------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
||
/// if the bank account changed, swaps the account-balance entries atomically: the old bank
|
||
/// account is debited (reversing the credit) and the new bank account is credited. The bill
|
||
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
||
{
|
||
if (!ModelState.IsValid)
|
||
{
|
||
TempData["Error"] = string.Join(" ", ModelState.Values
|
||
.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
try
|
||
{
|
||
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
|
||
if (payment == null) return NotFound();
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
// If the bank account changed, reverse the old balance entry and apply the new one
|
||
if (payment.BankAccountId != dto.BankAccountId)
|
||
{
|
||
await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
|
||
await _accountBalanceService.CreditAsync(dto.BankAccountId, payment.Amount);
|
||
}
|
||
|
||
payment.PaymentDate = dto.PaymentDate;
|
||
payment.PaymentMethod = dto.PaymentMethod;
|
||
payment.BankAccountId = dto.BankAccountId;
|
||
payment.CheckNumber = dto.CheckNumber;
|
||
payment.Memo = dto.Memo;
|
||
payment.UpdatedAt = DateTime.UtcNow;
|
||
payment.UpdatedBy = currentUser?.Email;
|
||
await _unitOfWork.BillPayments.UpdateAsync(payment);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = "Payment updated successfully.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error editing bill payment {PaymentId}", dto.PaymentId);
|
||
TempData["Error"] = "An error occurred while updating the payment.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||
}
|
||
|
||
// -- 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
|
||
/// 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>
|
||
/// (making the computed <c>BalanceDue</c> zero) rather than deleting payment records.
|
||
/// Restricted to <c>CompanyAdminOnly</c> because voiding affects the AP aging and P&L.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||
public async Task<IActionResult> Void(int id)
|
||
{
|
||
try
|
||
{
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||
if (bill == null) return NotFound();
|
||
|
||
if (bill.Status == BillStatus.Voided)
|
||
{
|
||
TempData["Error"] = "Bill is already voided.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
|
||
// Reverse vendor balance (only for the unpaid portion)
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
|
||
if (vendor != null)
|
||
{
|
||
vendor.CurrentBalance -= bill.BalanceDue;
|
||
if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
|
||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||
}
|
||
|
||
// Reverse AP account balance for the remaining (unpaid) balance
|
||
await _accountBalanceService.DebitAsync(bill.APAccountId, bill.BalanceDue);
|
||
|
||
bill.Status = BillStatus.Voided;
|
||
bill.AmountPaid = bill.Total; // zero out BalanceDue (computed: Total - AmountPaid)
|
||
bill.UpdatedAt = DateTime.UtcNow;
|
||
bill.UpdatedBy = currentUser?.Email;
|
||
|
||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"Bill {bill.BillNumber} has been voided.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error voiding bill {Id}", id);
|
||
TempData["Error"] = "An error occurred while voiding the bill.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
// -- AJAX: Vendor default expense account --------------------------------
|
||
|
||
/// <summary>
|
||
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
||
/// the Create/Edit bill form when the user changes the vendor dropdown so the UI can
|
||
/// pre-populate line items and the Due Date without a full page reload.
|
||
/// </summary>
|
||
[HttpGet]
|
||
public async Task<IActionResult> GetVendorDefaults(int vendorId)
|
||
{
|
||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId);
|
||
return Json(new
|
||
{
|
||
defaultExpenseAccountId = vendor?.DefaultExpenseAccountId,
|
||
terms = vendor?.PaymentTerms
|
||
});
|
||
}
|
||
|
||
// -- Helpers --------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
||
/// AP accounts, expense/COGS/asset accounts for line items, bank accounts for payment, payment
|
||
/// methods, and open jobs (for cost allocation). All account queries use a single
|
||
/// <c>FindAsync</c> call and then filter in memory to minimise round trips.
|
||
/// </summary>
|
||
private async Task PopulateDropdownsAsync()
|
||
{
|
||
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
|
||
ViewBag.Vendors = dd.Vendors;
|
||
ViewBag.APAccounts = dd.ApAccounts;
|
||
ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
|
||
ViewBag.BankAccounts = dd.BankAccounts;
|
||
ViewBag.PaymentMethods = dd.PaymentMethods;
|
||
ViewBag.Jobs = dd.ActiveJobs;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a sequential bill number in the format <c>BILL-YYMM-####</c>. Uses
|
||
/// <c>IgnoreQueryFilters()</c> so that soft-deleted bills are included in the sequence scan,
|
||
/// preventing number reuse after a bill is deleted. The month-scoped prefix means sequence
|
||
/// counters reset each month (e.g. BILL-2604-0001 in April 2026).
|
||
/// </summary>
|
||
private async Task<string> GenerateBillNumberAsync()
|
||
{
|
||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix);
|
||
|
||
int next = 1;
|
||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||
next = num + 1;
|
||
|
||
return $"{prefix}{next:D4}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||
/// 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()
|
||
{
|
||
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
|
||
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix);
|
||
|
||
int next = 1;
|
||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||
next = num + 1;
|
||
|
||
return $"{prefix}{next:D4}";
|
||
}
|
||
|
||
// -- Receipt File: Download / Remove -------------------------------------
|
||
|
||
/// <summary>
|
||
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
||
/// receipts (which are displayed inline for images), all bill receipt downloads are served
|
||
/// as attachments to ensure the browser prompts for save rather than opening in a tab.
|
||
/// </summary>
|
||
[HttpGet]
|
||
public async Task<IActionResult> DownloadReceipt(int id)
|
||
{
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||
if (bill == null || string.IsNullOrEmpty(bill.ReceiptFilePath)) return NotFound();
|
||
|
||
var result = await _blobStorage.DownloadAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
|
||
if (!result.Success) return NotFound();
|
||
|
||
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
|
||
var contentType = BlobFileHelper.GetContentType(ext);
|
||
var fileName = $"receipt-{bill.BillNumber}{ext}";
|
||
return File(result.Content, contentType, fileName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Detaches and permanently deletes the blob-stored receipt file from a bill. The blob is
|
||
/// deleted from Azure Blob Storage before the database reference is cleared, so there is no
|
||
/// window where the UI shows a broken attachment link.
|
||
/// </summary>
|
||
[HttpPost, ValidateAntiForgeryToken]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
public async Task<IActionResult> RemoveReceipt(int id)
|
||
{
|
||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||
if (bill == null) return NotFound();
|
||
|
||
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
|
||
{
|
||
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
|
||
bill.ReceiptFilePath = null;
|
||
await _unitOfWork.CompleteAsync();
|
||
}
|
||
|
||
TempData["Success"] = "Receipt attachment removed.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
// -- AI: Receipt Scanning -------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
||
/// the raw bytes to <see cref="IAccountingAiService.ScanReceiptAsync"/> (Anthropic Claude
|
||
/// Sonnet 4.6), and returns structured JSON containing extracted fields: vendor name, date,
|
||
/// amount, and suggested line-item account mappings. The caller's JS wizard uses the response
|
||
/// to pre-fill the bill Create form. Rate-limited to the <c>Ai</c> policy to control
|
||
/// Anthropic API costs. The available expense account list is passed with the request so the
|
||
/// model can match categories to the company's specific chart of accounts.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
||
{
|
||
if (receiptImage == null || receiptImage.Length == 0)
|
||
return Json(new { success = false, error = "No file uploaded." });
|
||
|
||
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "application/pdf" };
|
||
if (!allowedTypes.Contains(receiptImage.ContentType.ToLowerInvariant()))
|
||
return Json(new { success = false, error = "Supported formats: JPG, PNG, GIF, WebP, or PDF." });
|
||
|
||
if (receiptImage.Length > 10 * 1024 * 1024)
|
||
return Json(new { success = false, error = "File must be under 10 MB." });
|
||
|
||
// Load expense accounts for matching
|
||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||
var expenseAccounts = allAccounts
|
||
.Where(a => a.AccountType == AccountType.Expense ||
|
||
a.AccountType == AccountType.CostOfGoods ||
|
||
a.AccountType == AccountType.Asset)
|
||
.Select(a => new AccountSummary
|
||
{
|
||
Id = a.Id,
|
||
AccountNumber = a.AccountNumber,
|
||
Name = a.Name,
|
||
AccountType = a.AccountType.ToString(),
|
||
AccountSubType = a.AccountSubType.ToString()
|
||
})
|
||
.ToList();
|
||
|
||
using var ms = new MemoryStream();
|
||
await receiptImage.CopyToAsync(ms);
|
||
var imageBytes = ms.ToArray();
|
||
|
||
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
|
||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
|
||
return Json(result);
|
||
}
|
||
|
||
// -- AI: Account Suggestion ------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
||
/// supply <c>AvailableAccounts</c> (e.g. on first load) the controller fetches the active
|
||
/// expense/COGS/asset accounts from the database and adds them to the request before
|
||
/// forwarding to <see cref="IAccountingAiService.SuggestAccountAsync"/>. This lazy-load
|
||
/// approach lets the JS call this endpoint on blur without requiring the view to embed the
|
||
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
||
{
|
||
if (request == null)
|
||
return Json(new { success = false, error = "Invalid request." });
|
||
|
||
// Load expense accounts if not supplied
|
||
if (!request.AvailableAccounts.Any())
|
||
{
|
||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||
request.AvailableAccounts = allAccounts
|
||
.Where(a => a.AccountType == AccountType.Expense ||
|
||
a.AccountType == AccountType.CostOfGoods ||
|
||
a.AccountType == AccountType.Asset)
|
||
.Select(a => new AccountSummary
|
||
{
|
||
Id = a.Id,
|
||
AccountNumber = a.AccountNumber,
|
||
Name = a.Name,
|
||
AccountType = a.AccountType.ToString(),
|
||
AccountSubType = a.AccountSubType.ToString()
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
var result = await _accountingAi.SuggestAccountAsync(request);
|
||
var companyId2 = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid2) ? cid2 : 0;
|
||
var userId2 = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||
await _usageLogger.LogAsync(companyId2, userId2, AppConstants.AiFeatures.AccountSuggest, inputLength: (request.Description?.Length ?? 0));
|
||
return Json(result);
|
||
}
|
||
|
||
// -- AI: Recurring Bill Detection ------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// 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.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> RunRecurringDetection()
|
||
{
|
||
try
|
||
{
|
||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||
var cutoff = DateTime.Today.AddMonths(-12);
|
||
|
||
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||
.ToList();
|
||
|
||
if (!bills.Any())
|
||
return Json(new RecurringBillDetectionResult
|
||
{
|
||
Success = true,
|
||
Insights = new List<string> { "No bill history found in the last 12 months." }
|
||
});
|
||
|
||
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
|
||
|
||
var request = new RecurringBillDetectionRequest
|
||
{
|
||
CompanyName = companyName,
|
||
Bills = bills.Select(b => new RecurringBillHistoryItem
|
||
{
|
||
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
|
||
BillNumber = b.BillNumber,
|
||
Amount = b.Total,
|
||
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
|
||
Memo = b.Memo
|
||
}).ToList()
|
||
};
|
||
|
||
var result = await _accountingAi.DetectRecurringBillsAsync(request);
|
||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
|
||
return Json(result);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error running recurring bill detection");
|
||
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
|
||
}
|
||
}
|
||
|
||
// -- Receipt File Helpers --------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Uploads a receipt file to Azure Blob Storage under the path
|
||
/// <c>{companyId}/bill-receipts/{billId}/{guid}{ext}</c>. The GUID component prevents
|
||
/// name collisions when multiple receipts are uploaded during a single edit session. Returns
|
||
/// the blob name (relative path) to be stored on the <c>Bill.ReceiptFilePath</c> field, or
|
||
/// <c>null</c> when the upload fails so the caller can surface a non-fatal warning.
|
||
/// </summary>
|
||
private async Task<string?> UploadReceiptAsync(IFormFile file, int billId, int companyId)
|
||
{
|
||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
|
||
using var stream = file.OpenReadStream();
|
||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
|
||
return result.Success ? blobName : null;
|
||
}
|
||
}
|