Phase 2: Eliminate ApplicationDbContext from domain controllers

Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:20:39 -04:00
parent 80b0e547cc
commit 90bc0d965f
20 changed files with 730 additions and 878 deletions
@@ -2,7 +2,6 @@ using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.Interfaces;
@@ -10,7 +9,6 @@ using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using PowderCoating.Web.Helpers;
@@ -26,7 +24,6 @@ public class InvoicesController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<InvoicesController> _logger;
private readonly IPdfService _pdfService;
private readonly ApplicationDbContext _context;
private readonly ITenantContext _tenantContext;
private readonly INotificationService _notificationService;
private readonly IAccountBalanceService _accountBalanceService;
@@ -37,7 +34,6 @@ public class InvoicesController : Controller
UserManager<ApplicationUser> userManager,
ILogger<InvoicesController> logger,
IPdfService pdfService,
ApplicationDbContext context,
ITenantContext tenantContext,
INotificationService notificationService,
IAccountBalanceService accountBalanceService)
@@ -47,7 +43,6 @@ public class InvoicesController : Controller
_userManager = userManager;
_logger = logger;
_pdfService = pdfService;
_context = context;
_tenantContext = tenantContext;
_notificationService = notificationService;
_accountBalanceService = accountBalanceService;
@@ -256,8 +251,7 @@ public class InvoicesController : Controller
// Check whether this company's plan allows online payments
var company = await _unitOfWork.Companies.GetByIdAsync(_tenantContext.GetCurrentCompanyId() ?? 0);
var planConfig = company == null ? null : await _context.Set<SubscriptionPlanConfig>()
.AsNoTracking()
var planConfig = company == null ? null : await _unitOfWork.SubscriptionPlanConfigs
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
var onlinePaymentsAllowed = company?.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false);
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
@@ -298,12 +292,10 @@ public class InvoicesController : Controller
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var prefs = await _context.CompanyPreferences
.AsNoTracking()
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
var costs = await _context.CompanyOperatingCosts
.AsNoTracking()
var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var dto = new CreateInvoiceDto
@@ -321,9 +313,7 @@ public class InvoicesController : Controller
if (job == null) return NotFound();
// Validate no existing invoice for this job
var existing = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.JobId == jobId.Value && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true);
if (existing != null)
return RedirectToAction(nameof(Details), new { id = existing.Id });
@@ -343,8 +333,8 @@ public class InvoicesController : Controller
: new Dictionary<int, CatalogItem>();
// Fall back to the default revenue account (4000) if a catalog item has no specific account
var defaultRevenueAccount = await _context.Set<Account>()
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.CompanyId == currentUser.CompanyId && !a.IsDeleted);
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// If the job came from a quote, load it so we can use the agreed pricing.
// The quote stores the approved total including oven batch cost and shop supplies —
@@ -352,9 +342,7 @@ public class InvoicesController : Controller
PowderCoating.Core.Entities.Quote? sourceQuote = null;
if (job.QuoteId.HasValue)
{
sourceQuote = await _context.Set<PowderCoating.Core.Entities.Quote>()
.AsNoTracking()
.FirstOrDefaultAsync(q => q.Id == job.QuoteId.Value && !q.IsDeleted);
sourceQuote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value);
}
// Pre-populate from job items
@@ -494,9 +482,7 @@ public class InvoicesController : Controller
// Validate no existing invoice for this job before starting the transaction
if (dto.JobId.HasValue)
{
var existing = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.JobId == dto.JobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true);
if (existing != null)
{
ModelState.AddModelError("", "An invoice already exists for this job.");
@@ -591,11 +577,10 @@ public class InvoicesController : Controller
// Auto-apply any unapplied deposits for this job (and its linked quote)
var job = dto.JobId.HasValue ? await _unitOfWork.Jobs.GetByIdAsync(dto.JobId.Value) : null;
var depositQuery = _context.Set<Deposit>()
.Where(d => !d.IsDeleted && d.CompanyId == currentUser.CompanyId
&& d.AppliedToInvoiceId == null
&& (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)));
pendingDeposits = await depositQuery.ToListAsync();
pendingDeposits = (await _unitOfWork.Deposits.FindAsync(
d => d.AppliedToInvoiceId == null
&& (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))))
.ToList();
foreach (var deposit in pendingDeposits)
{
@@ -908,11 +893,7 @@ public class InvoicesController : Controller
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
}
var notifLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
@@ -1043,11 +1024,7 @@ public class InvoicesController : Controller
}
var paymentNotifLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(paymentNotifLog);
TempData["Success"] = overpayment > 0
@@ -1325,9 +1302,7 @@ public class InvoicesController : Controller
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var existing = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.JobId == jobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true);
if (existing != null)
return RedirectToAction(nameof(Details), new { id = existing.Id });
@@ -1386,11 +1361,7 @@ public class InvoicesController : Controller
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename);
var latestLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
@@ -1420,16 +1391,10 @@ public class InvoicesController : Controller
public async Task<IActionResult> NotificationsSent(int id)
{
var tz = ViewBag.CompanyTimeZone as string;
var raw = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == id)
.OrderByDescending(n => n.SentAt)
.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, n.SentAt })
.ToListAsync();
var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName,
n.Recipient, n.Subject, n.ErrorMessage, n.Message, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id);
var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message,
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
return Json(logs);
}
@@ -1474,32 +1439,24 @@ public class InvoicesController : Controller
}
// Soft-delete line items
var invoiceItems = await _context.InvoiceItems
.Where(ii => ii.InvoiceId == id && !ii.IsDeleted)
.ToListAsync();
var invoiceItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == id);
foreach (var item in invoiceItems)
await _unitOfWork.InvoiceItems.SoftDeleteAsync(item.Id);
// Soft-delete any payments (draft invoices shouldn't have them, but be safe)
var payments = await _context.Payments
.Where(p => p.InvoiceId == id && !p.IsDeleted)
.ToListAsync();
var payments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == id);
foreach (var payment in payments)
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
// Un-apply any deposits that were applied to this invoice so they can be
// re-applied if the invoice is recreated from the same job
var appliedDeposits = await _context.Deposits
.Where(d => d.AppliedToInvoiceId == id && !d.IsDeleted)
.ToListAsync();
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(d => d.AppliedToInvoiceId == id);
foreach (var deposit in appliedDeposits)
{
deposit.AppliedToInvoiceId = null;
deposit.AppliedDate = null;
deposit.UpdatedAt = DateTime.UtcNow;
}
if (appliedDeposits.Any())
await _context.SaveChangesAsync();
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
@@ -1529,36 +1486,11 @@ public class InvoicesController : Controller
// -----------------------------------------------------------------------
/// <summary>
/// Loads a complete invoice aggregate from the DB context (bypasses the generic repository
/// to allow the deep multi-level Include chain). Includes customer, job, preparer, all
/// non-deleted line items with revenue accounts and generated GCs, all non-deleted payments
/// with recorders and deposit accounts, refunds, credit applications, and GC redemptions.
/// Returns null if the invoice doesn't exist or is soft-deleted — callers check for null
/// and return NotFound rather than loading a partial object.
/// Delegates to <see cref="IInvoiceRepository.LoadForViewAsync"/> which expresses the full
/// eight-table include chain. Returns null if not found or soft-deleted.
/// </summary>
private async Task<Invoice?> LoadInvoiceForViewAsync(int id)
{
return await _context.Set<Invoice>()
.Where(i => i.Id == id && !i.IsDeleted)
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.PreparedBy)
.Include(i => i.SalesTaxAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.RevenueAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.GeneratedGiftCertificate)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.RecordedBy)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.DepositAccount)
.Include(i => i.Refunds.Where(r => !r.IsDeleted))
.ThenInclude(r => r.IssuedBy)
.Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
.ThenInclude(ca => ca.CreditMemo)
.Include(i => i.GiftCertificateRedemptions)
.FirstOrDefaultAsync();
}
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
await _unitOfWork.Invoices.LoadForViewAsync(id);
/// <summary>
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
@@ -1737,11 +1669,10 @@ public class InvoicesController : Controller
{
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = await _context.Set<CreditMemo>()
.IgnoreQueryFilters()
.Where(m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix))
var existing = (await _unitOfWork.CreditMemos.FindAsync(
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
.Select(m => m.MemoNumber)
.ToListAsync();
.ToList();
var maxNum = 0;
foreach (var num in existing)
@@ -1764,26 +1695,18 @@ public class InvoicesController : Controller
/// </summary>
private async Task<string> GenerateInvoiceNumberAsync(int companyId)
{
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
.Select(p => new { p.InvoiceNumberPrefix })
.FirstOrDefaultAsync();
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
var invoicePrefix = !string.IsNullOrWhiteSpace(prefs?.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV";
var prefix = $"{invoicePrefix}-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
.Select(i => i.InvoiceNumber)
.ToListAsync();
var last = await _unitOfWork.Invoices.GetLastInvoiceNumberByPrefixAsync(companyId, prefix);
var maxNum = 0;
foreach (var num in existing)
if (last != null && last.Length >= prefix.Length + 4)
{
var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
var suffix = last.Substring(prefix.Length);
if (int.TryParse(suffix, out int n))
maxNum = n;
}
@@ -1802,8 +1725,7 @@ public class InvoicesController : Controller
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
// Expose company default tax rate and exempt customer IDs for client-side tax handling
var costs = await _context.CompanyOperatingCosts
.AsNoTracking()
var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == companyId && !c.IsDeleted);
ViewBag.CompanyTaxPercent = costs?.TaxPercent ?? 0;
ViewBag.TaxExemptCustomerIds = customers
@@ -1812,12 +1734,12 @@ public class InvoicesController : Controller
.ToHashSet();
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
var merchItems = await _context.Set<CatalogItem>()
.Include(i => i.Category)
.Where(i => i.IsMerchandise && i.IsActive && !i.IsDeleted && i.CompanyId == companyId)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
var merchItems = allMerchItems
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
.ToListAsync();
.ToList();
ViewBag.MerchandiseItems = System.Text.Json.JsonSerializer.Serialize(merchItems,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
}
@@ -1845,15 +1767,13 @@ public class InvoicesController : Controller
return accounts.FirstOrDefault()?.Id;
}
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name.</summary>
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
var taxAccount = await _context.Set<Account>()
.Where(a => a.CompanyId == companyId && !a.IsDeleted && a.IsActive)
.OrderBy(a => a.AccountNumber == "2200" ? 0 : 1)
.ThenBy(a => a.AccountNumber)
.FirstOrDefaultAsync(a => a.AccountNumber == "2200"
|| (a.AccountType == AccountType.Liability && a.Name.ToLower().Contains("tax")));
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "2200" && a.IsActive);
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
return taxAccount?.Id;
}
@@ -2371,16 +2291,13 @@ public class InvoicesController : Controller
/// </summary>
private async Task<string?> TryGeneratePaymentTokenAsync(Invoice invoice)
{
var company = await _context.Companies
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
var company = await _unitOfWork.Companies.GetByIdAsync(invoice.CompanyId);
if (company == null) return null;
if (company.StripeConnectStatus != StripeConnectStatus.Active) return null;
if (invoice.BalanceDue <= 0) return null;
var planConfig = await _context.Set<SubscriptionPlanConfig>()
.AsNoTracking()
var planConfig = await _unitOfWork.SubscriptionPlanConfigs
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
var onlinePaymentsAllowed = company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false);
@@ -2405,8 +2322,7 @@ public class InvoicesController : Controller
{
try
{
var invoice = await _context.Invoices
.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
var invoice = await _unitOfWork.Invoices.GetByIdAsync(id);
if (invoice == null) return NotFound();
if (invoice.BalanceDue <= 0)
@@ -2417,8 +2333,8 @@ public class InvoicesController : Controller
return Json(new { success = false, message = "Online payments are not available for this company." });
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
var paymentUrl = Url.Action("Index", "Payment", new { token }, Request.Scheme)!
.Replace("/Payment/Index/", "/pay/");
@@ -2452,27 +2368,11 @@ public class InvoicesController : Controller
var endDate = to ?? startDate.AddMonths(1).AddDays(-1);
var endDateInclusive = endDate.AddDays(1);
var invoices = await _context.Invoices
.AsNoTracking()
.Include(i => i.Customer)
.Where(i => !i.IsDeleted
&& i.CompanyId == companyId
&& i.OnlineAmountPaid > 0
&& i.UpdatedAt >= startDate
&& i.UpdatedAt < endDateInclusive)
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
var invoices = await _unitOfWork.Invoices
.GetOnlineInvoicesForPeriodAsync(companyId, startDate, endDateInclusive);
var refunds = await _context.Refunds
.AsNoTracking()
.Include(r => r.Invoice).ThenInclude(inv => inv!.Customer)
.Where(r => !r.IsDeleted
&& r.CompanyId == companyId
&& r.RefundMethod == PaymentMethod.CreditDebitCard
&& r.RefundDate >= startDate
&& r.RefundDate < endDateInclusive)
.OrderByDescending(r => r.RefundDate)
.ToListAsync();
var refunds = await _unitOfWork.Invoices
.GetOnlineRefundsForPeriodAsync(companyId, startDate, endDateInclusive);
var vm = new OnlinePaymentsViewModel
{