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 _userManager; private readonly ILogger _logger; public DepositsController( IUnitOfWork unitOfWork, UserManager userManager, ILogger logger) { _unitOfWork = unitOfWork; _userManager = userManager; _logger = logger; } // ----------------------------------------------------------------------- // POST: /Deposits/Record — AJAX call from Job/Quote Details modal // ----------------------------------------------------------------------- /// /// Records a customer deposit via AJAX from the Job Details or Quote Details modal. A deposit /// must be linked to at least one of or /// so it can be matched to a specific obligation. The deposit is stored as unapplied /// (AppliedToInvoiceId is null) until InvoicesController auto-applies it when /// creating the invoice, at which point AppliedToInvoiceId 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 DEP-YYMM-#### is generated via /// using IgnoreQueryFilters to prevent /// number reuse after soft deletion. /// [HttpPost, ValidateAntiForgeryToken] public async Task 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, 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 // ----------------------------------------------------------------------- /// /// Soft-deletes a deposit via AJAX. A deposit that has already been applied to an invoice /// (AppliedToInvoiceId is not null) cannot be deleted because removing it would make /// the invoice's payment history inconsistent — the invoice AmountPaid 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. /// [HttpPost, ValidateAntiForgeryToken] public async Task 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 // ----------------------------------------------------------------------- /// /// 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 IPdfService — 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 /// CompanyPreferences.InAccentColor) for branding; if no colour is configured it /// falls back to Colors.Blue.Darken2. A company-isolation check guards against /// cross-tenant access; SuperAdmins bypass this check. /// public async Task 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 // ----------------------------------------------------------------------- /// /// Generates a monotonically increasing deposit receipt number in the format /// DEP-YYMM-#### (e.g. DEP-2604-0001 for April 2026). Scoped to the company /// so each tenant has an independent sequence. Uses IgnoreQueryFilters() to include /// soft-deleted records in the scan and prevent number reuse. /// private async Task 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}"; } /// /// Maps a enum value to the user-facing display string shown on /// receipts and in the AJAX response. Uses exhaustive switch-expression rather than /// .ToString() so that internal enum names (e.g. CreditDebitCard) can be /// displayed in a more readable form (e.g. "Credit / Debit Card"). /// 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() }; /// /// 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 CompanyPreferences.InAccentColor hex /// string; invalid or missing values fall back to QuestPDF's Colors.Blue.Darken2. /// PDF generation is entirely synchronous (QuestPDF is not async) so this method is not async. /// 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(); } /// /// Returns if it is non-empty and starts with # (a valid CSS /// hex colour), otherwise returns . QuestPDF requires the leading /// hash, so this guard prevents colour values imported without it from breaking the PDF. /// private static string ResolveColor(string? hex, string fallback) { if (string.IsNullOrWhiteSpace(hex)) return fallback; return hex.StartsWith("#") ? hex : fallback; } }