1cb7a8ca4a
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>
417 lines
20 KiB
C#
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;
|
|
}
|
|
}
|