Files
PowderCoatingLogix/src/PowderCoating.Application/Services/PdfService.cs
T
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00

2951 lines
157 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// Generates a customer-facing quote PDF and returns the raw bytes for download or email attachment.
/// Runs document composition on a background thread via <c>Task.Run</c> because QuestPDF's
/// <c>GeneratePdf()</c> is CPU-bound and would otherwise block an ASP.NET request thread.
/// The <paramref name="template"/> parameter drives accent colour, footer note, and default terms;
/// when null a sensible default (<see cref="QuoteTemplateSettingsDto"/>) is used so callers do not
/// need to guard against missing company preferences.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// Generates an invoice PDF and returns the raw bytes.
/// Mirrors the structure of <see cref="GenerateQuotePdfAsync"/> but delegates content layout to
/// <see cref="ComposeInvoiceHeader"/> and <see cref="ComposeInvoiceContent"/>, 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.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// 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.
/// </summary>
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);
});
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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
/// <see cref="QuoteTemplateSettingsDto.DefaultTerms"/> 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.
/// </summary>
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}");
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
c.Item().Text($"Project: {invoice.ProjectName}");
});
});
// 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);
});
}
});
}
/// <summary>
/// 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
/// <paramref name="fallback"/> constant when the input is absent or malformed.
/// </summary>
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;
}
/// <summary>
/// Formats a raw phone string as <c>(XXX) XXX-XXXX</c> 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.
/// </summary>
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;
}
/// <summary>
/// Composes the shared quote-page header used by <see cref="GenerateQuotePdfAsync"/>.
/// 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.
/// </summary>
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<string>();
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);
});
});
}
/// <summary>
/// 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 <see cref="QuoteTemplateSettingsDto.DefaultTerms"/> and notes fall
/// back to <see cref="QuoteTemplateSettingsDto.FooterNote"/> so the document remains useful even
/// when those fields are blank on the individual quote.
/// </summary>
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));
}
});
}
/// <summary>
/// Renders the customer or prospect address block in the quote PDF.
/// Branches on <see cref="QuoteDto.CustomerId"/>: existing customers use the joined
/// <c>CustomerCompanyName</c> / contact name / email / phone fields from the Customer entity,
/// while prospects use the denormalised <c>Prospect*</c> 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.
/// </summary>
private void ComposeCustomerInfo(IContainer container, QuoteDto quote)
{
container.Column(column =>
{
if (quote.CustomerId.HasValue)
{
// Existing customer
var contactName = new List<string>();
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);
}
});
}
/// <summary>
/// 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.
/// </summary>
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);
});
}
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Project:").FontSize(9);
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
});
}
});
}
/// <summary>
/// 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
/// <see cref="ComposeContent"/> but the call site guards on <c>preparedByPhoto != null</c>.
/// </summary>
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);
});
});
}
/// <summary>
/// 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
/// "&lt;n&gt; 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.
/// </summary>
private void ComposeLineItems(IContainer container, QuoteDto quote, string accentColor)
{
var allItems = quote.QuoteItems?.OrderBy(i => i.Id).ToList() ?? new List<QuoteItemDto>();
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();
}
});
}
/// <summary>
/// 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 <see cref="QuoteDto.PricingBreakdown"/> for the
/// detailed sub-items that are not directly on the top-level DTO.
/// </summary>
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);
});
});
}
/// <summary>
/// Renders the "Terms &amp; 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 <paramref name="terms"/> is non-empty; the caller
/// (<see cref="ComposeContent"/>) is responsible for the conditional guard.
/// </summary>
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);
});
}
/// <summary>
/// Renders the "Additional Notes" section, visually identical to <see cref="ComposeTerms"/> but
/// labelled differently. Notes come from the quote's own <c>Description</c> field, falling back
/// to <see cref="QuoteTemplateSettingsDto.FooterNote"/> 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.
/// </summary>
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);
});
}
/// <summary>
/// Generates a product catalog PDF grouped by <see cref="CatalogCategory"/>, 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 <c>ShowEntire()</c> 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.
/// </summary>
public async Task<byte[]> GenerateCatalogPdfAsync(
IEnumerable<IGrouping<CatalogCategory, CatalogItem>> 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();
});
}
/// <summary>
/// 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 <c>DateTime.Now</c> (server time) which is
/// acceptable because catalog PDFs are generated on demand and not stored.
/// </summary>
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);
});
});
}
/// <summary>
/// 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 <c>table.Header</c>) is the idiomatic
/// QuestPDF pattern for repeating group headings across page breaks.
/// </summary>
private void ComposeCatalogContent(IContainer container, IEnumerable<IGrouping<CatalogCategory, CatalogItem>> 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 ─────────────────────────────────────────────────────
/// <summary>
/// Generates a Profit &amp; Loss statement PDF for the date range stored in <paramref name="dto"/>.
/// Renders a KPI summary band (revenue, COGS, expenses, net income with colour-coded values),
/// then three report sections via <see cref="PlReportSection"/>: 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.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// Renders a single section (Revenue, COGS, or Expenses) of the P&amp;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 (<paramref name="revenueTotal"/>),
/// displaying "—" when revenue is zero to avoid a division-by-zero NaN in the PDF.
/// <paramref name="subtotalColor"/> 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).
/// </summary>
private void PlReportSection(IContainer container, string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> 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);
});
});
}
/// <summary>
/// Generates a Balance Sheet PDF as of the date in <paramref name="dto"/>.
/// 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 <see cref="PowderCoating.Application.DTOs.Accounting.BalanceSheetDto.IsBalanced"/> is false,
/// alerting accountants to a chart-of-accounts configuration issue without hiding the imbalance.
/// Sub-sections within each column are rendered by <see cref="BsSection"/>.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// 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 <paramref name="lines"/> is empty so the containing
/// column does not show an orphaned heading for accounts with no activity.
/// Called from <see cref="GenerateBalanceSheetPdfAsync"/> for both the Assets and Liabilities columns.
/// </summary>
private static void BsSection(ColumnDescriptor col, string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> 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
}
/// <summary>
/// Generates an Accounts Receivable Aging report PDF as of <see cref="PowderCoating.Application.DTOs.Accounting.ArAgingReportDto.AsOf"/>.
/// 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 <c>ShowEntire()</c> 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.
/// </summary>
public async Task<byte[]> 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, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 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", "130", "3160", "6190", "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();
});
}
/// <summary>
/// Generates a Sales &amp; Income report PDF covering the date range in <paramref name="dto"/>.
/// 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.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// Composes the shared header used by all four financial report PDFs (P&amp;L, Balance Sheet,
/// AR Aging, Sales &amp; 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.
/// </summary>
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);
});
}
/// <summary>
/// Appends a single KPI cell to a <see cref="RowDescriptor"/> 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.
/// </summary>
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
// =========================================================================
/// <summary>
/// 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 <see cref="ComposePOHeader"/> for the branding/PO-metadata
/// block and <see cref="ComposePOContent"/> for the vendor address, line-item table, totals, notes,
/// and authorisation signature line.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// 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 <see cref="ComposeInvoiceHeader"/> but uses
/// "PURCHASE ORDER" as the document type label. The header is separated from the body by a
/// 1pt accent-coloured rule.
/// </summary>
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
// =========================================================================
/// <summary>
/// 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 <see cref="ComposeGiftCertificateContent"/> which renders the double-border
/// frame, big-value amount, certificate code, redemption metadata, and optional remaining-balance
/// callout for partially redeemed certificates.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// 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.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> 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();
});
}
/// <summary>
/// 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.
/// </summary>
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);
}
});
});
});
}
/// <summary>
/// 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 <c>PaddingTop(32)</c> provides
/// enough blank space above the line for a handwritten signature.
/// </summary>
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 ─────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public Task<byte[]> 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();
});
}
/// <summary>
/// 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.
/// </summary>
public async Task<byte[]> 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, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 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", "130", "3160", "6190", "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();
});
}
/// <summary>
/// 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.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// 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.
/// </summary>
public async Task<byte[]> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public async Task<byte[]> 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();
});
}
/// <summary>
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
/// </summary>
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);
});
}
/// <summary>
/// Body of the packing slip: customer info block, optional PO number, and an items table
/// showing description, color, and quantity — no prices.
/// </summary>
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);
});
});
});
}
}