using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.DTOs.GiftCertificate; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Helpers; using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType; namespace PowderCoating.Web.Controllers; /// /// Manages gift certificate issuance, redemption tracking, voiding, and PDF generation. /// Gift certificates progress through five statuses: Active → PartiallyRedeemed → FullyRedeemed /// (automatic via invoice redemption), or Active/PartiallyRedeemed → Voided (admin action), or /// Active → Expired (time-based, detected lazily on Index load). Certificate codes use the /// GC-YYMM-#### format scoped per company, matching the INV- and DEP- numbering conventions /// across the billing module. Redemptions are recorded separately as child records so the full /// redemption history survives even if the parent certificate is voided. /// [Authorize(Policy = AppConstants.Policies.CanManageInvoices)] public class GiftCertificatesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly ILogger _logger; private readonly UserManager _userManager; private readonly IPdfService _pdfService; private readonly ICompanyLogoService _logoService; private readonly IAccountBalanceService _accountBalanceService; public GiftCertificatesController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, UserManager userManager, IPdfService pdfService, ICompanyLogoService logoService, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _userManager = userManager; _pdfService = pdfService; _logoService = logoService; _accountBalanceService = accountBalanceService; } /// /// Displays the gift certificate list with optional search and status filtering, and lazily /// transitions any Active certificates that have passed their expiry date to Expired status. /// Lazy expiration is used rather than a background job or DB trigger because the certificate /// volume is low and the mutation is idempotent; this ensures the list is always self-consistent /// without requiring a separate scheduler. Stats show only certificates with spendable balances /// (Active or PartiallyRedeemed) so the total outstanding liability is immediately visible. /// public async Task Index(string? searchTerm, string? statusFilter) { var certs = await _unitOfWork.GiftCertificates.FindAsync( gc => true, false, gc => gc.RecipientCustomer, gc => gc.PurchasingCustomer); // Expire any Active certs past their expiry date var now = DateTime.UtcNow; foreach (var cert in certs.Where(c => c.Status == GiftCertificateStatus.Active && c.ExpiryDate.HasValue && c.ExpiryDate.Value < now)) { cert.Status = GiftCertificateStatus.Expired; await _unitOfWork.GiftCertificates.UpdateAsync(cert); } await _unitOfWork.CompleteAsync(); if (!string.IsNullOrWhiteSpace(searchTerm)) { var term = searchTerm.Trim().ToLower(); certs = certs.Where(gc => gc.CertificateCode.ToLower().Contains(term) || (gc.RecipientName?.ToLower().Contains(term) ?? false) || (gc.RecipientCustomer?.CompanyName?.ToLower().Contains(term) ?? false) || (gc.RecipientEmail?.ToLower().Contains(term) ?? false) ).ToList(); } if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse(statusFilter, out var parsedStatus)) certs = certs.Where(gc => gc.Status == parsedStatus).ToList(); var dtos = certs .OrderByDescending(gc => gc.IssueDate) .Select(gc => new GiftCertificateListDto { Id = gc.Id, CertificateCode = gc.CertificateCode, OriginalAmount = gc.OriginalAmount, RedeemedAmount = gc.RedeemedAmount, RemainingBalance = gc.RemainingBalance, RecipientName = gc.RecipientCustomer != null ? (gc.RecipientCustomer.CompanyName ?? $"{gc.RecipientCustomer.ContactFirstName} {gc.RecipientCustomer.ContactLastName}".Trim()) : gc.RecipientName, RecipientEmail = gc.RecipientEmail, IssuedReason = gc.IssuedReason, Status = gc.Status, IssueDate = gc.IssueDate, ExpiryDate = gc.ExpiryDate, BatchId = gc.BatchId }) .ToList(); ViewBag.SearchTerm = searchTerm; ViewBag.StatusFilter = statusFilter; ViewBag.TotalActive = dtos.Count(d => d.Status == GiftCertificateStatus.Active || d.Status == GiftCertificateStatus.PartiallyRedeemed); ViewBag.TotalValue = dtos.Where(d => d.Status == GiftCertificateStatus.Active || d.Status == GiftCertificateStatus.PartiallyRedeemed) .Sum(d => d.RemainingBalance); return View(dtos); } /// /// Shows full certificate detail including all non-deleted redemption records, each annotated with /// the human-readable invoice number. Invoice numbers are resolved in a loop rather than a join /// because the redemption DTO is constructed manually (not via AutoMapper) to avoid over-fetching /// invoice data; the loop is bounded by the number of redemptions which is typically very small. /// Recipient name falls back to the free-text RecipientName field when no customer record /// is linked, supporting gift certificates issued to non-customers (e.g. promotional give-aways). /// public async Task Details(int id) { var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id, false, gc => gc.RecipientCustomer, gc => gc.PurchasingCustomer, gc => gc.IssuedBy, gc => gc.Redemptions); if (cert == null) return NotFound(); var dto = new GiftCertificateDto { Id = cert.Id, CertificateCode = cert.CertificateCode, OriginalAmount = cert.OriginalAmount, RedeemedAmount = cert.RedeemedAmount, RemainingBalance = cert.RemainingBalance, RecipientCustomerId = cert.RecipientCustomerId, RecipientName = cert.RecipientCustomer != null ? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim()) : cert.RecipientName, RecipientEmail = cert.RecipientEmail, IssuedReason = cert.IssuedReason, PurchasePrice = cert.PurchasePrice, PurchasingCustomerId = cert.PurchasingCustomerId, PurchasingCustomerName = cert.PurchasingCustomer != null ? (cert.PurchasingCustomer.CompanyName ?? $"{cert.PurchasingCustomer.ContactFirstName} {cert.PurchasingCustomer.ContactLastName}".Trim()) : null, Status = cert.Status, IssueDate = cert.IssueDate, ExpiryDate = cert.ExpiryDate, Notes = cert.Notes, IssuedByName = cert.IssuedBy != null ? $"{cert.IssuedBy.FirstName} {cert.IssuedBy.LastName}".Trim() : null }; dto.Redemptions = cert.Redemptions .Where(r => !r.IsDeleted) .OrderByDescending(r => r.RedeemedDate) .Select(r => new GiftCertificateRedemptionDto { Id = r.Id, GiftCertificateId = r.GiftCertificateId, InvoiceId = r.InvoiceId, AmountRedeemed = r.AmountRedeemed, RedeemedDate = r.RedeemedDate }).ToList(); foreach (var redemption in dto.Redemptions) { var inv = await _unitOfWork.Invoices.GetByIdAsync(redemption.InvoiceId); if (inv != null) redemption.InvoiceNumber = inv.InvoiceNumber; } return View(dto); } /// /// Renders the Create form with the customer dropdown pre-populated for optional recipient and /// purchaser selection. /// public async Task Create() { await PopulateCustomersAsync(); return View(new CreateGiftCertificateDto()); } /// /// Issues a new gift certificate with an auto-generated GC-YYMM-#### code and sets its initial /// status to Active. Purchase price and purchasing customer are only recorded when the issued /// reason is Sold; for goodwill, promotional, and refund certificates there is no /// associated sale transaction, so those fields are explicitly nulled to prevent misleading /// financial data. The certificate is always created with RedeemedAmount = 0 — redemptions /// are recorded separately via invoice payment flow. /// [HttpPost, ValidateAntiForgeryToken] public async Task Create(CreateGiftCertificateDto dto) { if (!ModelState.IsValid) { await PopulateCustomersAsync(); return View(dto); } try { var currentUser = await _userManager.GetUserAsync(User); var companyId = currentUser?.CompanyId ?? 0; var code = await GenerateCertificateCodeAsync(companyId); var cert = new GiftCertificate { CertificateCode = code, OriginalAmount = dto.Amount, RedeemedAmount = 0, RecipientCustomerId = dto.RecipientCustomerId, RecipientName = dto.RecipientName, RecipientEmail = dto.RecipientEmail, IssuedReason = dto.IssuedReason, PurchasePrice = dto.IssuedReason == GiftCertificateIssuedReason.Sold ? dto.PurchasePrice : null, PurchasingCustomerId = dto.IssuedReason == GiftCertificateIssuedReason.Sold ? dto.PurchasingCustomerId : null, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow, ExpiryDate = dto.ExpiryDate, Notes = dto.Notes, IssuedById = currentUser?.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser?.Email }; await _unitOfWork.GiftCertificates.AddAsync(cert); await _unitOfWork.CompleteAsync(); // GL: CR Gift Certificate Liability (2500) for the face value. // Debit side varies by reason: // Sold → DR Checking (received cash outside invoice flow) // Others → DR Sales Discounts 4950 (promotional/goodwill cost) var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId); await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount); if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) { var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking || a.AccountSubType == AccountSubTypeEnum.Cash)); await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount); } else { var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountNumber == "4950"); await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount); } TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully."; return RedirectToAction(nameof(Details), new { id = cert.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating gift certificate"); this.ToastError("An error occurred creating the gift certificate."); await PopulateCustomersAsync(); return View(dto); } } /// /// Voids a gift certificate, permanently cancelling any remaining balance. Requires /// CompanyAdminOnly because voiding has revenue implications — it eliminates a liability /// the company owes to the certificate holder. Voiding a FullyRedeemed certificate is blocked /// because it would have no practical effect (the balance is already zero) and could create /// confusion in audit logs. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Void(int id) { var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id); if (cert == null) return NotFound(); if (cert.Status == GiftCertificateStatus.FullyRedeemed) { TempData["Error"] = "Cannot void a fully redeemed gift certificate."; return RedirectToAction(nameof(Details), new { id }); } var remaining = cert.RemainingBalance; cert.Status = GiftCertificateStatus.Voided; cert.UpdatedAt = DateTime.UtcNow; await _unitOfWork.GiftCertificates.UpdateAsync(cert); await _unitOfWork.CompleteAsync(); // GL: DR GC Liability / CR Other Income (breakage — the company keeps the unredeemed amount) if (remaining > 0) { var currentUser = await _userManager.GetUserAsync(User); var companyId = currentUser?.CompanyId ?? 0; var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId); var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome); await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining); await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining); } TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided."; return RedirectToAction(nameof(Index)); } /// /// Generates and streams a branded gift certificate PDF. The DTO is built manually here (rather /// than reusing the Details action's DTO) so that the PDF endpoint is self-contained and does not /// depend on view state from a prior page load. Company logo bytes and content type are passed /// directly to the PDF service so QuestPDF can embed the logo without a second HTTP request. /// The file name includes the certificate code so the downloaded file is immediately identifiable /// in the user's downloads folder. /// public async Task DownloadPdf(int id) { var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id, false, gc => gc.RecipientCustomer, gc => gc.PurchasingCustomer, gc => gc.IssuedBy, gc => gc.Redemptions); if (cert == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); var companyId = currentUser?.CompanyId ?? 0; var company = await _unitOfWork.Companies.GetByIdAsync(companyId); var companyInfo = new Application.DTOs.Company.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 dto = new GiftCertificateDto { Id = cert.Id, CertificateCode = cert.CertificateCode, OriginalAmount = cert.OriginalAmount, RedeemedAmount = cert.RedeemedAmount, RemainingBalance = cert.RemainingBalance, RecipientCustomerId = cert.RecipientCustomerId, RecipientName = cert.RecipientCustomer != null ? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim()) : cert.RecipientName, RecipientEmail = cert.RecipientEmail, IssuedReason = cert.IssuedReason, PurchasePrice = cert.PurchasePrice, PurchasingCustomerId = cert.PurchasingCustomerId, PurchasingCustomerName = cert.PurchasingCustomer != null ? (cert.PurchasingCustomer.CompanyName ?? $"{cert.PurchasingCustomer.ContactFirstName} {cert.PurchasingCustomer.ContactLastName}".Trim()) : null, Status = cert.Status, IssueDate = cert.IssueDate, ExpiryDate = cert.ExpiryDate, Notes = cert.Notes, IssuedByName = cert.IssuedBy != null ? $"{cert.IssuedBy.FirstName} {cert.IssuedBy.LastName}".Trim() : null }; try { var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, logoData, logoContentType, companyInfo); return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf"); } catch (Exception ex) { _logger.LogError(ex, "Error generating gift certificate PDF for {Code}", cert.CertificateCode); TempData["Error"] = "Could not generate PDF."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Generates the next sequential GC-YYMM-#### certificate code for the given company. The /// sequence resets each calendar month (the YYMM component changes) which keeps codes short and /// readable. ignoreQueryFilters: true is passed so soft-deleted certificates from the same /// month are included in the max calculation, preventing number reuse after a certificate is /// deleted. The zero-padded 4-digit suffix supports up to 9999 certificates per company per month /// before overflow. /// private async Task GenerateCertificateCodeAsync(int companyId) { var prefix = $"GC-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var companyCerts = await _unitOfWork.GiftCertificates.FindAsync( c => c.CompanyId == companyId && c.CertificateCode.StartsWith(prefix), true); var maxNum = companyCerts .Select(c => { int.TryParse(c.CertificateCode.Replace(prefix, ""), out int n); return n; }) .DefaultIfEmpty(0).Max(); return $"{prefix}{(maxNum + 1):D4}"; } /// /// Populates ViewBag.Customers with active customers for the recipient and purchaser /// dropdowns. A "— None —" placeholder is prepended because both fields are optional: a gift /// certificate can be issued to a non-customer recipient (no customer record exists) and a /// goodwill/promotional certificate has no purchaser at all. /// private async Task PopulateCustomersAsync() { var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive); var list = customers .OrderBy(c => c.CompanyName ?? c.ContactFirstName) .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim() }) .ToList(); list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" }); ViewBag.Customers = list; } /// Returns the Gift Certificate Liability account ID (account 2500) for the company. private async Task GetGcLiabilityAccountIdAsync(int companyId) { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountNumber == "2500"); return acct?.Id; } /// /// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates /// since the primary use case is car shows and events where a batch of same-value certificates /// is distributed to attendees. /// public IActionResult BulkCreate() { return View(new BulkCreateGiftCertificateDto()); } /// /// Creates N gift certificates in a single batch, records GL entries for each, then redirects /// to a confirmation page where the user can download the full batch as a single print-ready PDF. /// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g. /// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event. /// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit /// Sales Discounts (4950) and credit GC Liability (2500). /// [HttpPost, ValidateAntiForgeryToken] public async Task BulkCreate(BulkCreateGiftCertificateDto dto) { if (!ModelState.IsValid) return View(dto); try { var currentUser = await _userManager.GetUserAsync(User); var companyId = currentUser?.CompanyId ?? 0; var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId); int? checkingAcctId = null; int? discountAcctId = null; if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking || a.AccountSubType == AccountSubTypeEnum.Cash)); checkingAcctId = acct?.Id; } else { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountNumber == "4950"); discountAcctId = acct?.Id; } var batchId = Guid.NewGuid(); var now = DateTime.UtcNow; for (int i = 0; i < dto.Quantity; i++) { var code = await GenerateCertificateCodeAsync(companyId); var cert = new GiftCertificate { CertificateCode = code, OriginalAmount = dto.Amount, RedeemedAmount = 0, IssuedReason = dto.IssuedReason, Status = GiftCertificateStatus.Active, IssueDate = now, ExpiryDate = dto.ExpiryDate, Notes = dto.Notes, IssuedById = currentUser?.Id, CompanyId = companyId, CreatedAt = now, CreatedBy = currentUser?.Email, BatchId = batchId }; await _unitOfWork.GiftCertificates.AddAsync(cert); await _unitOfWork.CompleteAsync(); await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount); if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount); else await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount); } return RedirectToAction(nameof(BulkResult), new { batchId }); } catch (Exception ex) { _logger.LogError(ex, "Error creating bulk gift certificates"); this.ToastError("An error occurred creating the certificates."); return View(dto); } } /// /// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives /// browser back/refresh — the user can return here any time to re-download the batch PDF. /// public async Task BulkResult(Guid batchId) { if (batchId == Guid.Empty) return RedirectToAction(nameof(Index)); var certs = await _unitOfWork.GiftCertificates.FindAsync( gc => gc.BatchId == batchId, false); if (!certs.Any()) return RedirectToAction(nameof(Index)); return View(certs.OrderBy(c => c.CertificateCode).ToList()); } /// /// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the /// user can bookmark or re-open it at any time after the batch was originally created. /// public async Task BatchDownloadPdf(Guid batchId) { if (batchId == Guid.Empty) return BadRequest(); var currentUser = await _userManager.GetUserAsync(User); var companyId = currentUser?.CompanyId ?? 0; var company = await _unitOfWork.Companies.GetByIdAsync(companyId); var companyInfo = new Application.DTOs.Company.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 certs = await _unitOfWork.GiftCertificates.FindAsync( gc => gc.BatchId == batchId, false, gc => gc.RecipientCustomer); if (!certs.Any()) return NotFound(); var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto { Id = cert.Id, CertificateCode = cert.CertificateCode, OriginalAmount = cert.OriginalAmount, RedeemedAmount = cert.RedeemedAmount, RemainingBalance = cert.RemainingBalance, RecipientName = cert.RecipientCustomer != null ? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim()) : cert.RecipientName, RecipientEmail = cert.RecipientEmail, IssuedReason = cert.IssuedReason, Status = cert.Status, IssueDate = cert.IssueDate, ExpiryDate = cert.ExpiryDate, Notes = cert.Notes }).ToList(); try { var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo); var first = dtos.First().CertificateCode; var last = dtos.Last().CertificateCode; var fileName = dtos.Count == 1 ? $"GiftCertificate-{first}.pdf" : $"GiftCertificates-{first}-to-{last}.pdf"; return File(pdfBytes, "application/pdf", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId); TempData["Error"] = "Could not generate PDF."; return RedirectToAction(nameof(BulkResult), new { batchId }); } } private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) { if (company == null) return (null, null); if (!string.IsNullOrEmpty(company.LogoFilePath)) { var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath); if (ok) return (content, contentType); } return (company.LogoData, company.LogoContentType); } }