38748c2152
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>
632 lines
28 KiB
C#
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);
|
|
}
|
|
}
|