Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,555 @@
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.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.Infrastructure.Data;
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 ApplicationDbContext _context;
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,
ApplicationDbContext context,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAccountBalanceService accountBalanceService,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_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 query = _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.Where(e => !e.IsDeleted);
if (!string.IsNullOrEmpty(search))
query = query.Where(e => e.ExpenseNumber.Contains(search) ||
e.Memo!.Contains(search) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)));
if (accountId.HasValue)
query = query.Where(e => e.ExpenseAccountId == accountId.Value);
if (from.HasValue)
query = query.Where(e => e.Date >= from.Value);
if (to.HasValue)
query = query.Where(e => e.Date <= to.Value);
var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync();
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 _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
.OrderBy(a => a.AccountNumber)
.ToListAsync();
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 && !IsValidReceiptFile(receiptFile, out var fileError))
{
ModelState.AddModelError(string.Empty, fileError);
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 && !IsValidReceiptFile(receiptFile, out var fileError))
{
ModelState.AddModelError(string.Empty, fileError);
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 _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
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 : MimeFromExt(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 allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.Vendors = (await _unitOfWork.Vendors.FindAsync(s => s.IsActive))
.OrderBy(s => s.CompanyName)
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
.ToList();
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED"))
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem($"{j.JobNumber} {j.Description ?? "No description"}", j.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
}
/// <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 _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.ExpenseNumber.StartsWith(prefix))
.OrderByDescending(e => e.ExpenseNumber)
.Select(e => e.ExpenseNumber)
.FirstOrDefaultAsync();
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}";
var contentType = MimeFromExt(ext);
using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
if (!result.Success)
{
_logger.LogError("Receipt upload failed for expense {Id}: {Error}", expenseId, result.ErrorMessage);
return null;
}
return blobName;
}
/// <summary>
/// Validates a receipt file against the allowed extension whitelist and the 10 MB size cap.
/// Returns <c>false</c> and sets <paramref name="error"/> when validation fails.
/// </summary>
private static bool IsValidReceiptFile(IFormFile file, out string error)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedReceiptTypes.Contains(ext))
{
error = $"File type '{ext}' is not allowed. Accepted types: {string.Join(", ", AllowedReceiptTypes)}";
return false;
}
if (file.Length > MaxReceiptBytes)
{
error = "Receipt file must be 10 MB or smaller.";
return false;
}
error = string.Empty;
return true;
}
private static string MimeFromExt(string ext) => ext switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
// ── 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);
}
}