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