Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/BillsController.cs
T
spouliot 08a5cd39d4 Scope all controller account lookups by CompanyId (defense-in-depth sweep)
Completes the read-path defense-in-depth pass flagged in the accounting audit:
every Accounts lookup in a controller now carries an explicit CompanyId predicate,
matching the standing rule in CLAUDE.md ("every FindAsync/GetAllAsync must include
an explicit CompanyId"). ~19 lookups across 12 controllers:

  - Tier 1 (write-path): AccountsController duplicate account-number check (Create/Edit)
  - Tier 2 (dropdowns/lists): Accounts (Index/year-end/parent), BankReconciliations,
    Bills (bank list + receipt scan + suggest), Budgets, CatalogItems, Expenses,
    FixedAssets, Inventory, JournalEntries chart dropdown, Vendors
  - Tier 3 (accountIds.Contains display maps): JournalEntries/Reports/VendorCredits
    detail views, scoped via the in-scope entity's CompanyId for uniformity

companyId source per controller: _tenantContext where available, else the in-scope
entity's CompanyId, else the current user. Build clean; 291 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:49:16 -04:00

1232 lines
54 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.Application.DTOs.PurchaseOrder;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class BillsController : Controller
{
private static readonly string[] AllowedReceiptTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" };
private const long MaxReceiptBytes = 10 * 1024 * 1024;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<BillsController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
private readonly IAccountingAiService _accountingAi;
private readonly IAzureBlobStorageService _blobStorage;
private readonly StorageSettings _storageSettings;
private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public BillsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<BillsController> logger,
IAccountBalanceService accountBalanceService,
IAccountingAiService accountingAi,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAiUsageLogger usageLogger,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
_accountingAi = accountingAi;
_blobStorage = blobStorage;
_storageSettings = storageSettings.Value;
_usageLogger = usageLogger;
_tenantContext = tenantContext;
}
// -- Index ----------------------------------------------------------------
/// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
{
var entries = new List<BillExpenseListDto>();
// Pre-parse search as amount once so both queries can use it
decimal? searchAmount = null;
if (!string.IsNullOrEmpty(search))
{
var normalized = search.TrimStart('$').Replace(",", "");
if (decimal.TryParse(normalized, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
searchAmount = parsed;
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Bills
if (type == null || type == "Bill")
{
var bills = await _unitOfWork.Bills.GetForIndexAsync(companyId, status, search, searchAmount);
entries.AddRange(bills.Select(b => new BillExpenseListDto
{
EntryType = "Bill",
Id = b.Id,
Number = b.BillNumber,
Date = b.BillDate,
VendorName = b.Vendor?.CompanyName,
Memo = b.Memo,
Total = b.Total,
BalanceDue = b.BalanceDue,
DueDate = b.DueDate,
IsOverdue = b.Status != BillStatus.Paid && b.Status != BillStatus.Voided
&& b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today,
HasReceipt = !string.IsNullOrEmpty(b.ReceiptFilePath),
StatusLabel = b.Status.ToString(),
StatusColor = b.Status switch
{
BillStatus.Open => "primary",
BillStatus.PartiallyPaid => "warning",
BillStatus.Paid => "success",
BillStatus.Voided => "danger",
_ => "secondary"
}
}));
}
// Expenses are always fully paid exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expSearch = search;
var expAmount = searchAmount;
var expenseList = await _unitOfWork.Expenses.FindAsync(
e => string.IsNullOrEmpty(expSearch) ||
e.ExpenseNumber.Contains(expSearch) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) ||
(e.Memo != null && e.Memo.Contains(expSearch)) ||
(expAmount.HasValue && e.Amount == expAmount.Value),
false,
e => e.Vendor!, e => e.ExpenseAccount!);
var expenses = expenseList.OrderByDescending(e => e.Date).ToList();
entries.AddRange(expenses.Select(e => new BillExpenseListDto
{
EntryType = "Expense",
Id = e.Id,
Number = e.ExpenseNumber,
Date = e.Date,
VendorName = e.Vendor?.CompanyName,
Memo = e.Memo,
AccountName = e.ExpenseAccount?.Name,
Total = e.Amount,
BalanceDue = 0,
HasReceipt = !string.IsNullOrEmpty(e.ReceiptFilePath),
StatusLabel = "Paid",
StatusColor = "success"
}));
}
entries = entries.OrderByDescending(e => e.Date).ToList();
ViewBag.TypeFilter = type;
ViewBag.Search = search;
ViewBag.StatusFilter = status;
ViewBag.TotalOwed = entries.Where(e => e.EntryType == "Bill" && e.BalanceDue > 0)
.Sum(e => e.BalanceDue);
ViewBag.TotalCount = entries.Count;
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(entries.Count / (double)pageSize);
var pagedEntries = entries.Skip((page - 1) * pageSize).Take(pageSize).ToList();
return View(pagedEntries);
}
// -- Create ---------------------------------------------------------------
// -- Create from Purchase Order --------------------------------------------
/// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and
/// shipping cost is appended as an additional line item if non-zero. The vendor's
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
/// first active Expense/COGS account when the vendor has no default configured.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Received && po.Status != PurchaseOrderStatus.PartiallyReceived)
{
TempData["Error"] = "Bills can only be created for Received or Partially Received purchase orders.";
return RedirectToAction("Details", "PurchaseOrders", new { id = purchaseOrderId });
}
if (po.BillId.HasValue)
{
TempData["Info"] = "A bill already exists for this purchase order.";
return RedirectToAction(nameof(Details), new { id = po.BillId });
}
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
// Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue)
{
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
defaultExpenseAccountId = fallbackAccount?.Id;
}
var lineItems = po.Items
.OrderBy(i => i.Id)
.Select((item, idx) => new CreateBillLineItemDto
{
AccountId = defaultExpenseAccountId ?? 0,
Description = item.InventoryItem?.Name ?? item.Description ?? string.Empty,
Quantity = item.QuantityOrdered,
UnitPrice = item.UnitCost,
DisplayOrder = idx
}).ToList();
if (po.ShippingCost > 0)
{
lineItems.Add(new CreateBillLineItemDto
{
AccountId = defaultExpenseAccountId ?? 0,
Description = "Shipping",
Quantity = 1,
UnitPrice = po.ShippingCost,
DisplayOrder = lineItems.Count
});
}
var dto = new CreateBillDto
{
VendorId = po.VendorId,
APAccountId = apAccount?.Id ?? 0,
BillDate = DateTime.Today,
Memo = $"From PO: {po.PoNumber}",
PurchaseOrderId = purchaseOrderId,
LineItems = lineItems
};
ViewBag.FromPoNumber = po.PoNumber;
ViewBag.FromPoId = purchaseOrderId;
await PopulateDropdownsAsync();
return View("Create", dto);
}
// -- Create ---------------------------------------------------------------
/// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
/// vendor is pre-selected and, if that vendor has a <c>DefaultExpenseAccountId</c>, a single
/// pre-categorised line item is added automatically so the user only needs to fill in the
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
/// so the double-entry pair is ready without manual lookup.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId)
{
var dto = new CreateBillDto
{
BillDate = DateTime.Today,
VendorId = vendorId ?? 0
};
// Pre-fill AP account
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor
if (vendorId.HasValue)
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId.Value);
if (vendor?.DefaultExpenseAccountId != null)
dto.LineItems.Add(new CreateBillLineItemDto { AccountId = vendor.DefaultExpenseAccountId.Value, Quantity = 1 });
}
if (!dto.LineItems.Any())
dto.LineItems.Add(new CreateBillLineItemDto { Quantity = 1 });
await PopulateDropdownsAsync();
return View(dto);
}
/// <summary>
/// Persists a new vendor bill. Bills are created in <c>Draft</c> status so the user can
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c>
/// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false,
DateTime? paymentDate = null,
int? paymentMethod = null,
int? bankAccountId = null,
string? checkNumber = null,
string? paymentMemo = null)
{
// Remove empty line items before validation
dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
if (!dto.LineItems.Any())
ModelState.AddModelError("LineItems", "At least one line item is required.");
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
{
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
await PopulateDropdownsAsync();
return View(dto);
}
}
Bill? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit
// atomically so a payNow failure cannot leave a bill with no payment record.
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
bill = _mapper.Map<Bill>(dto);
bill.BillNumber = await GenerateBillNumberAsync();
bill.Status = BillStatus.Open;
bill.CompanyId = currentUser!.CompanyId;
bill.CreatedBy = currentUser.Email;
// Calculate financials
int order = 0;
foreach (var li in bill.LineItems)
{
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
li.DisplayOrder = order++;
li.CompanyId = currentUser.CompanyId;
}
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
await _unitOfWork.Bills.AddAsync(bill);
await _unitOfWork.CompleteAsync(); // flush to get bill.Id
// Link bill back to source PO
if (dto.PurchaseOrderId > 0)
{
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
if (po != null)
{
po.BillId = bill.Id;
po.UpdatedAt = DateTime.UtcNow;
}
}
// Record payment immediately if "already paid" was checked
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
{
var payment = new BillPayment
{
BillId = bill.Id,
VendorId = bill.VendorId,
PaymentNumber = await GeneratePaymentNumberAsync(),
PaymentDate = paymentDate ?? DateTime.Today,
Amount = bill.Total,
PaymentMethod = (PaymentMethod)paymentMethod.Value,
BankAccountId = bankAccountId.Value,
CheckNumber = checkNumber,
Memo = paymentMemo,
CompanyId = bill.CompanyId,
CreatedBy = currentUser.Email
};
bill.AmountPaid = payment.Amount;
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
await _unitOfWork.BillPayments.AddAsync(payment);
}
await _unitOfWork.CompleteAsync();
});
// Receipt upload after the transaction commits bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0)
{
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
{
bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
await _unitOfWork.Bills.UpdateAsync(bill);
await _unitOfWork.CompleteAsync();
}
else
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
}
TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
? $"Bill {bill!.BillNumber} saved and marked as paid."
: $"Bill {bill!.BillNumber} created.";
return RedirectToAction(nameof(Details), new { id = bill!.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bill");
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync();
return View(dto);
}
}
// -- Details --------------------------------------------------------------
/// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form.
/// Bank account and payment method dropdowns are populated here (not in a separate action)
/// because the partial payment modal lives inline on the details page.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value, companyId);
if (bill == null) return NotFound();
var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == bill.CompanyId &&
(a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)))
.OrderBy(a => a.AccountNumber)
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
return View(dto);
}
// -- Edit -----------------------------------------------------------------
/// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
/// <c>Open</c> its account-balance side effects have been applied and edits would create
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value, companyId);
if (bill == null) return NotFound();
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
{
TempData["Error"] = "Paid and voided bills cannot be edited.";
return RedirectToAction(nameof(Details), new { id });
}
var dto = new EditBillDto
{
Id = bill.Id,
VendorId = bill.VendorId,
APAccountId = bill.APAccountId,
VendorInvoiceNumber = bill.VendorInvoiceNumber,
BillDate = bill.BillDate,
DueDate = bill.DueDate,
Terms = bill.Terms,
Memo = bill.Memo,
TaxPercent = bill.TaxPercent,
ReceiptFilePath = bill.ReceiptFilePath,
LineItems = bill.LineItems
.OrderBy(li => li.DisplayOrder)
.Select(li => new CreateBillLineItemDto
{
AccountId = li.AccountId,
JobId = li.JobId,
Description = li.Description,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice,
DisplayOrder = li.DisplayOrder
}).ToList()
};
await PopulateDropdownsAsync();
return View(dto);
}
/// <summary>
/// Saves bill edits. Line items are replaced using the soft-delete + re-insert pattern so
/// that historical <c>BillLineItem</c> records are preserved (required for audit log
/// integrity). Financials (SubTotal, TaxAmount, Total) are recalculated from the new line
/// items on every save. An optional receipt file replaces the previous attachment in blob
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{
if (id != dto.Id) return NotFound();
dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
if (!dto.LineItems.Any())
ModelState.AddModelError("LineItems", "At least one line item is required.");
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync();
return View(dto);
}
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id, companyId);
if (bill == null) return NotFound();
if (bill.Status != BillStatus.Draft)
{
TempData["Error"] = "Only Draft bills can be edited.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
// Soft-delete old line items
foreach (var li in bill.LineItems)
{
li.IsDeleted = true;
li.UpdatedAt = DateTime.UtcNow;
}
bill.VendorId = dto.VendorId;
bill.APAccountId = dto.APAccountId;
bill.VendorInvoiceNumber = dto.VendorInvoiceNumber;
bill.BillDate = dto.BillDate;
bill.DueDate = dto.DueDate;
bill.Terms = dto.Terms;
bill.Memo = dto.Memo;
bill.TaxPercent = dto.TaxPercent;
bill.UpdatedAt = DateTime.UtcNow;
bill.UpdatedBy = currentUser?.Email;
int order = 0;
var newLineItems = dto.LineItems.Select(li => new BillLineItem
{
BillId = bill.Id,
AccountId = li.AccountId,
JobId = li.JobId,
Description = li.Description,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice,
Amount = Math.Round(li.Quantity * li.UnitPrice, 2),
DisplayOrder = order++,
CompanyId = bill.CompanyId
}).ToList();
bill.SubTotal = newLineItems.Sum(li => li.Amount);
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems);
// Handle receipt file replacement
if (receiptFile != null && receiptFile.Length > 0)
{
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
{
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, bill.CompanyId);
}
else
{
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
}
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Bill {bill.BillNumber} updated.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating bill {Id}", id);
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync();
return View(dto);
}
}
// -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
/// the commit point for double-entry accounting: the AP account is credited for the full
/// bill total (increasing the liability), and each expense line item's account is debited
/// (recognising the cost). The vendor's <c>CurrentBalance</c> is also incremented so the
/// vendor aging report reflects the new obligation. These side effects are intentionally
/// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
if (bill == null) return NotFound();
if (bill.Status != BillStatus.Draft)
{
TempData["Error"] = "Only Draft bills can be opened.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
bill.Status = BillStatus.Open;
bill.UpdatedAt = DateTime.UtcNow;
bill.UpdatedBy = currentUser?.Email;
// Add to vendor's balance
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
if (vendor != null)
{
vendor.CurrentBalance += bill.Total;
await _unitOfWork.Vendors.UpdateAsync(vendor);
}
await _unitOfWork.Bills.UpdateAsync(bill);
// Update account balances: credit AP, debit each expense account
await _accountBalanceService.CreditAsync(bill.APAccountId, bill.Total);
foreach (var li in bill.LineItems.Where(l => !l.IsDeleted && l.AccountId.HasValue))
await _accountBalanceService.DebitAsync(li.AccountId!.Value, li.Amount);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Bill {bill.BillNumber} is now Open.";
return RedirectToAction(nameof(Details), new { id });
}
// -- Record Payment -------------------------------------------------------
/// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because
/// it would produce a negative balance due and make the AP aging report unreliable.
/// Double-entry effect: the bank account (asset) is credited, and the AP account (liability)
/// is debited, reducing the outstanding obligation. The vendor's <c>CurrentBalance</c> is
/// decremented by the payment amount (floored at zero to absorb any floating-point drift).
/// Bill status advances to <c>Paid</c> only when <c>BalanceDue</c> drops to zero or below;
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{
if (!ModelState.IsValid)
{
TempData["Error"] = string.Join(" ", ModelState.Values
.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
try
{
var bill = await _unitOfWork.Bills.GetByIdAsync(dto.BillId);
if (bill == null) return NotFound();
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
{
TempData["Error"] = "Cannot record payment on a Paid or Voided bill.";
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
if (dto.Amount > bill.BalanceDue)
{
TempData["Error"] = $"Payment amount ({dto.Amount:C}) exceeds balance due ({bill.BalanceDue:C}).";
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
var currentUser = await _userManager.GetUserAsync(User);
var payment = _mapper.Map<BillPayment>(dto);
payment.PaymentNumber = await GeneratePaymentNumberAsync();
payment.VendorId = bill.VendorId;
payment.CompanyId = bill.CompanyId;
payment.CreatedBy = currentUser?.Email;
await _unitOfWork.BillPayments.AddAsync(payment);
// Update bill financials
bill.AmountPaid += dto.Amount;
bill.Status = bill.BalanceDue <= 0 ? BillStatus.Paid : BillStatus.PartiallyPaid;
bill.UpdatedAt = DateTime.UtcNow;
bill.UpdatedBy = currentUser?.Email;
// Update vendor balance
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
if (vendor != null)
{
vendor.CurrentBalance -= dto.Amount;
if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
await _unitOfWork.Vendors.UpdateAsync(vendor);
}
await _unitOfWork.Bills.UpdateAsync(bill);
// Update account balances: credit bank account, debit AP
await _accountBalanceService.CreditAsync(payment.BankAccountId, dto.Amount);
await _accountBalanceService.DebitAsync(bill.APAccountId, dto.Amount);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Payment of {dto.Amount:C} recorded.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording payment for bill {BillId}", dto.BillId);
TempData["Error"] = "An error occurred while recording the payment.";
}
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// -- Delete Payment -------------------------------------------------------
/// <summary>
/// Reverses a previously recorded payment. All double-entry effects of
/// <see cref="RecordPayment"/> are unwound: the bank account is debited and the AP account
/// is credited to restore the outstanding balance. The vendor's <c>CurrentBalance</c> is
/// incremented by the reversed amount, and the bill status reverts to <c>Open</c> or
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{
try
{
var payment = await _unitOfWork.BillPayments.GetByIdAsync(paymentId);
if (payment == null) return NotFound();
var bill = await _unitOfWork.Bills.GetByIdAsync(billId);
if (bill == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
// Reverse bill financials
bill.AmountPaid -= payment.Amount;
bill.Status = bill.AmountPaid <= 0 ? BillStatus.Open : BillStatus.PartiallyPaid;
bill.UpdatedAt = DateTime.UtcNow;
bill.UpdatedBy = currentUser?.Email;
// Reverse vendor balance
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
if (vendor != null)
{
vendor.CurrentBalance += payment.Amount;
await _unitOfWork.Vendors.UpdateAsync(vendor);
}
await _unitOfWork.Bills.UpdateAsync(bill);
// Reverse account balances: debit bank account, credit AP
await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
await _accountBalanceService.CreditAsync(bill.APAccountId, payment.Amount);
await _unitOfWork.BillPayments.SoftDeleteAsync(paymentId);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Payment deleted and bill balance restored.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting payment {PaymentId}", paymentId);
TempData["Error"] = "An error occurred while deleting the payment.";
}
return RedirectToAction(nameof(Details), new { id = billId });
}
// -- Edit Payment ---------------------------------------------------------
/// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
/// if the bank account changed, swaps the account-balance entries atomically: the old bank
/// account is debited (reversing the credit) and the new bank account is credited. The bill
/// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{
if (!ModelState.IsValid)
{
TempData["Error"] = string.Join(" ", ModelState.Values
.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
try
{
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
if (payment == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
// If the bank account changed, reverse the old balance entry and apply the new one
if (payment.BankAccountId != dto.BankAccountId)
{
await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
await _accountBalanceService.CreditAsync(dto.BankAccountId, payment.Amount);
}
payment.PaymentDate = dto.PaymentDate;
payment.PaymentMethod = dto.PaymentMethod;
payment.BankAccountId = dto.BankAccountId;
payment.CheckNumber = dto.CheckNumber;
payment.Memo = dto.Memo;
payment.UpdatedAt = DateTime.UtcNow;
payment.UpdatedBy = currentUser?.Email;
await _unitOfWork.BillPayments.UpdateAsync(payment);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Payment updated successfully.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error editing bill payment {PaymentId}", dto.PaymentId);
TempData["Error"] = "An error occurred while updating the payment.";
}
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// -- Void -----------------------------------------------------------------
/// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
/// (making the computed <c>BalanceDue</c> zero) rather than deleting payment records.
/// Restricted to <c>CompanyAdminOnly</c> because voiding affects the AP aging and P&amp;L.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Void(int id)
{
try
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
if (bill == null) return NotFound();
if (bill.Status == BillStatus.Voided)
{
TempData["Error"] = "Bill is already voided.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
// Reverse vendor balance (only for the unpaid portion)
var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
if (vendor != null)
{
vendor.CurrentBalance -= bill.BalanceDue;
if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
await _unitOfWork.Vendors.UpdateAsync(vendor);
}
// Reverse AP account balance for the remaining (unpaid) balance
await _accountBalanceService.DebitAsync(bill.APAccountId, bill.BalanceDue);
bill.Status = BillStatus.Voided;
bill.AmountPaid = bill.Total; // zero out BalanceDue (computed: Total - AmountPaid)
bill.UpdatedAt = DateTime.UtcNow;
bill.UpdatedBy = currentUser?.Email;
await _unitOfWork.Bills.UpdateAsync(bill);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Bill {bill.BillNumber} has been voided.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error voiding bill {Id}", id);
TempData["Error"] = "An error occurred while voiding the bill.";
}
return RedirectToAction(nameof(Details), new { id });
}
// -- AJAX: Vendor default expense account --------------------------------
/// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
/// the Create/Edit bill form when the user changes the vendor dropdown so the UI can
/// pre-populate line items and the Due Date without a full page reload.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetVendorDefaults(int vendorId)
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId);
return Json(new
{
defaultExpenseAccountId = vendor?.DefaultExpenseAccountId,
terms = vendor?.PaymentTerms
});
}
// -- Helpers --------------------------------------------------------------
/// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
/// AP accounts, expense/COGS/asset accounts for line items, bank accounts for payment, payment
/// methods, and open jobs (for cost allocation). All account queries use a single
/// <c>FindAsync</c> call and then filter in memory to minimise round trips.
/// </summary>
private async Task PopulateDropdownsAsync()
{
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
ViewBag.Vendors = dd.Vendors;
ViewBag.APAccounts = dd.ApAccounts;
ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
ViewBag.BankAccounts = dd.BankAccounts;
ViewBag.PaymentMethods = dd.PaymentMethods;
ViewBag.Jobs = dd.ActiveJobs;
}
/// <summary>
/// Generates a sequential bill number in the format <c>BILL-YYMM-####</c>. Uses
/// <c>IgnoreQueryFilters()</c> so that soft-deleted bills are included in the sequence scan,
/// preventing number reuse after a bill is deleted. The month-scoped prefix means sequence
/// counters reset each month (e.g. BILL-2604-0001 in April 2026).
/// </summary>
private async Task<string> GenerateBillNumberAsync()
{
var prefix = $"BILL-{DateTime.Now:yyMM}-";
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(companyId, prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
next = num + 1;
return $"{prefix}{next:D4}";
}
/// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> soft-deleted
/// records are included in the scan so payment numbers are never reused.
/// </summary>
private async Task<string> GeneratePaymentNumberAsync()
{
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(companyId, prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
next = num + 1;
return $"{prefix}{next:D4}";
}
// -- Receipt File: Download / Remove -------------------------------------
/// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
/// receipts (which are displayed inline for images), all bill receipt downloads are served
/// as attachments to ensure the browser prompts for save rather than opening in a tab.
/// </summary>
[HttpGet]
public async Task<IActionResult> DownloadReceipt(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
if (bill == null || string.IsNullOrEmpty(bill.ReceiptFilePath)) return NotFound();
var result = await _blobStorage.DownloadAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
if (!result.Success) return NotFound();
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
var contentType = BlobFileHelper.GetContentType(ext);
var fileName = $"receipt-{bill.BillNumber}{ext}";
return File(result.Content, contentType, fileName);
}
/// <summary>
/// Detaches and permanently deletes the blob-stored receipt file from a bill. The blob is
/// deleted from Azure Blob Storage before the database reference is cleared, so there is no
/// window where the UI shows a broken attachment link.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
if (bill == null) return NotFound();
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
{
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
bill.ReceiptFilePath = null;
await _unitOfWork.CompleteAsync();
}
TempData["Success"] = "Receipt attachment removed.";
return RedirectToAction(nameof(Details), new { id });
}
// -- AI: Receipt Scanning -------------------------------------------------
/// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
/// the raw bytes to <see cref="IAccountingAiService.ScanReceiptAsync"/> (Anthropic Claude
/// Sonnet 4.6), and returns structured JSON containing extracted fields: vendor name, date,
/// amount, and suggested line-item account mappings. The caller's JS wizard uses the response
/// to pre-fill the bill Create form. Rate-limited to the <c>Ai</c> policy to control
/// Anthropic API costs. The available expense account list is passed with the request so the
/// model can match categories to the company's specific chart of accounts.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{
if (receiptImage == null || receiptImage.Length == 0)
return Json(new { success = false, error = "No file uploaded." });
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "application/pdf" };
if (!allowedTypes.Contains(receiptImage.ContentType.ToLowerInvariant()))
return Json(new { success = false, error = "Supported formats: JPG, PNG, GIF, WebP, or PDF." });
if (receiptImage.Length > 10 * 1024 * 1024)
return Json(new { success = false, error = "File must be under 10 MB." });
// Load expense accounts for matching
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var expenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)
.Select(a => new AccountSummary
{
Id = a.Id,
AccountNumber = a.AccountNumber,
Name = a.Name,
AccountType = a.AccountType.ToString(),
AccountSubType = a.AccountSubType.ToString()
})
.ToList();
using var ms = new MemoryStream();
await receiptImage.CopyToAsync(ms);
var imageBytes = ms.ToArray();
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
return Json(result);
}
// -- AI: Account Suggestion ------------------------------------------------
/// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not
/// supply <c>AvailableAccounts</c> (e.g. on first load) the controller fetches the active
/// expense/COGS/asset accounts from the database and adds them to the request before
/// forwarding to <see cref="IAccountingAiService.SuggestAccountAsync"/>. This lazy-load
/// approach lets the JS call this endpoint on blur without requiring the view to embed the
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{
if (request == null)
return Json(new { success = false, error = "Invalid request." });
// Load expense accounts if not supplied
if (!request.AvailableAccounts.Any())
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)
.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 companyId2 = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid2) ? cid2 : 0;
var userId2 = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId2, userId2, AppConstants.AiFeatures.AccountSuggest, inputLength: (request.Description?.Length ?? 0));
return Json(result);
}
// -- AI: Recurring Bill Detection ------------------------------------------
/// <summary>
/// GET page displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunRecurringDetection()
{
try
{
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList();
if (!bills.Any())
return Json(new RecurringBillDetectionResult
{
Success = true,
Insights = new List<string> { "No bill history found in the last 12 months." }
});
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
var request = new RecurringBillDetectionRequest
{
CompanyName = companyName,
Bills = bills.Select(b => new RecurringBillHistoryItem
{
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
BillNumber = b.BillNumber,
Amount = b.Total,
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
Memo = b.Memo
}).ToList()
};
var result = await _accountingAi.DetectRecurringBillsAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running recurring bill detection");
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
}
}
// -- Receipt File Helpers --------------------------------------------------
/// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path
/// <c>{companyId}/bill-receipts/{billId}/{guid}{ext}</c>. The GUID component prevents
/// name collisions when multiple receipts are uploaded during a single edit session. Returns
/// the blob name (relative path) to be stored on the <c>Bill.ReceiptFilePath</c> field, or
/// <c>null</c> when the upload fails so the caller can surface a non-fatal warning.
/// </summary>
private async Task<string?> UploadReceiptAsync(IFormFile file, int billId, int companyId)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
return result.Success ? blobName : null;
}
}