Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/DepositsController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

417 lines
20 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class DepositsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
}
// -----------------------------------------------------------------------
// POST: /Deposits/Record — AJAX call from Job/Quote Details modal
// -----------------------------------------------------------------------
/// <summary>
/// Records a customer deposit via AJAX from the Job Details or Quote Details modal. A deposit
/// must be linked to at least one of <paramref name="jobId"/> or <paramref name="quoteId"/>
/// so it can be matched to a specific obligation. The deposit is stored as unapplied
/// (<c>AppliedToInvoiceId</c> is null) until <c>InvoicesController</c> auto-applies it when
/// creating the invoice, at which point <c>AppliedToInvoiceId</c> is set and the deposit can
/// no longer be deleted. Returns a JSON object so the view's JavaScript can append the new
/// row to the deposits table without a full page reload.
/// A receipt number in the format <c>DEP-YYMM-####</c> is generated via
/// <see cref="GenerateReceiptNumberAsync"/> using <c>IgnoreQueryFilters</c> to prevent
/// number reuse after soft deletion.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Record(
int? jobId,
int? quoteId,
int customerId,
decimal amount,
string paymentMethod,
DateTime receivedDate,
string? reference,
string? notes)
{
try
{
if (amount <= 0)
return Json(new { success = false, message = "Amount must be greater than zero." });
if (jobId == null && quoteId == null)
return Json(new { success = false, message = "A Job or Quote must be specified." });
if (!Enum.TryParse<PaymentMethod>(paymentMethod, out var method))
return Json(new { success = false, message = "Invalid payment method." });
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var deposit = new Deposit
{
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
};
await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync();
return Json(new
{
success = true,
depositId = deposit.Id,
receiptNumber = deposit.ReceiptNumber,
amount = deposit.Amount,
paymentMethod = GetPaymentMethodDisplay(method),
receivedDate = deposit.ReceivedDate.ToString("MM/dd/yyyy"),
notes = deposit.Notes,
reference = deposit.Reference
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording deposit for job={JobId} quote={QuoteId}", jobId, quoteId);
return Json(new { success = false, message = "An error occurred recording the deposit." });
}
}
// -----------------------------------------------------------------------
// POST: /Deposits/Delete/5
// -----------------------------------------------------------------------
/// <summary>
/// Soft-deletes a deposit via AJAX. A deposit that has already been applied to an invoice
/// (<c>AppliedToInvoiceId</c> is not null) cannot be deleted because removing it would make
/// the invoice's payment history inconsistent — the invoice <c>AmountPaid</c> field would
/// no longer match the sum of its payment records. Returns JSON so the calling page can
/// remove the row from the UI without a full page reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id, string? returnUrl)
{
try
{
var deposit = await _unitOfWork.Deposits.GetByIdAsync(id);
if (deposit == null) return Json(new { success = false, message = "Deposit not found." });
if (deposit.AppliedToInvoiceId != null)
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
await _unitOfWork.Deposits.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting deposit {DepositId}", id);
return Json(new { success = false, message = "An error occurred deleting the deposit." });
}
}
// -----------------------------------------------------------------------
// GET: /Deposits/Receipt/5 — PDF receipt download
// -----------------------------------------------------------------------
/// <summary>
/// Generates and streams a deposit receipt PDF inline (browser opens rather than downloads).
/// The PDF is built entirely inside this action using QuestPDF directly — deliberately NOT
/// delegated to <c>IPdfService</c> — because the deposit receipt layout is small and
/// self-contained, and adding it to the shared PDF service would require coupling the service
/// to deposit-specific entities. The receipt uses the tenant's accent colour (from
/// <c>CompanyPreferences.InAccentColor</c>) for branding; if no colour is configured it
/// falls back to <c>Colors.Blue.Darken2</c>. A company-isolation check guards against
/// cross-tenant access; SuperAdmins bypass this check.
/// </summary>
public async Task<IActionResult> Receipt(int id)
{
try
{
var deposit = await _unitOfWork.Deposits.GetByIdAsync(id, false,
d => d.Customer,
d => d.Job,
d => d.Quote);
if (deposit == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
// Verify the deposit belongs to the user's company
if (deposit.CompanyId != currentUser.CompanyId && !User.IsInRole("SuperAdmin"))
return Forbid();
var company = await _unitOfWork.Companies.GetByIdAsync(deposit.CompanyId);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == deposit.CompanyId && !p.IsDeleted);
var companyInfo = new CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var pdfBytes = GenerateReceiptPdf(deposit, company?.LogoData, company?.LogoContentType, companyInfo, prefs?.InAccentColor);
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
return File(pdfBytes, "application/pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating receipt for deposit {DepositId}", id);
return StatusCode(500, "An error occurred generating the receipt.");
}
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/// <summary>
/// Generates a monotonically increasing deposit receipt number in the format
/// <c>DEP-YYMM-####</c> (e.g. <c>DEP-2604-0001</c> for April 2026). Scoped to the company
/// so each tenant has an independent sequence. Uses <c>IgnoreQueryFilters()</c> to include
/// soft-deleted records in the scan and prevent number reuse.
/// </summary>
private async Task<string> GenerateReceiptNumberAsync(int companyId)
{
var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber)
.ToList();
var maxNum = 0;
foreach (var num in existing)
{
var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
maxNum = n;
}
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Maps a <see cref="PaymentMethod"/> enum value to the user-facing display string shown on
/// receipts and in the AJAX response. Uses exhaustive switch-expression rather than
/// <c>.ToString()</c> so that internal enum names (e.g. <c>CreditDebitCard</c>) can be
/// displayed in a more readable form (e.g. "Credit / Debit Card").
/// </summary>
private static string GetPaymentMethodDisplay(PaymentMethod method) => method switch
{
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Credit / Debit Card",
PaymentMethod.BankTransferACH => "Bank Transfer / ACH",
PaymentMethod.DigitalPayment => "Digital Payment",
PaymentMethod.StoreCredit => "Store Credit",
_ => method.ToString()
};
/// <summary>
/// Builds the deposit receipt PDF bytes using QuestPDF's fluent API. The layout places the
/// company header band (with logo or text fallback, phone, and email) at the top, followed
/// by a "Received From" / "Applied To" row, a large amount box, optional notes, and a footer
/// note indicating whether the deposit is pending or has been applied to an invoice. The
/// accent colour is taken from the tenant's <c>CompanyPreferences.InAccentColor</c> hex
/// string; invalid or missing values fall back to QuestPDF's <c>Colors.Blue.Darken2</c>.
/// PDF generation is entirely synchronous (QuestPDF is not async) so this method is not async.
/// </summary>
private byte[] GenerateReceiptPdf(
Deposit deposit,
byte[]? logoData,
string? logoContentType,
CompanyInfoDto companyInfo,
string? accentHex)
{
QuestPDF.Settings.License = LicenseType.Community;
var accent = ResolveColor(accentHex, Colors.Blue.Darken2);
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Column(col =>
{
// Header band
col.Item().Background(accent).Padding(16).Row(row =>
{
// Logo or company name
if (logoData != null && logoData.Length > 0)
{
row.ConstantItem(80).Image(logoData).FitArea();
row.RelativeItem().PaddingLeft(12).Column(c =>
{
c.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
c.Item().Text(companyInfo.Phone).FontSize(9).FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
c.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.White);
});
}
else
{
row.RelativeItem().Column(c =>
{
c.Item().Text(companyInfo.CompanyName).FontSize(20).Bold().FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
c.Item().Text(companyInfo.Phone).FontSize(9).FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
c.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.White);
});
}
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("DEPOSIT RECEIPT").FontSize(22).Bold().FontColor(Colors.White);
c.Item().Text(deposit.ReceiptNumber).FontSize(11).FontColor(Colors.White);
c.Item().Text(deposit.ReceivedDate.ToString("MMMM dd, yyyy")).FontSize(9).FontColor(Colors.White);
});
});
col.Item().PaddingTop(24).Row(row =>
{
// Bill To
row.RelativeItem().Column(c =>
{
c.Item().Text("RECEIVED FROM").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Customer.CompanyName ?? $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim()).FontSize(12).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Customer.Email))
c.Item().Text(deposit.Customer.Email).FontSize(9);
if (!string.IsNullOrWhiteSpace(deposit.Customer.Phone))
c.Item().Text(deposit.Customer.Phone).FontSize(9);
});
// Applied To
row.RelativeItem().Column(c =>
{
c.Item().Text("APPLIED TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
if (deposit.Job != null)
{
c.Item().Text($"Job #{deposit.Job.JobNumber}").FontSize(11).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Job.Description))
c.Item().Text(deposit.Job.Description).FontSize(9);
}
else if (deposit.Quote != null)
{
c.Item().Text($"Quote #{deposit.Quote.QuoteNumber}").FontSize(11).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Quote.Description))
c.Item().Text(deposit.Quote.Description).FontSize(9);
}
});
});
// Divider
col.Item().PaddingTop(20).PaddingBottom(20)
.LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
// Amount box
col.Item().Background(Colors.Grey.Lighten4).Padding(20).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("AMOUNT RECEIVED").FontSize(10).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text($"{deposit.Amount:C}").FontSize(32).Bold().FontColor(accent);
});
row.ConstantItem(200).AlignRight().Column(c =>
{
c.Item().Text("PAYMENT METHOD").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(GetPaymentMethodDisplay(deposit.PaymentMethod)).FontSize(13);
if (!string.IsNullOrWhiteSpace(deposit.Reference))
{
c.Item().PaddingTop(6).Text("REFERENCE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Reference).FontSize(11);
}
});
});
// Notes
if (!string.IsNullOrWhiteSpace(deposit.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("NOTES").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Notes).FontSize(10);
});
}
// Status note
col.Item().PaddingTop(28).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(12).AlignCenter().Text(text =>
{
text.Span("This deposit will be applied to your invoice when the job is complete. ").FontSize(9).FontColor(Colors.Grey.Darken1);
if (deposit.AppliedToInvoiceId != null)
text.Span("(Applied)").FontSize(9).Bold().FontColor(Colors.Green.Darken2);
else
text.Span("(Pending application)").FontSize(9).Italic().FontColor(Colors.Orange.Darken2);
});
col.Item().PaddingTop(8).AlignCenter()
.Text($"Thank you for your business — {companyInfo.CompanyName}")
.FontSize(9).Italic().FontColor(Colors.Grey.Medium);
});
});
});
return document.GeneratePdf();
}
/// <summary>
/// Returns <paramref name="hex"/> if it is non-empty and starts with <c>#</c> (a valid CSS
/// hex colour), otherwise returns <paramref name="fallback"/>. QuestPDF requires the leading
/// hash, so this guard prevents colour values imported without it from breaking the PDF.
/// </summary>
private static string ResolveColor(string? hex, string fallback)
{
if (string.IsNullOrWhiteSpace(hex)) return fallback;
return hex.StartsWith("#") ? hex : fallback;
}
}