edd7389d7d
- 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>
498 lines
22 KiB
C#
498 lines
22 KiB
C#
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);
|
||
}
|
||
}
|