using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.DTOs.GiftCertificate;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.PurchaseOrder;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PowderCoating.Application.Services;
public class PdfService : IPdfService
{
///
/// Generates a customer-facing quote PDF and returns the raw bytes for download or email attachment.
/// Runs document composition on a background thread via Task.Run because QuestPDF's
/// GeneratePdf() is CPU-bound and would otherwise block an ASP.NET request thread.
/// The parameter drives accent colour, footer note, and default terms;
/// when null a sensible default () is used so callers do not
/// need to guard against missing company preferences.
///
public async Task GenerateQuotePdfAsync(
QuoteDto quoteDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo,
QuoteTemplateSettingsDto? template = null,
byte[]? preparedByPhoto = null)
{
// Configure QuestPDF license (community/commercial)
QuestPDF.Settings.License = LicenseType.Community;
template ??= new QuoteTemplateSettingsDto();
var accentColor = ResolveColor(template.AccentColor, Colors.Grey.Darken2);
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeHeader(c, companyLogo, companyLogoContentType, companyInfo, accentColor));
page.Content().Element(c => ComposeContent(c, quoteDto, preparedByPhoto, template, accentColor));
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
text.Span(" / ");
text.TotalPages();
text.Span($" - {companyInfo.CompanyName}");
if (!string.IsNullOrWhiteSpace(template.FooterNote))
{
text.Span($" | {template.FooterNote}");
}
});
});
});
return document.GeneratePdf();
});
}
///
/// Generates an invoice PDF and returns the raw bytes.
/// Mirrors the structure of but delegates content layout to
/// and , which render the
/// payment history, balance-due totals, and overdue status colouring that are unique to invoices.
/// The accent colour defaults to blue (versus grey for quotes) to give invoices a distinct visual identity.
///
public async Task GenerateInvoicePdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo,
QuoteTemplateSettingsDto? template = null)
{
QuestPDF.Settings.License = LicenseType.Community;
template ??= new QuoteTemplateSettingsDto();
var accentColor = ResolveColor(template.AccentColor, Colors.Blue.Darken2);
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Layers(layers =>
{
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
if (invoiceDto.Status == InvoiceStatus.Paid)
layers.Layer().Element(c => ComposePaidStamp(c));
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
text.Span(" / ");
text.TotalPages();
text.Span($" - {companyInfo.CompanyName}");
if (!string.IsNullOrWhiteSpace(template.FooterNote))
{
text.Span($" | {template.FooterNote}");
}
});
});
});
return document.GeneratePdf();
});
}
///
/// Composes the invoice page header: a two-column row with company branding on the left and
/// invoice metadata (number, date, due date, status) on the right, separated from the body by
/// a full-width accent-coloured rule. Due dates are rendered in red when the invoice is overdue,
/// giving accountants an immediate visual cue without opening the web application.
///
private void ComposeInvoiceHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
column.Item().MaxHeight(60).Image(companyLogo);
else
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(9).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
column.Item().Text($"# {invoice.InvoiceNumber}").FontSize(12).Bold();
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
if (invoice.DueDate.HasValue)
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
});
}
///
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
/// external Skia/SkiaSharp dependency is needed.
///
private static void ComposePaidStamp(IContainer container)
{
container
.AlignCenter()
.AlignMiddle()
.Rotate(-45f)
.Border(5)
.BorderColor(Colors.Green.Darken2)
.PaddingVertical(14)
.PaddingHorizontal(28)
.Text("PAID")
.FontSize(80)
.Bold()
.FontColor(Colors.Green.Darken2);
}
///
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
/// amount-paid, and balance-due rows. Terms fall back to the company template's
/// when the invoice itself has none, keeping
/// every PDF self-contained for the customer even if terms were added to the template after the
/// invoice was created.
///
private void ComposeInvoiceContent(IContainer container, InvoiceDto invoice, string accentColor, QuoteTemplateSettingsDto template)
{
container.Column(col =>
{
// Bill To
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("BILL TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerName).Bold();
if (!string.IsNullOrWhiteSpace(invoice.CustomerEmail))
c.Item().Text(invoice.CustomerEmail).FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
c.Item().Text(invoice.CustomerPhone).FontSize(9);
});
row.RelativeItem().Column(c =>
{
c.Item().Text("JOB REFERENCE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text($"Job #: {invoice.JobNumber}");
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
c.Item().Text($"PO #: {invoice.CustomerPO}");
});
});
// Line items header
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
h.Cell().Background(accentColor).Padding(4).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(4).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(4).AlignRight().Text("Unit Price").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(4).AlignRight().Text("Total").FontColor(Colors.White).Bold().FontSize(9);
});
var rowAlt = false;
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(4).Column(c =>
{
c.Item().Text(item.Description).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.ColorName))
c.Item().Text(item.ColorName).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(4).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
table.Cell().Background(bg).Padding(4).AlignRight().Text(item.UnitPrice.ToString("C")).FontSize(9);
table.Cell().Background(bg).Padding(4).AlignRight().Text(item.TotalPrice.ToString("C")).FontSize(9);
rowAlt = !rowAlt;
}
});
// Totals
col.Item().PaddingTop(8).AlignRight().Column(c =>
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Row(rr =>
{
rr.RelativeItem().Text("Subtotal:").FontSize(9);
rr.ConstantItem(80).AlignRight().Text(invoice.SubTotal.ToString("C")).FontSize(9);
});
});
if (invoice.DiscountAmount > 0)
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Row(rr =>
{
rr.RelativeItem().Text("Discount:").FontSize(9).FontColor(Colors.Green.Darken1);
rr.ConstantItem(80).AlignRight().Text($"({invoice.DiscountAmount:C})").FontSize(9).FontColor(Colors.Green.Darken1);
});
});
}
if (invoice.TaxPercent > 0)
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Row(rr =>
{
rr.RelativeItem().Text($"Tax ({invoice.TaxPercent:G}%):").FontSize(9);
rr.ConstantItem(80).AlignRight().Text(invoice.TaxAmount.ToString("C")).FontSize(9);
});
});
}
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Background(accentColor).Padding(4).Row(rr =>
{
rr.RelativeItem().Text("TOTAL:").FontSize(10).Bold().FontColor(Colors.White);
rr.ConstantItem(80).AlignRight().Text(invoice.Total.ToString("C")).FontSize(10).Bold().FontColor(Colors.White);
});
});
if (invoice.AmountPaid > 0)
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Row(rr =>
{
rr.RelativeItem().Text("Amount Paid:").FontSize(9).FontColor(Colors.Green.Darken1);
rr.ConstantItem(80).AlignRight().Text(invoice.AmountPaid.ToString("C")).FontSize(9).FontColor(Colors.Green.Darken1);
});
});
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(150).Row(rr =>
{
rr.RelativeItem().Text("Balance Due:").FontSize(10).Bold();
rr.ConstantItem(80).AlignRight().Text(invoice.BalanceDue.ToString("C")).FontSize(10).Bold();
});
});
}
});
// Notes
if (!string.IsNullOrWhiteSpace(invoice.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("Notes").Bold().FontSize(9);
c.Item().Text(invoice.Notes).FontSize(9);
});
}
var termsToShow = !string.IsNullOrWhiteSpace(invoice.Terms) ? invoice.Terms : template.DefaultTerms;
if (!string.IsNullOrWhiteSpace(termsToShow))
{
col.Item().PaddingTop(8).Column(c =>
{
c.Item().Text("Payment Terms").Bold().FontSize(9);
c.Item().Text(termsToShow).FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
});
}
///
/// Validates and normalises a hex colour string for use with QuestPDF's string-based colour API.
/// QuestPDF accepts CSS-style hex strings ("#RRGGBB") but does not validate them at call-time;
/// passing a malformed value silently produces black. This method strips a leading '#', confirms
/// exactly six valid hexadecimal characters, and returns the normalised string — or the
/// constant when the input is absent or malformed.
///
private static string ResolveColor(string? hex, string fallback)
{
if (string.IsNullOrWhiteSpace(hex)) return fallback;
// Validate: must be a 6-digit hex color
var h = hex.TrimStart('#');
if (h.Length == 6 && h.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')))
return "#" + h;
return fallback;
}
///
/// Formats a raw phone string as (XXX) XXX-XXXX for US ten-digit numbers, stripping
/// all non-numeric characters first. Inputs that are not exactly ten digits are returned as-is
/// to preserve international or extension-bearing numbers stored in the database without
/// corrupting them on the printed document.
///
private string FormatPhoneNumber(string? phone)
{
if (string.IsNullOrWhiteSpace(phone)) return string.Empty;
// Remove all non-numeric characters
var digits = new string(phone.Where(char.IsDigit).ToArray());
// Format as (XXX) XXX-XXXX for 10-digit numbers
if (digits.Length == 10)
{
return $"({digits.Substring(0, 3)}) {digits.Substring(3, 3)}-{digits.Substring(6, 4)}";
}
// Return original if not 10 digits
return phone;
}
///
/// Composes the shared quote-page header used by .
/// Renders the company logo (passed as raw bytes so this service has no file-system dependency)
/// at a capped height of 60pt on the left, and formatted contact details on the right.
/// When no logo is provided the company name is rendered in the accent colour instead, so the
/// header always looks intentional whether or not a logo has been uploaded.
///
private void ComposeHeader(IContainer container, byte[]? companyLogo, string? companyLogoContentType, CompanyInfoDto companyInfo, string accentColor)
{
container.Row(row =>
{
// Left: Company logo or name
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
{
column.Item().MaxHeight(60).Image(companyLogo);
}
else
{
column.Item().Text(companyInfo.CompanyName)
.FontSize(18)
.Bold()
.FontColor(accentColor);
}
});
// Right: Company contact info
row.RelativeItem().AlignRight().Column(column =>
{
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(9);
var cityStateZip = new List();
if (!string.IsNullOrWhiteSpace(companyInfo.City)) cityStateZip.Add(companyInfo.City);
if (!string.IsNullOrWhiteSpace(companyInfo.State)) cityStateZip.Add(companyInfo.State);
if (!string.IsNullOrWhiteSpace(companyInfo.ZipCode)) cityStateZip.Add(companyInfo.ZipCode);
if (cityStateZip.Any())
column.Item().Text(string.Join(", ", cityStateZip)).FontSize(9);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9);
});
});
}
///
/// Composes the full body of the quote PDF below the header.
/// Orchestrates the sequence of sub-composers: customer/prospect info and quote metadata in a
/// two-column row, line-items table, right-aligned pricing summary, and optional terms and notes
/// sections. Terms fall back to and notes fall
/// back to so the document remains useful even
/// when those fields are blank on the individual quote.
///
private void ComposeContent(IContainer container, QuoteDto quote, byte[]? preparedByPhoto, QuoteTemplateSettingsDto template, string accentColor)
{
container.Column(column =>
{
// Quote number
column.Item().PaddingTop(15).Text($"Quote #: {quote.QuoteNumber}")
.FontSize(12)
.Bold();
column.Item().PaddingTop(10);
// Two-column info section
column.Item().Row(row =>
{
// Left: Customer/Prospect info
row.RelativeItem().Column(leftCol =>
{
leftCol.Item().Text(quote.CustomerId.HasValue ? "CUSTOMER INFORMATION" : "PROSPECT INFORMATION")
.FontSize(11)
.Bold()
.FontColor(accentColor);
leftCol.Item().PaddingTop(5).Element(c => ComposeCustomerInfo(c, quote));
});
row.ConstantItem(20);
// Right: Quote details
row.RelativeItem().Column(rightCol =>
{
rightCol.Item().Text("QUOTE DETAILS")
.FontSize(11)
.Bold()
.FontColor(accentColor);
rightCol.Item().PaddingTop(5).Element(c => ComposeQuoteDetails(c, quote));
});
});
column.Item().PaddingTop(15);
// Line items table
column.Item().Element(c => ComposeLineItems(c, quote, accentColor));
column.Item().PaddingTop(10);
// Pricing summary
column.Item().AlignRight().Width(250).Element(c => ComposePricingSummary(c, quote));
// Terms section: use quote's own terms, fall back to company default terms
var termsToShow = !string.IsNullOrWhiteSpace(quote.Terms) ? quote.Terms : template.DefaultTerms;
if (!string.IsNullOrWhiteSpace(termsToShow))
{
column.Item().PaddingTop(15).Element(c => ComposeTerms(c, termsToShow, accentColor));
}
// Description/Notes: use quote's own description, fall back to footer note
var notesToShow = !string.IsNullOrWhiteSpace(quote.Description) ? quote.Description : template.FooterNote;
if (!string.IsNullOrWhiteSpace(notesToShow))
{
column.Item().PaddingTop(10).Element(c => ComposeNotes(c, notesToShow, accentColor));
}
});
}
///
/// Renders the customer or prospect address block in the quote PDF.
/// Branches on : existing customers use the joined
/// CustomerCompanyName / contact name / email / phone fields from the Customer entity,
/// while prospects use the denormalised Prospect* fields stored directly on the quote
/// (because prospects are not yet in the Customers table). This branching ensures the correct
/// data source regardless of how the quote was created.
///
private void ComposeCustomerInfo(IContainer container, QuoteDto quote)
{
container.Column(column =>
{
if (quote.CustomerId.HasValue)
{
// Existing customer
var contactName = new List();
if (!string.IsNullOrWhiteSpace(quote.CustomerContactFirstName)) contactName.Add(quote.CustomerContactFirstName);
if (!string.IsNullOrWhiteSpace(quote.CustomerContactLastName)) contactName.Add(quote.CustomerContactLastName);
var contactFullName = string.Join(" ", contactName);
// Only show company name when it's distinct from the contact's full name.
// Non-commercial customers store the person's name in CompanyName, which would
// otherwise render twice (e.g. "Bryndon Lee" bold + "Bryndon Lee" regular).
if (!string.IsNullOrWhiteSpace(quote.CustomerCompanyName) &&
!string.Equals(quote.CustomerCompanyName.Trim(), contactFullName.Trim(), StringComparison.OrdinalIgnoreCase))
column.Item().Text(quote.CustomerCompanyName).FontSize(10).Bold();
if (contactName.Any())
column.Item().Text(contactFullName).FontSize(9);
if (!string.IsNullOrWhiteSpace(quote.CustomerEmail))
column.Item().Text(quote.CustomerEmail).FontSize(9);
if (!string.IsNullOrWhiteSpace(quote.CustomerPhone))
column.Item().Text(FormatPhoneNumber(quote.CustomerPhone)).FontSize(9);
}
else
{
// Prospect
if (!string.IsNullOrWhiteSpace(quote.ProspectCompanyName))
column.Item().Text(quote.ProspectCompanyName).FontSize(10).Bold();
if (!string.IsNullOrWhiteSpace(quote.ProspectContactName))
column.Item().Text(quote.ProspectContactName).FontSize(9);
if (!string.IsNullOrWhiteSpace(quote.ProspectEmail))
column.Item().Text(quote.ProspectEmail).FontSize(9);
if (!string.IsNullOrWhiteSpace(quote.ProspectPhone))
column.Item().Text(FormatPhoneNumber(quote.ProspectPhone)).FontSize(9);
}
});
}
///
/// Renders the right-hand "Quote Details" metadata panel: quote date, expiration date, sent date,
/// approved date, and customer PO number. Each field is only printed when it has a value, keeping
/// the panel compact for simple quotes and fully informative for quotes that have progressed
/// through the approval workflow.
///
private void ComposeQuoteDetails(IContainer container, QuoteDto quote)
{
container.Column(column =>
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Quote Date:").FontSize(9);
row.RelativeItem().Text(quote.QuoteDate.ToString("MMM dd, yyyy")).FontSize(9);
});
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Valid Until:").FontSize(9);
row.RelativeItem().Text(quote.ExpirationDate?.ToString("MMM dd, yyyy") ?? "N/A").FontSize(9);
});
if (quote.SentDate.HasValue)
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Sent Date:").FontSize(9);
row.RelativeItem().Text(quote.SentDate.Value.ToString("MMM dd, yyyy")).FontSize(9);
});
}
if (quote.ApprovedDate.HasValue)
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Approved:").FontSize(9);
row.RelativeItem().Text(quote.ApprovedDate.Value.ToString("MMM dd, yyyy")).FontSize(9);
});
}
if (!string.IsNullOrWhiteSpace(quote.CustomerPO))
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("PO Number:").FontSize(9);
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
});
}
});
}
///
/// Renders the "Prepared By" card with the sales rep's avatar, name, and email.
/// The avatar is embedded as raw bytes (loaded by the controller from disk before the PDF call)
/// and displayed at 40×40pt; if no photo is available the card still renders without blank space
/// by simply omitting the image column. This component is currently wired into
/// but the call site guards on preparedByPhoto != null.
///
private void ComposePreparedBy(IContainer container, QuoteDto quote, byte[]? preparedByPhoto)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Row(row =>
{
if (preparedByPhoto != null && preparedByPhoto.Length > 0)
{
row.ConstantItem(40, Unit.Point).Height(40).Image(preparedByPhoto);
row.ConstantItem(10, Unit.Point);
}
row.RelativeItem().Column(column =>
{
column.Item().Text("Prepared By").FontSize(8).FontColor(Colors.Grey.Darken1);
column.Item().Text(quote.PreparedByName ?? "N/A").FontSize(10).Bold();
if (!string.IsNullOrWhiteSpace(quote.PreparedByEmail))
column.Item().Text(quote.PreparedByEmail).FontSize(9);
});
});
}
///
/// Renders the quote line-items table with alternating row shading.
/// Each item cell is a sub-column that prints the item description (preferring the explicit
/// description over a default "Product Item" label for catalog items), then iterates the ordered
/// coat list to show colour name or inventory powder name, colour code, and finish on separate
/// sub-rows. The "Color" summary column shows the single colour name for single-coat items or
/// "<n> Coats" for multi-coat items, since repeating all coat names in that narrow column
/// would make the row illegible. Inventory item names are preferred over free-text colour names
/// to match the language used in the job and inventory modules.
///
private void ComposeLineItems(IContainer container, QuoteDto quote, string accentColor)
{
var allItems = quote.QuoteItems?.OrderBy(i => i.Id).ToList() ?? new List();
container.Column(column =>
{
column.Item().Text("LINE ITEMS")
.FontSize(11)
.Bold()
.FontColor(accentColor);
if (allItems.Any())
{
column.Item().PaddingTop(10).Table(table =>
{
// Define columns: Description, Color, Qty, Unit Price, Total
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(4); // Description
columns.RelativeColumn(2); // Color
columns.ConstantColumn(40); // Qty
columns.ConstantColumn(80); // Unit Price
columns.ConstantColumn(80); // Total
});
// Header
table.Header(header =>
{
header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Unit Price").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White);
});
// All items (unified)
var index = 0;
foreach (var item in allItems)
{
var bgColor = index % 2 == 0 ? Colors.White : Colors.Grey.Lighten4;
// Description - show product name for catalog items, or custom description
table.Cell().Background(bgColor).Padding(5).Column(descCol =>
{
string displayDescription;
if (item.CatalogItemId.HasValue)
{
// For catalog items, show product name
displayDescription = item.CatalogItemName ?? "Catalog Item";
// If there's a custom description (not "Product Item"), use that instead
if (!string.IsNullOrWhiteSpace(item.Description) && item.Description != "Product Item")
{
displayDescription = item.Description;
}
}
else
{
// For calculated items, use the description
displayDescription = item.Description ?? "Custom Item";
}
descCol.Item().Text(displayDescription).FontSize(9).Bold();
// Show coating layers if present
if (item.Coats != null && item.Coats.Any())
{
descCol.Item().PaddingTop(2).Text("Coating Layers:")
.FontSize(7)
.FontColor(Colors.Grey.Darken2)
.Italic();
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
var coatText = $" • {coat.CoatName}";
// Show inventory powder name (preferred) or custom color name
if (!string.IsNullOrEmpty(coat.InventoryItemName))
{
coatText += $" - {coat.InventoryItemName}";
}
else if (!string.IsNullOrEmpty(coat.ColorName))
{
coatText += $" - {coat.ColorName}";
}
// Add color code if present
if (!string.IsNullOrEmpty(coat.ColorCode))
{
coatText += $" ({coat.ColorCode})";
}
// Add finish type if present
if (!string.IsNullOrEmpty(coat.Finish))
{
coatText += $" - {coat.Finish}";
}
descCol.Item().Text(coatText)
.FontSize(7)
.FontColor(Colors.Grey.Darken1);
}
}
// Show notes if present
if (!string.IsNullOrWhiteSpace(item.Notes))
{
descCol.Item().PaddingTop(2).Text(item.Notes)
.FontSize(7)
.FontColor(Colors.Grey.Darken1)
.Italic();
}
});
// Color - show count if multiple coats, or first coat color
string colorText;
if (item.Coats == null || !item.Coats.Any())
{
colorText = "-";
}
else if (item.Coats.Count > 1)
{
colorText = $"{item.Coats.Count} Coats";
}
else
{
var firstCoat = item.Coats.First();
// Prefer inventory item name over color name
colorText = firstCoat.InventoryItemName ?? firstCoat.ColorName ?? "-";
}
table.Cell().Background(bgColor).Padding(5).Text(colorText).FontSize(9);
// Quantity (centered)
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9);
// Unit Price (right-aligned)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.UnitPrice:N2}").FontSize(9);
// Total (right-aligned, bold)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold();
index++;
}
});
}
else
{
column.Item().PaddingTop(10).Text("No items in this quote.")
.FontSize(9)
.FontColor(Colors.Grey.Darken1)
.Italic();
}
});
}
///
/// Renders the right-aligned pricing summary box at the bottom of the quote content area.
/// Shows only the rows that are non-zero (shop supplies surcharge, discount, rush fee, tax) to
/// keep the summary clean for simple jobs. The grand total line is styled bold and green to draw
/// the customer's eye to the final price. Uses for the
/// detailed sub-items that are not directly on the top-level DTO.
///
private void ComposePricingSummary(IContainer container, QuoteDto quote)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
// Customer-facing summary (simplified)
if (quote.PricingBreakdown?.ShopSuppliesAmount > 0)
{
column.Item().Row(row =>
{
row.RelativeItem().Text($"Shop Supplies ({quote.PricingBreakdown.ShopSuppliesPercent:F1}%):").FontSize(9);
row.RelativeItem().AlignRight().Text($"${quote.PricingBreakdown.ShopSuppliesAmount:N2}").FontSize(9);
});
}
column.Item().Row(row =>
{
row.RelativeItem().Text("Subtotal:").FontSize(9);
row.RelativeItem().AlignRight().Text($"${quote.SubTotal:N2}").FontSize(9);
});
if (quote.DiscountPercent > 0 && !quote.HideDiscountFromCustomer)
{
column.Item().Row(row =>
{
row.RelativeItem().Text($"Discount ({quote.DiscountPercent:F1}%):").FontSize(9);
row.RelativeItem().AlignRight().Text($"-${quote.DiscountAmount:N2}").FontSize(9).FontColor(Colors.Red.Darken2);
});
}
if (quote.PricingBreakdown?.RushFee > 0)
{
column.Item().Row(row =>
{
row.RelativeItem().Text("Rush Charge:").FontSize(9).FontColor(Colors.Orange.Darken2);
row.RelativeItem().AlignRight().Text($"${quote.PricingBreakdown.RushFee:N2}").FontSize(9).FontColor(Colors.Orange.Darken2);
});
}
if (quote.TaxPercent > 0)
{
column.Item().Row(row =>
{
row.RelativeItem().Text($"Tax ({quote.TaxPercent:F1}%):").FontSize(9);
row.RelativeItem().AlignRight().Text($"${quote.TaxAmount:N2}").FontSize(9);
});
}
column.Item().PaddingVertical(5).LineHorizontal(1).LineColor(Colors.Grey.Darken1);
column.Item().Row(row =>
{
row.RelativeItem().Text("TOTAL:").FontSize(11).Bold();
row.RelativeItem().AlignRight().Text($"${quote.Total:N2}").FontSize(11).Bold().FontColor(Colors.Green.Darken3);
});
});
}
///
/// Renders the "Terms & Conditions" section as a bordered box with the accent-coloured
/// heading and the terms text at 8pt with 1.3× line height for readability of multi-line legal text.
/// The section is only called when is non-empty; the caller
/// () is responsible for the conditional guard.
///
private void ComposeTerms(IContainer container, string terms, string accentColor)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(column =>
{
column.Item().Text("TERMS & CONDITIONS").FontSize(9).Bold().FontColor(accentColor);
column.Item().PaddingTop(3).Text(terms).FontSize(8).LineHeight(1.3f);
});
}
///
/// Renders the "Additional Notes" section, visually identical to but
/// labelled differently. Notes come from the quote's own Description field, falling back
/// to when the quote description is blank.
/// Kept as a separate method (rather than reusing ComposeTerms with a label parameter) to make
/// future per-section styling changes independent.
///
private void ComposeNotes(IContainer container, string notes, string accentColor)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(column =>
{
column.Item().Text("ADDITIONAL NOTES").FontSize(9).Bold().FontColor(accentColor);
column.Item().PaddingTop(3).Text(notes).FontSize(8).LineHeight(1.3f);
});
}
///
/// Generates a product catalog PDF grouped by , returning the raw bytes.
/// Items are pre-grouped by the caller so this service stays free of database access.
/// Each category is wrapped in QuestPDF's ShowEntire() container which attempts to keep
/// the category block on a single page; if it overflows QuestPDF moves the entire block to the
/// next page rather than splitting it mid-category. A summary row at the end shows total category
/// and item counts for quick reference.
///
public async Task GenerateCatalogPdfAsync(
IEnumerable> itemsByCategory,
string companyName,
byte[]? companyLogo,
string? companyLogoContentType)
{
// Configure QuestPDF license
QuestPDF.Settings.License = LicenseType.Community;
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeCatalogHeader(c, companyName, companyLogo, companyLogoContentType));
page.Content().Element(c => ComposeCatalogContent(c, itemsByCategory));
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
text.Span(" / ");
text.TotalPages();
text.Span($" - {companyName} Product Catalog");
});
});
});
return document.GeneratePdf();
});
}
///
/// Composes the catalog page header with company branding on the left and "PRODUCT CATALOG"
/// plus generation date on the right. The generation timestamp is embedded so recipients can
/// identify stale printed copies; the date uses DateTime.Now (server time) which is
/// acceptable because catalog PDFs are generated on demand and not stored.
///
private void ComposeCatalogHeader(IContainer container, string companyName, byte[]? companyLogo, string? companyLogoContentType)
{
container.Row(row =>
{
// Left: Company logo or name
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
{
column.Item().MaxHeight(60).Image(companyLogo);
}
else
{
column.Item().Text(companyName)
.FontSize(18)
.Bold()
.FontColor(Colors.Blue.Darken3);
}
});
// Right: Title
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("PRODUCT CATALOG")
.FontSize(20)
.Bold()
.FontColor(Colors.Grey.Darken3);
column.Item().Text($"Generated: {DateTime.Now:MMMM dd, yyyy}")
.FontSize(9)
.FontColor(Colors.Grey.Darken1);
});
});
}
///
/// Composes the catalog body: iterates each category group, renders a QuestPDF table whose header
/// row carries both the category name band and the column labels so both repeat on overflow pages.
/// Items within a category are sorted by name for alphabetical scanning. The two-row header
/// technique (category name + column labels both in table.Header) is the idiomatic
/// QuestPDF pattern for repeating group headings across page breaks.
///
private void ComposeCatalogContent(IContainer container, IEnumerable> itemsByCategory)
{
container.Column(column =>
{
column.Item().PaddingTop(20);
foreach (var categoryGroup in itemsByCategory)
{
// Wrap category in a container that tries to keep it together on one page
// QuestPDF will automatically move it to next page if it doesn't fit
column.Item().ShowEntire().Column(categoryColumn =>
{
// Items table with category header integrated
categoryColumn.Item().Table(table =>
{
// Define columns
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(4); // Item Name
columns.RelativeColumn(6); // Description
columns.ConstantColumn(100); // Price
});
// Header - will repeat on each page if category spans multiple pages
table.Header(header =>
{
// Category name row (spans all columns, repeats on each page)
header.Cell().ColumnSpan(3).Background(Colors.Blue.Darken2).Padding(8).Row(row =>
{
row.RelativeItem().Text(categoryGroup.Key.Name)
.FontSize(14)
.Bold()
.FontColor(Colors.White);
row.ConstantItem(80).AlignRight().Text($"{categoryGroup.Count()} items")
.FontSize(10)
.FontColor(Colors.White);
});
// Column headers row (repeats on each page)
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).Text("Item Name").FontSize(9).Bold();
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).Text("Description").FontSize(9).Bold();
header.Cell().Background(Colors.Grey.Lighten2).Padding(5).AlignRight().Text("Price").FontSize(9).Bold();
});
// Items
var index = 0;
foreach (var item in categoryGroup.OrderBy(i => i.Name))
{
var bgColor = index % 2 == 0 ? Colors.White : Colors.Grey.Lighten4;
table.Cell().Background(bgColor).Padding(5).Text(item.Name ?? "").FontSize(9).Bold();
table.Cell().Background(bgColor).Padding(5).Text(item.Description ?? "").FontSize(9);
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.DefaultPrice:N2}").FontSize(9).Bold();
index++;
}
});
});
}
// Summary at the end
column.Item().PaddingTop(20).Border(1).BorderColor(Colors.Blue.Darken2).Padding(10).Row(row =>
{
var totalItems = itemsByCategory.Sum(g => g.Count());
var totalCategories = itemsByCategory.Count();
row.RelativeItem().Column(col =>
{
col.Item().Text("Catalog Summary").FontSize(12).Bold().FontColor(Colors.Blue.Darken3);
col.Item().PaddingTop(5).Text($"Total Categories: {totalCategories}").FontSize(9);
col.Item().Text($"Total Items: {totalItems}").FontSize(9);
});
});
});
}
// ── Financial Reports ─────────────────────────────────────────────────────
///
/// Generates a Profit & Loss statement PDF for the date range stored in .
/// Renders a KPI summary band (revenue, COGS, expenses, net income with colour-coded values),
/// then three report sections via : Revenue, Cost of Goods Sold,
/// and Operating Expenses. Gross Profit and Net Income summary rows are injected between sections
/// and colour-coded green/red to reflect profitability at a glance. All financial data is
/// pre-computed by the Reports controller — this method only handles layout.
///
public async Task GenerateProfitAndLossPdfAsync(PowderCoating.Application.DTOs.Accounting.ProfitAndLossDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Profit & Loss",
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
// KPI summary band
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Revenue", dto.TotalRevenue.ToString("C"), "#16a34a");
KpiCell(row, "Cost of Goods", dto.TotalCogs.ToString("C"), "#ca8a04");
KpiCell(row, "Operating Expenses", dto.TotalExpenses.ToString("C"), "#dc2626");
KpiCell(row, "Net Income", dto.NetIncome.ToString("C"), dto.NetIncome >= 0 ? "#16a34a" : "#dc2626");
});
col.Item().PaddingTop(14).Element(c => PlReportSection(c, "REVENUE", dto.RevenueLines, dto.TotalRevenue, dto.TotalRevenue, "Total Revenue", "#16a34a", accent));
if (dto.CogsLines.Any())
{
col.Item().PaddingTop(10).Element(c => PlReportSection(c, "COST OF GOODS SOLD", dto.CogsLines, dto.TotalCogs, dto.TotalRevenue, "Total COGS", "#ca8a04", accent));
col.Item().PaddingTop(4).Row(row =>
{
row.RelativeItem(3).AlignRight().Text("Gross Profit").Bold().FontSize(10);
row.ConstantItem(110).AlignRight().Text(dto.GrossProfit.ToString("C")).Bold().FontSize(10)
.FontColor(dto.GrossProfit >= 0 ? "#16a34a" : "#dc2626");
row.ConstantItem(70).AlignRight().Text(dto.TotalRevenue == 0 ? "—" : $"{dto.GrossMarginPercent:F1}%")
.FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
col.Item().PaddingTop(10).Element(c => PlReportSection(c, "OPERATING EXPENSES", dto.ExpenseLines, dto.TotalExpenses, dto.TotalRevenue, "Total Expenses", "#dc2626", accent));
// Net Income row
col.Item().PaddingTop(6).Background("#e8f5e9").Padding(8).Row(row =>
{
row.RelativeItem(3).Text("NET INCOME").Bold().FontSize(11);
row.ConstantItem(110).AlignRight().Text(dto.NetIncome.ToString("C")).Bold().FontSize(11)
.FontColor(dto.NetIncome >= 0 ? "#16a34a" : "#dc2626");
row.ConstantItem(70).AlignRight()
.Text(dto.TotalRevenue == 0 ? "—" : $"{dto.NetIncome / dto.TotalRevenue * 100:F1}%")
.FontSize(9).FontColor(Colors.Grey.Darken1);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Renders a single section (Revenue, COGS, or Expenses) of the P&L report as an accent-headed
/// block of alternating account rows followed by a bold subtotal row.
/// The right-most column shows each line's percentage of total revenue (),
/// displaying "—" when revenue is zero to avoid a division-by-zero NaN in the PDF.
/// is passed by the caller so each section's total row can be
/// colour-coded independently (green for revenue, amber for COGS, red for expenses).
///
private void PlReportSection(IContainer container, string title, List lines,
decimal sectionTotal, decimal revenueTotal, string subtotalLabel, string subtotalColor, string accent)
{
container.Column(col =>
{
col.Item().Background(accent).Padding(4).Text(title).FontColor(Colors.White).Bold().FontSize(9);
if (!lines.Any())
{
col.Item().PaddingLeft(8).PaddingVertical(4).Text("No activity for this period.").FontSize(9).FontColor(Colors.Grey.Medium);
return;
}
var alt = false;
foreach (var line in lines)
{
col.Item().Background(alt ? "#f8fafc" : Colors.White).Row(row =>
{
row.RelativeItem(3).PaddingLeft(12).PaddingVertical(3).Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
row.ConstantItem(110).AlignRight().PaddingVertical(3).Text(line.Amount.ToString("C")).FontSize(9);
row.ConstantItem(70).AlignRight().PaddingVertical(3)
.Text(revenueTotal == 0 ? "—" : $"{line.Amount / revenueTotal * 100:F1}%").FontSize(8).FontColor(Colors.Grey.Medium);
});
alt = !alt;
}
col.Item().BorderTop(1).BorderColor("#dee2e6").Row(row =>
{
row.RelativeItem(3).PaddingLeft(12).PaddingVertical(4).Text(subtotalLabel).Bold().FontSize(9);
row.ConstantItem(110).AlignRight().PaddingVertical(4).Text(sectionTotal.ToString("C")).Bold().FontSize(9).FontColor(subtotalColor);
row.ConstantItem(70).AlignRight().PaddingVertical(4)
.Text(revenueTotal == 0 ? "—" : $"{sectionTotal / revenueTotal * 100:F1}%").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
///
/// Generates a Balance Sheet PDF as of the date in .
/// Uses a two-column layout (Assets left, Liabilities + Equity right) which matches the
/// traditional accounting presentation and makes the Assets = Liabilities + Equity identity
/// visually obvious on the page. A balance-check row at the bottom is coloured red with a
/// warning message when is false,
/// alerting accountants to a chart-of-accounts configuration issue without hiding the imbalance.
/// Sub-sections within each column are rendered by .
///
public async Task GenerateBalanceSheetPdfAsync(PowderCoating.Application.DTOs.Accounting.BalanceSheetDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Balance Sheet",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
// KPI band
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Assets", dto.TotalAssets.ToString("C"), "#1a56db");
KpiCell(row, "Total Liabilities", dto.TotalLiabilities.ToString("C"), "#dc2626");
KpiCell(row, "Total Equity", dto.TotalEquity.ToString("C"), "#16a34a");
});
// Two-column: Assets | Liabilities+Equity
col.Item().PaddingTop(14).Row(row =>
{
// Assets
row.RelativeItem().Column(assetCol =>
{
assetCol.Item().Background(accent).Padding(5).Text("ASSETS").FontColor(Colors.White).Bold().FontSize(10);
BsSection(assetCol, "Current Assets", dto.CurrentAssets);
BsSection(assetCol, "Fixed Assets", dto.FixedAssets);
BsSection(assetCol, "Other Assets", dto.OtherAssets);
assetCol.Item().Background("#dbeafe").Padding(5).Row(r =>
{
r.RelativeItem().Text("Total Assets").Bold().FontSize(10).FontColor("#1e40af");
r.ConstantItem(100).AlignRight().Text(dto.TotalAssets.ToString("C")).Bold().FontSize(10).FontColor("#1e40af");
});
});
row.ConstantItem(16); // gutter
// Liabilities + Equity
row.RelativeItem().Column(liabCol =>
{
liabCol.Item().Background("#dc2626").Padding(5).Text("LIABILITIES").FontColor(Colors.White).Bold().FontSize(10);
BsSection(liabCol, "Current Liabilities", dto.CurrentLiabilities);
BsSection(liabCol, "Long-Term Liabilities", dto.LongTermLiabilities);
liabCol.Item().Background("#fee2e2").Padding(5).Row(r =>
{
r.RelativeItem().Text("Total Liabilities").Bold().FontSize(9).FontColor("#991b1b");
r.ConstantItem(100).AlignRight().Text(dto.TotalLiabilities.ToString("C")).Bold().FontSize(9).FontColor("#991b1b");
});
liabCol.Item().PaddingTop(10).Background("#16a34a").Padding(5).Text("EQUITY").FontColor(Colors.White).Bold().FontSize(10);
foreach (var line in dto.EquityLines)
{
liabCol.Item().PaddingLeft(8).PaddingVertical(3).Row(r =>
{
r.RelativeItem().Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
r.ConstantItem(100).AlignRight().Text(line.Amount.ToString("C")).FontSize(9);
});
}
liabCol.Item().PaddingLeft(8).PaddingVertical(3).Row(r =>
{
r.RelativeItem().Text("Retained Earnings (Net Income)").FontSize(9).FontColor(Colors.Grey.Darken1);
r.ConstantItem(100).AlignRight().Text(dto.RetainedEarnings.ToString("C")).FontSize(9)
.FontColor(dto.RetainedEarnings < 0 ? "#dc2626" : Colors.Black);
});
liabCol.Item().Background("#dcfce7").Padding(5).Row(r =>
{
r.RelativeItem().Text("Total Equity").Bold().FontSize(9).FontColor("#166534");
r.ConstantItem(100).AlignRight().Text(dto.TotalEquity.ToString("C")).Bold().FontSize(9).FontColor("#166534");
});
liabCol.Item().PaddingTop(4).Background(dto.IsBalanced ? "#dcfce7" : "#fee2e2").Padding(5).Row(r =>
{
r.RelativeItem().Text("Total Liabilities & Equity").Bold().FontSize(10);
r.ConstantItem(100).AlignRight().Text(dto.TotalLiabilitiesAndEquity.ToString("C")).Bold().FontSize(10)
.FontColor(dto.IsBalanced ? "#166534" : "#dc2626");
});
if (!dto.IsBalanced)
liabCol.Item().PaddingTop(4).Text("⚠ Sheet does not balance — check account setup.").FontSize(8).FontColor("#dc2626");
});
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Renders a named sub-section of the Balance Sheet (e.g. "Current Assets", "Fixed Assets")
/// as a light-grey header, alternating account rows, a subtotal border-top row, and a small
/// vertical spacer. Returns immediately when is empty so the containing
/// column does not show an orphaned heading for accounts with no activity.
/// Called from for both the Assets and Liabilities columns.
///
private static void BsSection(ColumnDescriptor col, string title, List lines)
{
if (!lines.Any()) return;
col.Item().Background("#f1f5f9").PaddingLeft(4).PaddingVertical(3)
.Text(title.ToUpperInvariant()).FontSize(8).Bold().FontColor(Colors.Grey.Darken2);
var alt = false;
foreach (var line in lines)
{
col.Item().Background(alt ? "#f8fafc" : Colors.White).PaddingLeft(8).PaddingVertical(2).Row(r =>
{
r.RelativeItem().Text($"{line.AccountNumber} {line.AccountName}").FontSize(9);
r.ConstantItem(100).AlignRight().Text(line.Amount.ToString("C")).FontSize(9);
});
alt = !alt;
}
col.Item().BorderTop(1).BorderColor("#dee2e6").PaddingLeft(8).PaddingVertical(3).Row(r =>
{
r.RelativeItem().Text($"Total {title}").Bold().FontSize(9);
r.ConstantItem(100).AlignRight().Text(lines.Sum(l => l.Amount).ToString("C")).Bold().FontSize(9);
});
col.Item().PaddingVertical(2); // spacer
}
///
/// Generates an Accounts Receivable Aging report PDF as of .
/// Renders a summary KPI band with five aging buckets (Current, 1-30, 31-60, 61-90, Over 90),
/// a customer-level summary table, and a per-customer invoice detail section that colour-codes
/// each invoice age from green (current) to dark red (over 90 days). The detail section uses
/// QuestPDF's ShowEntire() per customer so a customer block is never split across pages.
/// When there are no outstanding invoices a centred "all paid" message is shown instead of an
/// empty table — a common state for companies that bill promptly.
///
public async Task GenerateArAgingPdfAsync(PowderCoating.Application.DTOs.Accounting.ArAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Receivable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
// Summary band
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Outstanding", dto.TotalOutstanding.ToString("C0"), "#1a56db");
});
if (!dto.Customers.Any())
{
col.Item().PaddingTop(20).AlignCenter().Text("All invoices are paid — no outstanding balances.").FontSize(11).FontColor("#16a34a");
return;
}
// Summary table
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
void Hdr(string t) => table.Header(h =>
{
foreach (var lbl in new[] { "Customer", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
Hdr("");
var alt = false;
foreach (var cust in dto.Customers)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(cust.CustomerName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(cust.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
// Totals row
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
// Invoice detail per customer
col.Item().PaddingTop(16).Text("Invoice Detail").FontSize(11).Bold();
foreach (var cust in dto.Customers)
{
col.Item().PaddingTop(8).ShowEntire().Column(custCol =>
{
custCol.Item().Background("#f1f5f9").Padding(4).Text(cust.CustomerName).Bold().FontSize(10);
custCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Invoice", "Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var inv in cust.Invoices.OrderBy(i => i.DaysOverdue))
{
var ageColor = inv.DaysOverdue <= 0 ? "#16a34a"
: inv.DaysOverdue <= 30 ? "#ca8a04"
: inv.DaysOverdue <= 60 ? "#ea580c"
: inv.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = inv.DaysOverdue <= 0 ? "Current" : $"{inv.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(inv.InvoiceNumber).FontSize(8);
table.Cell().Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(inv.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(inv.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3).Text($"{cust.CustomerName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(cust.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Generates a Sales & Income report PDF covering the date range in .
/// Contains three sections: Sales by Month (invoice vs. collected per month), Sales by Customer
/// (invoice count, total invoiced, paid, and balance per customer), and a full Invoice Detail
/// table. Invoice status strings are colour-coded (Paid=green, PartiallyPaid=amber, Overdue=red)
/// using a switch expression so adding a new status only requires updating that one switch.
/// The "no invoices" guard short-circuits after the KPI band to avoid rendering empty tables.
///
public async Task GenerateSalesAndIncomePdfAsync(PowderCoating.Application.DTOs.Accounting.SalesIncomeReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.65f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Sales & Income",
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
// KPI band
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Invoiced", dto.TotalInvoiced.ToString("C"), "#1a56db");
KpiCell(row, "Collected (period)", dto.TotalCollected.ToString("C"), "#16a34a");
KpiCell(row, "Avg Invoice Value", dto.AverageInvoiceValue.ToString("C"), Colors.Grey.Darken2);
KpiCell(row, "Active Customers", dto.CustomerCount.ToString(), Colors.Grey.Darken2);
});
if (!dto.Invoices.Any())
{
col.Item().PaddingTop(20).AlignCenter().Text("No invoices found for this period.").FontSize(11).FontColor(Colors.Grey.Medium);
return;
}
// By Month
col.Item().PaddingTop(14).Text("Sales by Month").FontSize(11).Bold();
col.Item().PaddingTop(4).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(1);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Month", "Invoiced", "Collected", "#" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var m in dto.ByMonth)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(3).Text(m.Label).FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(3).Text(m.TotalInvoiced.ToString("C")).FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(3).Text(m.TotalCollected.ToString("C")).FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignCenter().Padding(3).Text(m.InvoiceCount.ToString()).FontSize(9).FontColor(Colors.Grey.Darken1);
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(3).Text("Total").Bold().FontSize(9);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(9);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(9).FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignCenter().Padding(3).Text(dto.InvoiceCount.ToString()).Bold().FontSize(9);
});
// By Customer
col.Item().PaddingTop(14).Text("Sales by Customer").FontSize(11).Bold();
col.Item().PaddingTop(4).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(4);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Customer", "Inv", "Total Invoiced", "Paid", "Balance Due" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var c in dto.ByCustomer)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(3).Text(c.CustomerName).FontSize(9).Bold();
table.Cell().Background(bg).AlignCenter().Padding(3).Text(c.InvoiceCount.ToString()).FontSize(9).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.TotalInvoiced.ToString("C")).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.TotalPaid.ToString("C")).FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(3).Text(c.BalanceDue.ToString("C")).FontSize(9)
.FontColor(c.BalanceDue > 0 ? "#ca8a04" : Colors.Grey.Darken1);
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(3).Text($"Total ({dto.CustomerCount} customers)").Bold().FontSize(9);
table.Cell().Background("#e2e8f0").AlignCenter().Padding(3).Text(dto.InvoiceCount.ToString()).Bold().FontSize(9);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(9);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(9).FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.Invoices.Sum(i => i.BalanceDue).ToString("C")).Bold().FontSize(9);
});
// Invoice Detail
col.Item().PaddingTop(14).Text("Invoice Detail").FontSize(11).Bold();
col.Item().PaddingTop(4).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Invoice", "Customer", "Date", "Total", "Paid", "Status" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var inv in dto.Invoices)
{
var bg = alt ? "#f8fafc" : "#ffffff";
var statusColor = inv.Status switch
{
"Paid" => "#16a34a",
"PartiallyPaid" => "#ca8a04",
"Overdue" => "#dc2626",
_ => "#6c757d"
};
table.Cell().Background(bg).Padding(3).Text(inv.InvoiceNumber).FontSize(8).Bold();
table.Cell().Background(bg).Padding(3).Text(inv.CustomerName).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yy")).FontSize(8);
table.Cell().Background(bg).AlignRight().Padding(3).Text(inv.Total.ToString("C")).FontSize(8).Bold();
table.Cell().Background(bg).AlignRight().Padding(3).Text(inv.AmountPaid.ToString("C")).FontSize(8).FontColor("#16a34a");
table.Cell().Background(bg).Padding(3).Text(inv.Status).FontSize(8).FontColor(statusColor);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(3).Text("Totals").Bold().FontSize(8);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalInvoiced.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#e2e8f0").AlignRight().Padding(3).Text(dto.TotalCollected.ToString("C")).Bold().FontSize(8).FontColor("#16a34a");
table.Cell().Background("#e2e8f0");
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Composes the shared header used by all four financial report PDFs (P&L, Balance Sheet,
/// AR Aging, Sales & Income). Places the company name and date range/subtitle on the left,
/// and the report title in large bold accent text on the right, separated from the content area
/// by a 1.5pt accent-coloured horizontal rule. Kept static because it references no instance state.
///
private static void ComposeReportHeader(IContainer container, string companyName, string reportTitle, string subtitle, string accent)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text(companyName).FontSize(14).Bold().FontColor(accent);
c.Item().Text(subtitle).FontSize(9).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text(reportTitle).FontSize(20).Bold().FontColor(accent);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1.5f).LineColor(accent);
});
}
///
/// Appends a single KPI cell to a for use in the summary bands of
/// all financial report PDFs. Each cell is a centred column with the value on top (bold, 11pt,
/// colour-coded) and the label below (8pt grey). Implemented as a static helper to avoid
/// repeating the same four-line QuestPDF fluent chain at every call site in the four report methods.
///
private static void KpiCell(RowDescriptor row, string label, string value, string valueColor)
{
row.RelativeItem().AlignCenter().Column(c =>
{
c.Item().AlignCenter().Text(value).Bold().FontSize(11).FontColor(valueColor);
c.Item().AlignCenter().Text(label).FontSize(8).FontColor(Colors.Grey.Darken1);
});
}
// =========================================================================
// Purchase Order PDF
// =========================================================================
///
/// Generates a Purchase Order PDF and returns the raw bytes for download or email to the vendor.
/// Uses a green accent colour to visually distinguish PO documents from quote (grey) and invoice
/// (blue) PDFs. Layout delegates to for the branding/PO-metadata
/// block and for the vendor address, line-item table, totals, notes,
/// and authorisation signature line.
///
public async Task GeneratePurchaseOrderPdfAsync(
PurchaseOrderDto po,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
var accent = Colors.Green.Darken2;
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposePOHeader(c, companyLogo, companyInfo, accent, po));
page.Content().Element(c => ComposePOContent(c, po, accent));
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
text.Span(" / ");
text.TotalPages();
text.Span($" — {companyInfo.CompanyName}");
});
});
});
return doc.GeneratePdf();
});
}
///
/// Composes the PO page header: company branding (logo or name + address) on the left, and a
/// "PURCHASE ORDER" title block with PO number, order date, expected delivery date, and status
/// on the right. Mirrors the structure of but uses
/// "PURCHASE ORDER" as the document type label. The header is separated from the body by a
/// 1pt accent-coloured rule.
///
private void ComposePOHeader(IContainer container, byte[]? logo, CompanyInfoDto company, string accent, PurchaseOrderDto po)
{
container.Column(col =>
{
col.Item().Row(row =>
{
// Left: company branding
row.RelativeItem().Column(c =>
{
if (logo != null && logo.Length > 0)
c.Item().MaxHeight(60).Image(logo);
else
c.Item().Text(company.CompanyName).FontSize(18).Bold().FontColor(accent);
if (!string.IsNullOrWhiteSpace(company.Address))
c.Item().Text(company.Address).FontSize(9).FontColor(Colors.Grey.Darken1);
var cityLine = $"{company.City}{(!string.IsNullOrEmpty(company.City) && !string.IsNullOrEmpty(company.State) ? ", " : "")}{company.State} {company.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(company.Phone))
c.Item().Text(FormatPhoneNumber(company.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
});
// Right: PO title block
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text("PURCHASE ORDER").FontSize(24).Bold().FontColor(accent);
c.Item().Text($"# {po.PoNumber}").FontSize(12).Bold();
c.Item().Text($"Date: {po.OrderDate:MMMM d, yyyy}").FontSize(9);
if (po.ExpectedDeliveryDate.HasValue)
c.Item().Text($"Expected: {po.ExpectedDeliveryDate.Value:MMMM d, yyyy}").FontSize(9);
c.Item().Text($"Status: {po.Status}").FontSize(9).FontColor(Colors.Grey.Darken1);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accent);
});
}
// =========================================================================
// Gift Certificate PDF
// =========================================================================
///
/// Generates a decorative gift certificate PDF and returns the raw bytes.
/// Uses purple (#7c3aed) and gold (#b45309) colour constants rather than a configurable accent
/// because the celebratory aesthetic should be consistent regardless of the company's brand colour.
/// Layout is handled by which renders the double-border
/// frame, big-value amount, certificate code, redemption metadata, and optional remaining-balance
/// callout for partially redeemed certificates.
///
public async Task GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed"; // purple — gift/celebration feel
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return doc.GeneratePdf();
});
}
///
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
///
public async Task GenerateBulkGiftCertificatePdfAsync(
IList certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
///
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
/// frame: company branding, a 48pt bold amount (the visual centrepiece), a two-column row with
/// the recipient name and certificate code in a highlighted box, and a four-column metadata row
/// (issued, valid-through, issued-as, status). A conditional remaining-balance callout is shown
/// in a gold-bordered amber box when the certificate has been partially redeemed, so the customer
/// knows exactly how much they still have. The certificate code is displayed prominently because
/// it is the redemption key — staff look it up in the system at time of service.
///
private void ComposeGiftCertificateContent(IContainer container, GiftCertificateDto cert, CompanyInfoDto companyInfo,
byte[]? logo, string accent, string gold)
{
container.Column(col =>
{
// ── Outer decorative border ────────────────────────────────────────
col.Item().Border(3).BorderColor(accent).Padding(16).Column(inner =>
{
inner.Item().Border(1).BorderColor(gold).Padding(16).Column(body =>
{
// ── Company header ─────────────────────────────────────────
body.Item().Row(row =>
{
row.RelativeItem().Column(c =>
{
if (logo != null && logo.Length > 0)
c.Item().MaxHeight(55).Image(logo);
else
c.Item().Text(companyInfo.CompanyName).FontSize(16).Bold().FontColor(accent);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
c.Item().PaddingTop(2).Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
c.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text("GIFT CERTIFICATE").FontSize(26).Bold().FontColor(accent);
});
});
body.Item().PaddingVertical(10).LineHorizontal(1.5f).LineColor(gold);
// ── Big amount ─────────────────────────────────────────────
body.Item().PaddingVertical(10).AlignCenter().Column(c =>
{
c.Item().AlignCenter().Text("This certificate is worth").FontSize(11).FontColor(Colors.Grey.Darken2);
c.Item().AlignCenter().Text(cert.OriginalAmount.ToString("C")).FontSize(48).Bold().FontColor(accent);
});
body.Item().PaddingVertical(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
// ── Recipient + Code ───────────────────────────────────────
body.Item().PaddingTop(10).Row(row =>
{
// Left: recipient
row.RelativeItem().Column(c =>
{
c.Item().Text("PRESENTED TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(3).Text(
!string.IsNullOrWhiteSpace(cert.RecipientName) ? cert.RecipientName : "Bearer"
).FontSize(16).Bold();
if (!string.IsNullOrWhiteSpace(cert.RecipientEmail))
c.Item().PaddingTop(2).Text(cert.RecipientEmail).FontSize(9).FontColor(Colors.Grey.Darken1);
});
// Right: certificate code
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().AlignRight().Text("CERTIFICATE CODE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(3).AlignRight().Background("#f5f3ff").Padding(6)
.Text(cert.CertificateCode).FontSize(16).Bold().FontColor(accent);
});
});
body.Item().PaddingTop(14).Row(row =>
{
// Issued
row.RelativeItem().Column(c =>
{
c.Item().Text("ISSUED").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(2).Text(cert.IssueDate.ToLocalTime().ToString("MMMM d, yyyy")).FontSize(10);
});
// Expiry
row.RelativeItem().Column(c =>
{
c.Item().Text("VALID THROUGH").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(2).Text(
cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMMM d, yyyy")
: "No expiration"
).FontSize(10);
});
// Issued reason
row.RelativeItem().Column(c =>
{
c.Item().Text("ISSUED AS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(2).Text(cert.IssuedReason switch
{
GiftCertificateIssuedReason.Sold => "Purchased Gift",
GiftCertificateIssuedReason.Prize => "Prize / Award",
GiftCertificateIssuedReason.Promotional => "Promotional",
GiftCertificateIssuedReason.Goodwill => "Goodwill",
_ => "Gift Certificate"
}).FontSize(10);
});
// Status
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().AlignRight().Text("STATUS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
string statusText, statusColor;
(statusText, statusColor) = cert.Status switch
{
GiftCertificateStatus.Active => ("Active", "#16a34a"),
GiftCertificateStatus.PartiallyRedeemed => ("Partially Redeemed", "#ca8a04"),
GiftCertificateStatus.FullyRedeemed => ("Fully Redeemed", "#6b7280"),
GiftCertificateStatus.Expired => ("Expired", "#dc2626"),
GiftCertificateStatus.Voided => ("VOIDED", "#dc2626"),
_ => (cert.Status.ToString(), "#6b7280")
};
c.Item().PaddingTop(2).AlignRight().Text(statusText).FontSize(10).Bold().FontColor(statusColor);
});
});
// Show remaining balance if partially redeemed
if (cert.RedeemedAmount > 0 && cert.RemainingBalance > 0)
{
body.Item().PaddingTop(10).Background("#fefce8").Border(1).BorderColor(gold).Padding(8).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text($"Original Value: {cert.OriginalAmount:C}").FontSize(9).FontColor(Colors.Grey.Darken1);
c.Item().Text($"Amount Used: ({cert.RedeemedAmount:C})").FontSize(9).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().AlignRight().Text("REMAINING BALANCE").FontSize(8).Bold().FontColor(gold);
c.Item().AlignRight().Text(cert.RemainingBalance.ToString("C")).FontSize(18).Bold().FontColor(gold);
});
});
}
body.Item().PaddingTop(14).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
// ── Redemption instructions ────────────────────────────────
body.Item().PaddingTop(10).AlignCenter().Text(
"Present this certificate at time of service. Quote the certificate code above to redeem."
).FontSize(9).FontColor(Colors.Grey.Darken1).Italic();
if (!string.IsNullOrWhiteSpace(cert.Notes))
{
body.Item().PaddingTop(8).AlignCenter().Text(cert.Notes).FontSize(9).FontColor(Colors.Grey.Darken2);
}
});
});
});
}
///
/// Composes the body of the Purchase Order PDF: vendor contact block and order-reference panel,
/// an alternating-row line-item table with item name, notes, quantity (using "G29" format to
/// suppress trailing zeros for decimal quantities), unit cost, and line total, followed by a
/// right-aligned totals block that conditionally includes shipping cost. A signature line is
/// rendered at the bottom for physical authorisation; QuestPDF's PaddingTop(32) provides
/// enough blank space above the line for a handwritten signature.
///
private void ComposePOContent(IContainer container, PurchaseOrderDto po, string accent)
{
container.Column(col =>
{
// Vendor info
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("VENDOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(po.VendorName).Bold();
if (!string.IsNullOrWhiteSpace(po.VendorEmail))
c.Item().Text(po.VendorEmail).FontSize(9);
if (!string.IsNullOrWhiteSpace(po.VendorPhone))
c.Item().Text(po.VendorPhone).FontSize(9);
});
row.ConstantItem(200).Column(c =>
{
c.Item().Text("ORDER DETAILS").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text($"PO Number: {po.PoNumber}").FontSize(9);
c.Item().Text($"Order Date: {po.OrderDate:MM/dd/yyyy}").FontSize(9);
if (po.ExpectedDeliveryDate.HasValue)
c.Item().Text($"Expected Delivery: {po.ExpectedDeliveryDate.Value:MM/dd/yyyy}").FontSize(9);
});
});
// Line items table
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5); // Description
cols.RelativeColumn(1.5f); // Qty
cols.RelativeColumn(2); // Unit Cost
cols.RelativeColumn(2); // Line Total
});
table.Header(h =>
{
h.Cell().Background(accent).Padding(5).Text("Item / Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accent).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accent).Padding(5).AlignRight().Text("Unit Cost").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accent).Padding(5).AlignRight().Text("Line Total").FontColor(Colors.White).Bold().FontSize(9);
});
var alt = false;
foreach (var item in po.Items)
{
var bg = alt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(5).Column(c =>
{
c.Item().Text(item.ItemName).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.Notes))
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.QuantityOrdered.ToString("G29")).FontSize(9);
table.Cell().Background(bg).Padding(5).AlignRight().Text(item.UnitCost.ToString("C")).FontSize(9);
table.Cell().Background(bg).Padding(5).AlignRight().Text(item.LineTotal.ToString("C")).FontSize(9);
alt = !alt;
}
});
// Totals block
col.Item().PaddingTop(8).AlignRight().Column(c =>
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(180).Row(rr =>
{
rr.RelativeItem().Text("Subtotal:").FontSize(9);
rr.ConstantItem(90).AlignRight().Text(po.SubTotal.ToString("C")).FontSize(9);
});
});
if (po.ShippingCost > 0)
{
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(180).Row(rr =>
{
rr.RelativeItem().Text("Shipping:").FontSize(9);
rr.ConstantItem(90).AlignRight().Text(po.ShippingCost.ToString("C")).FontSize(9);
});
});
}
c.Item().Row(r =>
{
r.RelativeItem();
r.ConstantItem(180).Background(accent).Padding(5).Row(rr =>
{
rr.RelativeItem().Text("TOTAL:").FontSize(10).Bold().FontColor(Colors.White);
rr.ConstantItem(90).AlignRight().Text(po.TotalAmount.ToString("C")).FontSize(10).Bold().FontColor(Colors.White);
});
});
});
// Notes
if (!string.IsNullOrWhiteSpace(po.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("NOTES").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().PaddingTop(2).Border(0.5f).BorderColor(Colors.Grey.Lighten2)
.Padding(6).Text(po.Notes).FontSize(9);
});
}
// Signature line
col.Item().PaddingTop(32).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(0.5f).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text("").FontSize(9);
c.Item().PaddingTop(2).Text("Authorized Signature").FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(20);
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(0.5f).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text("").FontSize(9);
c.Item().PaddingTop(2).Text("Date").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
}
// ─── Sales Tax Report ─────────────────────────────────────────────────────
///
/// Generates a letter-sized PDF for the Sales Tax Liability report. Sections: summary KPI
/// row, breakdown by tax account, breakdown by month, then a full invoice detail table.
/// Intended for handing to an accountant or attaching to a tax filing.
///
public Task GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1e3a5f";
return Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.65f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text(dto.CompanyName).FontSize(14).Bold().FontColor(accent);
c.Item().Text("Sales Tax Liability Report").FontSize(10).FontColor(Colors.Grey.Darken1);
c.Item().Text($"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text("Invoice-Basis Report").FontSize(8).Italic().FontColor(Colors.Grey.Medium);
c.Item().Text($"Generated {DateTime.Today:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
});
});
col.Item().PaddingTop(4).BorderBottom(1.5f).BorderColor(accent);
});
page.Content().PaddingTop(12).Column(col =>
{
// KPI summary row
col.Item().PaddingBottom(12).Row(row =>
{
void KpiBox(RowDescriptor r, string label, string value, string bg)
{
r.RelativeItem().Background(bg).Padding(8).Column(c =>
{
c.Item().Text(label).FontSize(7.5f).FontColor(Colors.Grey.Darken2);
c.Item().Text(value).FontSize(13).Bold().FontColor(accent);
});
}
KpiBox(row, "Total Tax Billed", $"{dto.TotalTaxBilled:C}", "#e8f4fd");
row.ConstantItem(6);
KpiBox(row, "Taxable Sales", $"{dto.TotalTaxableSales:C}", "#f0fdf4");
row.ConstantItem(6);
KpiBox(row, "Non-Taxable Sales", $"{dto.TotalNonTaxableSales:C}", "#fafafa");
row.ConstantItem(6);
KpiBox(row, "Effective Tax Rate", $"{dto.EffectiveTaxRate:F2}%", "#fff7ed");
});
// By account
if (dto.ByAccount.Any())
{
col.Item().PaddingBottom(4).Text("Tax by Liability Account").FontSize(10).Bold().FontColor(accent);
col.Item().PaddingBottom(10).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(3);
c.RelativeColumn(2);
c.RelativeColumn(2);
c.ConstantColumn(40);
});
void AHdr(string t) => table.Header(h => h.Cell().Background("#e8f4fd").Padding(4).Text(t).Bold().FontSize(8));
table.Header(h =>
{
h.Cell().Background("#e8f4fd").Padding(4).Text("Account").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
});
foreach (var a in dto.ByAccount)
{
var label = string.IsNullOrEmpty(a.AccountNumber) ? a.AccountName : $"{a.AccountNumber} {a.AccountName}";
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(label).FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxableSales:C}").FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxBilled:C}").FontSize(8).Bold();
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(a.InvoiceCount.ToString()).FontSize(8);
}
// Totals row
table.Cell().Background("#f8fafc").Padding(4).Text("Total").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxableSales):C}").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxBilled):C}").Bold().FontSize(8);
table.Cell().Background("#f8fafc").Padding(4).AlignCenter().Text(dto.ByAccount.Sum(a => a.InvoiceCount).ToString()).Bold().FontSize(8);
});
}
// By month
if (dto.ByMonth.Any())
{
col.Item().PaddingBottom(4).Text("Tax by Month").FontSize(10).Bold().FontColor(accent);
col.Item().PaddingBottom(10).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(2);
c.RelativeColumn(2);
c.RelativeColumn(2);
c.ConstantColumn(40);
});
table.Header(h =>
{
h.Cell().Background("#e8f4fd").Padding(4).Text("Month").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
});
foreach (var m in dto.ByMonth)
{
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(m.Label).FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxableSales:C}").FontSize(8);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxBilled:C}").FontSize(8).Bold();
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(m.InvoiceCount.ToString()).FontSize(8);
}
});
}
// Invoice detail
col.Item().PaddingBottom(4).Text("Invoice Detail").FontSize(10).Bold().FontColor(accent);
col.Item().Table(table =>
{
table.ColumnsDefinition(c =>
{
c.ConstantColumn(70); // Invoice #
c.RelativeColumn(2.5f); // Customer
c.ConstantColumn(58); // Date
c.ConstantColumn(48); // Status
c.ConstantColumn(52); // SubTotal
c.ConstantColumn(34); // Tax %
c.ConstantColumn(52); // Tax $
c.ConstantColumn(52); // Total
c.RelativeColumn(2); // Tax Account
});
table.Header(h =>
{
string bg = "#e8f4fd";
h.Cell().Background(bg).Padding(3).Text("Invoice #").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Customer").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Date").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Status").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Subtotal").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax %").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax $").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).AlignRight().Text("Total").Bold().FontSize(7.5f);
h.Cell().Background(bg).Padding(3).Text("Tax Account").Bold().FontSize(7.5f);
});
foreach (var inv in dto.Invoices)
{
var rowBg = inv.TaxAmount == 0 ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceNumber).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.CustomerName).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.Status).FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.SubTotal:C}").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxPercent:F2}%" : "—").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxAmount:C}" : "—").FontSize(7.5f).Bold();
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.Total:C}").FontSize(7.5f);
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.TaxAccountName).FontSize(7).FontColor(Colors.Grey.Darken1);
}
// Totals row
table.Cell().ColumnSpan(4).Background("#f0fdf4").Padding(3).AlignRight().Text("Totals").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.SubTotal):C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.TotalTaxBilled:C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.Total):C}").Bold().FontSize(7.5f);
table.Cell().Background("#f0fdf4").Padding(3);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.DefaultTextStyle(s => s.FontSize(7.5f).FontColor(Colors.Grey.Medium));
text.Span("Sales Tax Liability Report | Invoice Basis | ");
text.CurrentPageNumber();
text.Span(" of ");
text.TotalPages();
});
});
});
return document.GeneratePdf();
});
}
///
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
///
public async Task GenerateApAgingPdfAsync(ApAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#b91c1c";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
});
if (!dto.Vendors.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("All bills are paid — no outstanding balances.")
.FontSize(11).FontColor("#16a34a");
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var vend in dto.Vendors)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
foreach (var vend in dto.Vendors)
{
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
{
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
vendCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
: bill.DaysOverdue <= 30 ? "#ca8a04"
: bill.DaysOverdue <= 60 ? "#ea580c"
: bill.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
/// totals and a balanced/unbalanced indicator.
///
public async Task GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
dto.IsBalanced ? "#16a34a" : "#dc2626");
});
if (!dto.Lines.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("No active accounts with balances found.")
.FontSize(11).FontColor(Colors.Grey.Darken1);
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.ConstantColumn(70);
cols.RelativeColumn(4);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var line in dto.Lines)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
/// visually distinguish it from the other financial statements.
///
public async Task GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#0891b2";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Spacing(4);
// ── Operating Activities ──────────────────────────────────────
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
});
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.InvestingLines.Count == 0)
CfRow(t, "No investing activities recorded", 0, true);
else
foreach (var line in dto.InvestingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
});
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.FinancingLines.Count == 0)
CfRow(t, "No financing activities recorded", 0, true);
else
foreach (var line in dto.FinancingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
});
// ── Summary ───────────────────────────────────────────────────
col.Item().PaddingTop(12).Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
void SumRow(string label, decimal amount, bool bold = false)
{
var bg = bold ? "#e0f2fe" : "#ffffff";
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
if (bold) lText.Bold();
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
if (bold) vText.Bold();
}
SumRow("Beginning Cash Balance", dto.BeginningCash);
SumRow("Net Change in Cash", dto.NetChangeInCash);
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
{
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6)
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
{
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
.Text(label).Bold().FontSize(9);
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).Bold().FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
// -----------------------------------------------------------------------
// Packing Slip
// -----------------------------------------------------------------------
///
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
/// description, color, and quantity only — no unit prices or totals. Intended for
/// physical pickup/delivery paperwork where pricing should not be visible.
///
public async Task GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accentColor = "#1e40af"; // blue
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
page.Footer().AlignCenter().Text(text =>
{
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
///
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
///
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
column.Item().MaxHeight(60).Image(companyLogo);
else
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
});
}
///
/// Body of the packing slip: customer info block, optional PO number, and an items table
/// showing description, color, and quantity — no prices.
///
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
{
container.Column(col =>
{
// Customer info
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerName).Bold();
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
c.Item().Text(invoice.CustomerAddress).FontSize(9);
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
});
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
{
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerPO).Bold();
});
}
});
// Items table
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5);
cols.RelativeColumn(3);
cols.RelativeColumn(1);
});
table.Header(h =>
{
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
});
var rowAlt = false;
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(5).Column(c =>
{
c.Item().Text(item.Description).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.Notes))
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
rowAlt = !rowAlt;
}
});
// Notes (if any)
if (!string.IsNullOrWhiteSpace(invoice.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("Notes").Bold().FontSize(9);
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
// Received by signature line
col.Item().PaddingTop(32).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(24);
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
}
}