Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/GiftCertificatesController.cs
T
spouliot 38748c2152 Add BatchId to GiftCertificate for persistent bulk batch tracking
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.

Migration: AddGiftCertificateBatchId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:32:56 -04:00

632 lines
28 KiB
C#

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;
/// <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;
private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public GiftCertificatesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager,
IPdfService pdfService,
ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_userManager = userManager;
_pdfService = pdfService;
_logoService = logoService;
_accountBalanceService = accountBalanceService;
}
/// <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,
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);
}
/// <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();
// 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);
}
}
/// <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 });
}
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));
}
/// <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 (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 });
}
}
/// <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;
}
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}
/// <summary>
/// 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.
/// </summary>
public IActionResult BulkCreate()
{
return View(new BulkCreateGiftCertificateDto());
}
/// <summary>
/// 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).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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());
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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);
}
}