Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,393 @@
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;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
public class GiftCertificatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<GiftCertificatesController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPdfService _pdfService;
public GiftCertificatesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager,
IPdfService pdfService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_userManager = userManager;
_pdfService = pdfService;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<GiftCertificateStatus>(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
})
.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);
}
/// <summary>
/// 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 <c>RecipientName</c> field when no customer record
/// is linked, supporting gift certificates issued to non-customers (e.g. promotional give-aways).
/// </summary>
public async Task<IActionResult> 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);
}
/// <summary>
/// Renders the Create form with the customer dropdown pre-populated for optional recipient and
/// purchaser selection.
/// </summary>
public async Task<IActionResult> Create()
{
await PopulateCustomersAsync();
return View(new CreateGiftCertificateDto());
}
/// <summary>
/// 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 <c>Sold</c>; 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 <c>RedeemedAmount = 0</c> — redemptions
/// are recorded separately via invoice payment flow.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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();
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);
}
}
/// <summary>
/// Voids a gift certificate, permanently cancelling any remaining balance. Requires
/// <c>CompanyAdminOnly</c> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> 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 });
}
cert.Status = GiftCertificateStatus.Voided;
cert.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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 pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, company?.LogoData, company?.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 });
}
}
/// <summary>
/// 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. <c>ignoreQueryFilters: true</c> 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.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// Populates <c>ViewBag.Customers</c> 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.
/// </summary>
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;
}
}