Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/ExpensesController.cs
T
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00

498 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ApplicationUser> _userManager;
private readonly ILogger<ExpensesController> _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<ApplicationUser> userManager,
ILogger<ExpensesController> logger,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> 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 ────────────────────────────────────────────────────────────────
/// <summary>
/// Redirects to the unified bills/expenses ledger (<see cref="BillsController.Index"/>)
/// pre-filtered to Expense entries. The two entry types share a single list view to reduce
/// navigation surface area; this redirect keeps the <c>/Expenses</c> URL working for
/// existing bookmarks and links.
/// </summary>
public IActionResult Index()
{
return RedirectToAction("Index", "Bills", new { type = "Expense" });
}
/// <summary>
/// Legacy standalone expense list — kept for reference but disabled via <c>[NonAction]</c>
/// since the unified Bills/Expenses index in <see cref="BillsController"/> superseded it.
/// Do not route traffic here; use <see cref="Index"/> instead.
/// </summary>
[NonAction]
public async Task<IActionResult> 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<List<ExpenseListDto>>(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 ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Create()
{
await PopulateDropdownsAsync();
return View(new CreateExpenseDto { Date = DateTime.Today });
}
/// <summary>
/// Persists a new direct expense. The receipt file (if provided) is uploaded after the
/// entity is saved so that the <c>Expense.Id</c> is available for the blob path. Double-entry
/// effect: the expense account is debited (<c>DebitAsync</c>) and the payment account is
/// credited (<c>CreditAsync</c>). An optional <paramref name="receiptFile"/> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> 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<Expense>(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 ─────────────────────────────────────────────────────────────────
/// <summary>
/// Returns the edit form for an existing expense.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> 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<EditExpenseDto>(expense));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> 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 ──────────────────────────────────────────────────────────────
/// <summary>
/// Displays the read-only expense detail view, including vendor, expense account, payment
/// account, and linked job (if cost was allocated to a job).
/// </summary>
public async Task<IActionResult> 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<ExpenseDto>(expense));
}
// ── Delete ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> 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 ──────────────────────────────────────────────────────────────
/// <summary>
/// Streams the receipt file from Azure Blob Storage. Images are served inline
/// (<c>Content-Disposition: inline</c>) so the browser can preview them in a tab; PDFs are
/// served as downloads (<c>Content-Disposition: attachment</c>) because inline PDF rendering
/// varies between browsers and can cause a poor UX.
/// </summary>
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> 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 ──────────────────────────────────────────────────────────────
/// <summary>
/// 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
/// <c>FindAsync</c> fetches all accounts and then in-memory LINQ filters split them into the
/// relevant subsets to avoid multiple database round trips.
/// </summary>
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;
}
/// <summary>
/// Generates a sequential expense number in the format <c>EXP-YYMM-####</c>. Uses
/// <c>IgnoreQueryFilters()</c> so that soft-deleted expense records are included in the
/// max-sequence scan, preventing number reuse after deletion.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// Uploads a receipt file to Azure Blob Storage at
/// <c>{companyId}/expense-receipts/{expenseId}{ext}</c>. Returns the blob name (relative
/// path) on success or <c>null</c> on failure, allowing the caller to continue saving the
/// expense even when storage is unavailable.
/// </summary>
private async Task<string?> 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 ────────────────────────────────────────────────
/// <summary>
/// AI-powered account categorisation for a single expense entry. If the caller does not
/// supply <c>AvailableAccounts</c>, the controller fetches the active Expense and CostOfGoods
/// accounts and merges them into the request before forwarding to
/// <see cref="IAccountingAiService.SuggestAccountAsync"/>. Called on blur from the expense
/// account dropdown when the user types a memo, helping reduce mis-categorisation. Rate-limited
/// to the <c>Ai</c> policy to control Anthropic API usage.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> 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);
}
}