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.Web.Helpers; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanViewData)] public class ExpensesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IAzureBlobStorageService _blobStorage; private readonly StorageSettings _storageSettings; private readonly IAccountBalanceService _accountBalanceService; private readonly IAccountingAiService _accountingAi; private readonly IAiUsageLogger _usageLogger; private static readonly string[] AllowedReceiptTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" }; private const long MaxReceiptBytes = 10 * 1024 * 1024; // 10 MB public ExpensesController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, IAzureBlobStorageService blobStorage, IOptions storageSettings, IAccountBalanceService accountBalanceService, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _blobStorage = blobStorage; _storageSettings = storageSettings.Value; _accountBalanceService = accountBalanceService; _accountingAi = accountingAi; _usageLogger = usageLogger; } // ── Index ──────────────────────────────────────────────────────────────── /// /// Redirects to the unified bills/expenses ledger () /// pre-filtered to Expense entries. The two entry types share a single list view to reduce /// navigation surface area; this redirect keeps the /Expenses URL working for /// existing bookmarks and links. /// public IActionResult Index() { return RedirectToAction("Index", "Bills", new { type = "Expense" }); } /// /// Legacy standalone expense list — kept for reference but disabled via [NonAction] /// since the unified Bills/Expenses index in superseded it. /// Do not route traffic here; use instead. /// [NonAction] public async Task IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25) { var allExpenses = (await _unitOfWork.Expenses.GetAllAsync( false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job)) .AsEnumerable(); if (!string.IsNullOrEmpty(search)) allExpenses = allExpenses.Where(e => e.ExpenseNumber.Contains(search) || (e.Memo != null && e.Memo.Contains(search)) || (e.Vendor != null && e.Vendor.CompanyName.Contains(search))); if (accountId.HasValue) allExpenses = allExpenses.Where(e => e.ExpenseAccountId == accountId.Value); if (from.HasValue) allExpenses = allExpenses.Where(e => e.Date >= from.Value); if (to.HasValue) allExpenses = allExpenses.Where(e => e.Date <= to.Value); var expenses = allExpenses.OrderByDescending(e => e.CreatedAt).ToList(); var dtos = _mapper.Map>(expenses); ViewBag.Search = search; ViewBag.AccountId = accountId; ViewBag.From = from?.ToString("yyyy-MM-dd"); ViewBag.To = to?.ToString("yyyy-MM-dd"); ViewBag.TotalAmount = dtos.Sum(e => e.Amount); var expenseAccounts = (await _unitOfWork.Accounts.FindAsync( a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))) .OrderBy(a => a.AccountNumber) .ToList(); ViewBag.AccountFilter = expenseAccounts .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); return View(dtos); } // ── Create ─────────────────────────────────────────────────────────────── /// /// Returns the blank expense creation form. Unlike vendor bills, direct expenses record /// an immediate payment (no AP liability step) — the expense account is debited and the /// payment account (bank/credit card) is credited at the point of saving. /// [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task Create() { await PopulateDropdownsAsync(); return View(new CreateExpenseDto { Date = DateTime.Today }); } /// /// Persists a new direct expense. The receipt file (if provided) is uploaded after the /// entity is saved so that the Expense.Id is available for the blob path. Double-entry /// effect: the expense account is debited (DebitAsync) and the payment account is /// credited (CreditAsync). An optional is stored in /// Azure Blob Storage; upload failure is non-fatal (a warning is shown) so the expense record /// is never lost due to a transient storage outage. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task Create(CreateExpenseDto dto, IFormFile? receiptFile) { if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(dto); } if (receiptFile != null) { var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes); if (!receiptValid) { ModelState.AddModelError(string.Empty, receiptError); await PopulateDropdownsAsync(); return View(dto); } } try { var currentUser = await _userManager.GetUserAsync(User); var expense = _mapper.Map(dto); expense.ExpenseNumber = await GenerateExpenseNumberAsync(); expense.CompanyId = currentUser!.CompanyId; expense.CreatedBy = currentUser.Email; await _unitOfWork.Expenses.AddAsync(expense); await _unitOfWork.CompleteAsync(); if (receiptFile != null) expense.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser.CompanyId); // Update account balances: debit expense account, credit payment account await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount); await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount); if (expense.ReceiptFilePath != null) await _unitOfWork.Expenses.UpdateAsync(expense); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Expense {expense.ExpenseNumber} recorded."; return RedirectToAction(nameof(Details), new { id = expense.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating expense"); ModelState.AddModelError(string.Empty, "An error occurred while saving."); await PopulateDropdownsAsync(); return View(dto); } } // ── Edit ───────────────────────────────────────────────────────────────── /// /// Returns the edit form for an existing expense. /// [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task Edit(int? id) { if (id == null) return NotFound(); var expense = await _unitOfWork.Expenses.GetByIdAsync(id.Value); if (expense == null) return NotFound(); await PopulateDropdownsAsync(); return View(_mapper.Map(expense)); } /// /// Saves expense edits. Because account balances were already applied when the expense was /// created, the old balances must be reversed before applying the new ones to keep the ledger /// accurate: the old expense account is credited (reversing the original debit) and the old /// payment account is debited (reversing the original credit), then the new accounts are /// updated with the new amount. If the account or amount is unchanged the net effect is zero. /// The old receipt blob is deleted from storage before uploading the replacement to avoid /// orphaned files. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task Edit(int id, EditExpenseDto dto, IFormFile? receiptFile) { if (id != dto.Id) return NotFound(); if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(dto); } if (receiptFile != null) { var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes); if (!receiptValid) { ModelState.AddModelError(string.Empty, receiptError); await PopulateDropdownsAsync(); return View(dto); } } try { var expense = await _unitOfWork.Expenses.GetByIdAsync(id); if (expense == null) return NotFound(); // Capture old values before overwriting var oldAmount = expense.Amount; var oldExpenseAccountId = expense.ExpenseAccountId; var oldPaymentAccountId = expense.PaymentAccountId; var currentUser = await _userManager.GetUserAsync(User); _mapper.Map(dto, expense); expense.UpdatedAt = DateTime.UtcNow; expense.UpdatedBy = currentUser?.Email; if (receiptFile != null) { // Delete old receipt if present if (!string.IsNullOrEmpty(expense.ReceiptFilePath)) await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath); expense.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser!.CompanyId); } // Reverse old balances, apply new balances await _accountBalanceService.CreditAsync(oldExpenseAccountId, oldAmount); await _accountBalanceService.DebitAsync(oldPaymentAccountId, oldAmount); await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount); await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount); await _unitOfWork.Expenses.UpdateAsync(expense); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Expense {expense.ExpenseNumber} updated."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating expense {Id}", id); ModelState.AddModelError(string.Empty, "An error occurred while saving."); await PopulateDropdownsAsync(); return View(dto); } } // ── Details ────────────────────────────────────────────────────────────── /// /// Displays the read-only expense detail view, including vendor, expense account, payment /// account, and linked job (if cost was allocated to a job). /// public async Task Details(int? id) { if (id == null) return NotFound(); var expense = await _unitOfWork.Expenses.GetByIdAsync( id.Value, false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job); if (expense == null) return NotFound(); return View(_mapper.Map(expense)); } // ── Delete ─────────────────────────────────────────────────────────────── /// /// Soft-deletes an expense and reverses its account-balance effects: the expense account is /// credited (reversing the original debit) and the payment account is debited (reversing the /// original credit). The receipt blob is permanently deleted from Azure Blob Storage because /// it is no longer referenced by any active record. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task Delete(int id) { var expense = await _unitOfWork.Expenses.GetByIdAsync(id); if (expense != null) { if (!string.IsNullOrEmpty(expense.ReceiptFilePath)) await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath); // Reverse account balances await _accountBalanceService.CreditAsync(expense.ExpenseAccountId, expense.Amount); await _accountBalanceService.DebitAsync(expense.PaymentAccountId, expense.Amount); } await _unitOfWork.Expenses.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Expense deleted."; return RedirectToAction(nameof(Index)); } // ── Receipt ────────────────────────────────────────────────────────────── /// /// Streams the receipt file from Azure Blob Storage. Images are served inline /// (Content-Disposition: inline) so the browser can preview them in a tab; PDFs are /// served as downloads (Content-Disposition: attachment) because inline PDF rendering /// varies between browsers and can cause a poor UX. /// public async Task ViewReceipt(int id) { var expense = await _unitOfWork.Expenses.GetByIdAsync(id); if (expense == null || string.IsNullOrEmpty(expense.ReceiptFilePath)) return NotFound(); var result = await _blobStorage.DownloadAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath); if (!result.Success) return NotFound(); // Inline for images so the browser previews them; attachment for PDFs triggers download var ext = Path.GetExtension(expense.ReceiptFilePath).ToLowerInvariant(); var contentType = result.ContentType.Length > 0 ? result.ContentType : BlobFileHelper.GetContentType(ext); var filename = $"Receipt-{expense.ExpenseNumber}{ext}"; Response.Headers["Content-Disposition"] = ext == ".pdf" ? $"attachment; filename=\"{filename}\"" : $"inline; filename=\"{filename}\""; return File(result.Content, contentType); } /// /// Removes the receipt attachment from an expense without deleting the expense itself. The /// blob is permanently deleted from Azure Blob Storage and the database path is nulled in the /// same save operation to keep them in sync. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CanManageInventory)] public async Task DeleteReceipt(int id) { var expense = await _unitOfWork.Expenses.GetByIdAsync(id); if (expense == null) return NotFound(); if (!string.IsNullOrEmpty(expense.ReceiptFilePath)) { await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath); expense.ReceiptFilePath = null; expense.UpdatedAt = DateTime.UtcNow; expense.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email; await _unitOfWork.Expenses.UpdateAsync(expense); await _unitOfWork.CompleteAsync(); } TempData["Success"] = "Receipt removed."; return RedirectToAction(nameof(Details), new { id }); } // ── Helpers ────────────────────────────────────────────────────────────── /// /// Loads all dropdowns required by the Create and Edit expense views: expense accounts /// (Expense and CostOfGoods types), payment accounts (Checking, Savings, CreditCard sub-types), /// active vendors, open jobs (for cost allocation), and payment methods. A single /// FindAsync fetches all accounts and then in-memory LINQ filters split them into the /// relevant subsets to avoid multiple database round trips. /// private async Task PopulateDropdownsAsync() { var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork); ViewBag.ExpenseAccounts = dd.ExpenseAccounts; ViewBag.PaymentAccounts = dd.BankAccounts; ViewBag.Vendors = dd.Vendors; ViewBag.Jobs = dd.ActiveJobs; ViewBag.PaymentMethods = dd.PaymentMethods; } /// /// Generates a sequential expense number in the format EXP-YYMM-####. Uses /// IgnoreQueryFilters() so that soft-deleted expense records are included in the /// max-sequence scan, preventing number reuse after deletion. /// private async Task GenerateExpenseNumberAsync() { var prefix = $"EXP-{DateTime.Now:yyMM}-"; var last = (await _unitOfWork.Expenses.FindAsync( e => e.ExpenseNumber.StartsWith(prefix), ignoreQueryFilters: true)) .OrderByDescending(e => e.ExpenseNumber) .Select(e => e.ExpenseNumber) .FirstOrDefault(); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) next = num + 1; return $"{prefix}{next:D4}"; } /// /// Uploads a receipt file to Azure Blob Storage at /// {companyId}/expense-receipts/{expenseId}{ext}. Returns the blob name (relative /// path) on success or null on failure, allowing the caller to continue saving the /// expense even when storage is unavailable. /// private async Task UploadReceiptAsync(IFormFile file, int expenseId, int companyId) { var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var blobName = $"{companyId}/expense-receipts/{expenseId}{ext}"; using var stream = file.OpenReadStream(); var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext)); if (!result.Success) { _logger.LogError("Receipt upload failed for expense {Id}: {Error}", expenseId, result.ErrorMessage); return null; } return blobName; } // ── AI: Account Suggestion ──────────────────────────────────────────────── /// /// AI-powered account categorisation for a single expense entry. If the caller does not /// supply AvailableAccounts, the controller fetches the active Expense and CostOfGoods /// accounts and merges them into the request before forwarding to /// . Called on blur from the expense /// account dropdown when the user types a memo, helping reduce mis-categorisation. Rate-limited /// to the Ai policy to control Anthropic API usage. /// [HttpPost] [Authorize(Policy = AppConstants.Policies.CanManageInventory)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task SuggestAccount([FromBody] AccountSuggestionRequest request) { if (request == null) return Json(new { success = false, error = "Invalid request." }); 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) .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 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.AccountSuggest, inputLength: (request.Description?.Length ?? 0)); return Json(result); } }