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>
This commit is contained in:
2026-05-14 20:32:56 -04:00
parent 4ec55e7290
commit 38748c2152
8 changed files with 10889 additions and 62 deletions
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; } public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; } public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; } public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
} }
public class GiftCertificateDto : GiftCertificateListDto public class GiftCertificateDto : GiftCertificateListDto
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary> /// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; } public int? SourceInvoiceItemId { get; set; }
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
public Guid? BatchId { get; set; }
// Navigation // Navigation
public virtual Customer? RecipientCustomer { get; set; } public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; } public virtual Customer? PurchasingCustomer { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGiftCertificateBatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BatchId",
table: "GiftCertificates",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchId",
table: "GiftCertificates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
}
}
@@ -3290,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode") b.Property<string>("CertificateCode")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -6708,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6719,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6730,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
IssuedReason = gc.IssuedReason, IssuedReason = gc.IssuedReason,
Status = gc.Status, Status = gc.Status,
IssueDate = gc.IssueDate, IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate ExpiryDate = gc.ExpiryDate,
BatchId = gc.BatchId
}) })
.ToList(); .ToList();
@@ -487,7 +488,7 @@ public class GiftCertificatesController : Controller
discountAcctId = acct?.Id; discountAcctId = acct?.Id;
} }
var createdIds = new List<int>(); var batchId = Guid.NewGuid();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
for (int i = 0; i < dto.Quantity; i++) for (int i = 0; i < dto.Quantity; i++)
@@ -507,12 +508,12 @@ public class GiftCertificatesController : Controller
IssuedById = currentUser?.Id, IssuedById = currentUser?.Id,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = now, CreatedAt = now,
CreatedBy = currentUser?.Email CreatedBy = currentUser?.Email,
BatchId = batchId
}; };
await _unitOfWork.GiftCertificates.AddAsync(cert); await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
createdIds.Add(cert.Id);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount); await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
@@ -521,10 +522,7 @@ public class GiftCertificatesController : Controller
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount); await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
} }
TempData["BulkCertIds"] = System.Text.Json.JsonSerializer.Serialize(createdIds); return RedirectToAction(nameof(BulkResult), new { batchId });
TempData["BulkCertCount"] = dto.Quantity;
TempData["BulkCertAmount"] = dto.Amount.ToString("F2");
return RedirectToAction(nameof(BulkResult));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -535,31 +533,30 @@ public class GiftCertificatesController : Controller
} }
/// <summary> /// <summary>
/// Displays the bulk creation confirmation page with the list of generated certificate codes /// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
/// and a button to download all as a single print-ready PDF. /// browser back/refresh — the user can return here any time to re-download the batch PDF.
/// </summary> /// </summary>
public async Task<IActionResult> BulkResult() public async Task<IActionResult> BulkResult(Guid batchId)
{ {
if (TempData["BulkCertIds"] is not string json) if (batchId == Guid.Empty)
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
var ids = System.Text.Json.JsonSerializer.Deserialize<List<int>>(json) ?? new(); var certs = await _unitOfWork.GiftCertificates.FindAsync(
var certs = await _unitOfWork.GiftCertificates.FindAsync(gc => ids.Contains(gc.Id), false); gc => gc.BatchId == batchId, false);
if (!certs.Any())
return RedirectToAction(nameof(Index));
ViewBag.CertCount = TempData["BulkCertCount"];
ViewBag.CertAmount = TempData["BulkCertAmount"];
ViewBag.CertIds = ids;
return View(certs.OrderBy(c => c.CertificateCode).ToList()); return View(certs.OrderBy(c => c.CertificateCode).ToList());
} }
/// <summary> /// <summary>
/// Generates and streams a single PDF containing one page per certificate in the batch. /// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
/// The certificate IDs are posted as a form array so there is no URL-length limit on batch size. /// user can bookmark or re-open it at any time after the batch was originally created.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
public async Task<IActionResult> BulkDownloadPdf(int[] certIds)
{ {
if (certIds == null || certIds.Length == 0) if (batchId == Guid.Empty)
return BadRequest(); return BadRequest();
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
@@ -578,9 +575,12 @@ public class GiftCertificatesController : Controller
}; };
var certs = await _unitOfWork.GiftCertificates.FindAsync( var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => certIds.Contains(gc.Id), false, gc => gc.BatchId == batchId, false,
gc => gc.RecipientCustomer); gc => gc.RecipientCustomer);
if (!certs.Any())
return NotFound();
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
{ {
Id = cert.Id, Id = cert.Id,
@@ -603,18 +603,18 @@ public class GiftCertificatesController : Controller
{ {
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo); var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
var firstName = dtos.FirstOrDefault()?.CertificateCode ?? "batch"; var first = dtos.First().CertificateCode;
var lastName = dtos.LastOrDefault()?.CertificateCode ?? string.Empty; var last = dtos.Last().CertificateCode;
var fileName = dtos.Count == 1 var fileName = dtos.Count == 1
? $"GiftCertificate-{firstName}.pdf" ? $"GiftCertificate-{first}.pdf"
: $"GiftCertificates-{firstName}-to-{lastName}.pdf"; : $"GiftCertificates-{first}-to-{last}.pdf";
return File(pdfBytes, "application/pdf", fileName); return File(pdfBytes, "application/pdf", fileName);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error generating bulk gift certificate PDF"); _logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
TempData["Error"] = "Could not generate PDF."; TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(BulkResult), new { batchId });
} }
} }
@@ -2,41 +2,35 @@
@using PowderCoating.Core.Enums @using PowderCoating.Core.Enums
@{ @{
ViewData["Title"] = "Batch Created"; ViewData["Title"] = "Batch Gift Certificates";
ViewData["PageIcon"] = "bi-gift"; ViewData["PageIcon"] = "bi-gift";
var count = ViewBag.CertCount as int? ?? Model.Count; var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
var amount = ViewBag.CertAmount as string ?? "0.00"; var count = Model.Count;
var ids = ViewBag.CertIds as List<int> ?? Model.Select(c => c.Id).ToList(); var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
} }
<div class="alert alert-success alert-permanent mb-4"> <div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle-fill me-2"></i> <i class="bi bi-check-circle-fill me-2"></i>
<strong>@count gift certificates created</strong> &mdash; each worth $@amount. <strong>@count gift certificates created</strong> &mdash; each worth @amount.ToString("C").
Download the PDF below to print the full batch. Download the PDF below to print the full batch. This page is bookmarkable &mdash; you can return here any time to re-download.
</div> </div>
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3"> <div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count) <i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]&hellip;</span>
</h5> </h5>
<form asp-action="BulkDownloadPdf" method="post"> <a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
@Html.AntiForgeryToken() <i class="bi bi-file-pdf me-2"></i>Download All as PDF
@foreach (var id in ids) </a>
{
<input type="hidden" name="certIds" value="@id" />
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
</button>
</form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Certificate Code</th> <th class="ps-3">Certificate Code</th>
<th>Face Value</th> <th>Face Value</th>
<th>Issued</th> <th>Issued</th>
<th>Expiry</th> <th>Expiry</th>
@@ -48,7 +42,7 @@
@foreach (var cert in Model) @foreach (var cert in Model)
{ {
<tr> <tr>
<td class="fw-mono fw-semibold">@cert.CertificateCode</td> <td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
<td>@cert.OriginalAmount.ToString("C")</td> <td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td> <td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td> <td>
@@ -58,7 +52,7 @@
</td> </td>
<td><span class="badge bg-success">Active</span></td> <td><span class="badge bg-success">Active</span></td>
<td class="text-end"> <td class="text-end">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary"> <a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF"> <a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
@@ -75,15 +69,8 @@
<a asp-action="Index" class="btn btn-outline-secondary"> <a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates <i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
</a> </a>
<form asp-action="BulkDownloadPdf" method="post"> <a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
@Html.AntiForgeryToken() <i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
@foreach (var id in ids) </a>
{
<input type="hidden" name="certIds" value="@id" />
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
</button>
</form>
</div> </div>
</div> </div>
@@ -80,6 +80,14 @@ else
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace"> <a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
@cert.CertificateCode @cert.CertificateCode
</a> </a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
title="View &amp; download batch">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</td> </td>
<td> <td>
@if (!string.IsNullOrEmpty(cert.RecipientName)) @if (!string.IsNullOrEmpty(cert.RecipientName))
@@ -88,7 +96,7 @@ else
} }
else else
{ {
<span class="text-muted"></span> <span class="text-muted">&mdash;</span>
} }
@if (!string.IsNullOrEmpty(cert.RecipientEmail)) @if (!string.IsNullOrEmpty(cert.RecipientEmail))
{ {