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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user