Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user