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 _userManager; private readonly ILogger _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 userManager, ILogger logger, IAccountBalanceService accountBalanceService, IAccountingAiService accountingAi, IAzureBlobStorageService blobStorage, IOptions storageSettings, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _accountBalanceService = accountBalanceService; _accountingAi = accountingAi; _blobStorage = blobStorage; _storageSettings = storageSettings.Value; _usageLogger = usageLogger; } // -- Index ---------------------------------------------------------------- /// /// Lists bills and direct expenses in a unified AP ledger view. The /// 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. /// public async Task Index(string? type, string? search, string? status, int page = 1, int pageSize = 25) { var entries = new List(); // 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 -------------------------------------------- /// /// Scaffolds a new bill pre-filled from a received purchase order. Only POs in /// Received or PartiallyReceived 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 /// DefaultExpenseAccountId is used to pre-categorise all lines, falling back to the /// first active Expense/COGS account when the vendor has no default configured. /// [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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 --------------------------------------------------------------- /// /// Returns the blank bill creation form. When is supplied the /// vendor is pre-selected and, if that vendor has a DefaultExpenseAccountId, 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. /// [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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); } /// /// Persists a new vendor bill. Bills are created in Draft 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 is true a record is inserted /// immediately and the bill status is advanced to Paid or PartiallyPaid — /// useful for entering historical bills that were already settled. Account balance side /// effects are deliberately deferred to 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 PurchaseOrder.BillId is set to establish the 1:1 linkage. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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(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 -------------------------------------------------------------- /// /// 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. /// public async Task 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(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() .Select(m => new SelectListItem(m.ToString(), ((int)m).ToString())) .ToList(); return View(dto); } // -- Edit ----------------------------------------------------------------- /// /// Returns the edit form for a bill. Only Draft bills are editable; once a bill is /// Open 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. /// [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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); } /// /// Saves bill edits. Line items are replaced using the soft-delete + re-insert pattern so /// that historical BillLineItem 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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) --------------------------------------------- /// /// Transitions a bill from Draft to Open (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 CurrentBalance 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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 ------------------------------------------------------- /// /// 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 CurrentBalance is /// decremented by the payment amount (floored at zero to absorb any floating-point drift). /// Bill status advances to Paid only when BalanceDue drops to zero or below; /// any positive remainder leaves the bill in PartiallyPaid. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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(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 ------------------------------------------------------- /// /// Reverses a previously recorded payment. All double-entry effects of /// are unwound: the bank account is debited and the AP account /// is credited to restore the outstanding balance. The vendor's CurrentBalance is /// incremented by the reversed amount, and the bill status reverts to Open or /// PartiallyPaid depending on the remaining AmountPaid after reversal. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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 --------------------------------------------------------- /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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 ----------------------------------------------------------------- /// /// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger. /// Only the unpaid portion (BalanceDue) 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 BalanceDue, AmountPaid is set equal to Total /// (making the computed BalanceDue zero) rather than deleting payment records. /// Restricted to CompanyAdminOnly because voiding affects the AP aging and P&L. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task 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 -------------------------------- /// /// 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. /// [HttpGet] public async Task GetVendorDefaults(int vendorId) { var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId); return Json(new { defaultExpenseAccountId = vendor?.DefaultExpenseAccountId, terms = vendor?.PaymentTerms }); } // -- Helpers -------------------------------------------------------------- /// /// Loads all dropdown lists needed by the Create and Edit views into ViewBag: 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 /// FindAsync call and then filter in memory to minimise round trips. /// 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; } /// /// Generates a sequential bill number in the format BILL-YYMM-####. Uses /// IgnoreQueryFilters() 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). /// private async Task 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}"; } /// /// Generates a sequential payment reference number in the format BPMT-YYMM-####. /// Same monotonic sequence logic as — soft-deleted /// records are included in the scan so payment numbers are never reused. /// private async Task 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 ------------------------------------- /// /// 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. /// [HttpGet] public async Task 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); } /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageBills)] public async Task 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 ------------------------------------------------- /// /// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes /// the raw bytes to (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 Ai 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. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageBills)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task 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 ------------------------------------------------ /// /// AI-powered account categorisation for a single bill line item. When the caller does not /// supply AvailableAccounts (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 . 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 Ai policy. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageBills)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task 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 ------------------------------------------ /// /// 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 . /// public IActionResult RecurringDetection() => View(); /// /// 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. /// [HttpPost] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [ValidateAntiForgeryToken] public async Task 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 { "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 -------------------------------------------------- /// /// Uploads a receipt file to Azure Blob Storage under the path /// {companyId}/bill-receipts/{billId}/{guid}{ext}. 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 Bill.ReceiptFilePath field, or /// null when the upload fails so the caller can surface a non-fatal warning. /// private async Task 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; } }