Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user