Refactor: centralize accounting helpers, status constants, and query deduplication

- AccountingDropdownHelper: wired into BillsController and ExpensesController,
  replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
  magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
  AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
  helper; removed duplicate private method from AccountBalanceService and
  LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
  includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
  entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
  ExecuteInTransactionAsync; blob uploads moved after commit to keep
  financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
  GetAllAsync to prefix-filtered FindAsync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:42:39 -04:00
parent edd7389d7d
commit 379b0de885
15 changed files with 394 additions and 359 deletions
@@ -321,84 +321,90 @@ public class BillsController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
Bill? bill = null;
var 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)
// 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 () =>
{
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
li.DisplayOrder = order++;
li.CompanyId = currentUser.CompanyId;
}
bill = _mapper.Map<Bill>(dto);
bill.BillNumber = await GenerateBillNumberAsync();
bill.Status = BillStatus.Open;
bill.CompanyId = currentUser!.CompanyId;
bill.CreatedBy = currentUser.Email;
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
// 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;
}
await _unitOfWork.Bills.AddAsync(bill);
await _unitOfWork.CompleteAsync();
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
// Attach receipt file if provided
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);
else
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
await _unitOfWork.CompleteAsync();
}
// Link bill back to source PO if created from one
if (dto.PurchaseOrderId > 0)
{
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
if (po != null)
{
po.BillId = bill.Id;
po.UpdatedAt = DateTime.UtcNow;
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}";
}
// 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();
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid.";
}
else
{
TempData["Success"] = $"Bill {bill.BillNumber} created.";
}
return RedirectToAction(nameof(Details), new { id = bill.Id });
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)
{