Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/BillsController.cs
T
spouliot feff0fa73d Add Accountant role and CanManageBills/CanManageAccounting permissions
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and
  CanManageAccounting to Policies
- ApplicationUser: add CanManageBills and CanManageAccounting bool fields
- UserManagementDtos: expose new fields in all three DTOs
- ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims
- Program.cs: add CanManageBills and CanManageAccounting policies;
  update CanManageInvoices, CanViewReports, CanManagePurchaseOrders,
  and CanManageVendors to auto-pass for Accountant role
- BillsController: replace CanManageInventory with CanManageBills on
  all write actions (correct policy — bills are not inventory)
- BankReconciliationsController: replace CanManageJobs with
  CanManageAccounting on write actions
- CompanyUsersController: add Accountant to validCompanyRoles (both
  Create/Edit), legacyRole switch, and all permission assignment blocks
- Create/Edit views: add Accountant option to role dropdown; add
  CanManageBills and CanManageAccounting checkboxes; JS auto-checks
  financial permissions when Accountant role is selected
- Migration AddAccountantRolePermissions: adds columns + backfills
  CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:42:53 -04:00

1219 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Services;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.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;
public BillsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<BillsController> logger,
IAccountBalanceService accountBalanceService,
IAccountingAiService accountingAi,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
_accountingAi = accountingAi;
_blobStorage = blobStorage;
_storageSettings = storageSettings.Value;
_usageLogger = usageLogger;
}
// -- 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;
}
// Bills
if (type == null || type == "Bill")
{
var bills = await _unitOfWork.Bills.GetForIndexAsync(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.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.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 apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => 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 bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
if (bill == null) return NotFound();
var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => 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 bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
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 bill = await _unitOfWork.Bills.LoadForEditAsync(id);
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 last = await _unitOfWork.Bills.GetLastBillNumberAsync(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 last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(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 allAccounts = await _unitOfWork.Accounts.FindAsync(a => 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 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.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 allAccounts = await _unitOfWork.Accounts.FindAsync(a => 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;
}
}