Invoice working in Go

This commit is contained in:
Finley Ghosh 2026-01-16 23:26:56 +11:00
parent 934150282f
commit 67dba461f2
19 changed files with 2115 additions and 179 deletions

View file

@ -2,6 +2,19 @@ FROM golang:1.24-alpine
WORKDIR /app WORKDIR /app
# Install Chromium and dependencies for chromedp
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
# Set environment variable for chromedp to find chromium
ENV CHROME_BIN=/usr/bin/chromium-browser \
CHROME_PATH=/usr/bin/chromium-browser
# Copy go.mod and go.sum first # Copy go.mod and go.sum first
COPY go/go.mod go/go.sum ./ COPY go/go.mod go/go.sum ./
@ -19,6 +32,9 @@ COPY go/ .
# Generate sqlc code # Generate sqlc code
RUN sqlc generate RUN sqlc generate
# Ensure static assets are available
COPY go/static ./static
# Copy Air config # Copy Air config
COPY go/.air.toml .air.toml COPY go/.air.toml .air.toml

View file

@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/m
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/ WORKDIR /app
COPY --from=builder /app/server . COPY --from=builder /app/server .
COPY --from=builder /app/vault . COPY --from=builder /app/vault .
COPY go/templates ./templates COPY go/templates ./templates

View file

@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/ WORKDIR /app
COPY --from=builder /app/server . COPY --from=builder /app/server .
COPY go/templates ./templates COPY go/templates ./templates
COPY go/static ./static COPY go/static ./static

View file

@ -84,6 +84,7 @@ func main() {
// PDF generation routes (under /api/pdf/* to avoid conflict with file server) // PDF generation routes (under /api/pdf/* to avoid conflict with file server)
goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST") goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-invoice-html", handlers.GenerateInvoicePDFHTML).Methods("POST") // HTML version
goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST") goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST") goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST") goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")

View file

@ -4,21 +4,31 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
) )
// escapeToHTML converts plain text to HTML with newlines as <br>
func escapeToHTML(s string) string {
s = html.EscapeString(s)
s = strings.ReplaceAll(s, "\n", "<br>")
return s
}
// InvoiceLineItemRequest is the JSON shape for a single line item. // InvoiceLineItemRequest is the JSON shape for a single line item.
type InvoiceLineItemRequest struct { type InvoiceLineItemRequest struct {
ItemNumber string `json:"item_number"` ItemNumber string `json:"item_number"`
Quantity string `json:"quantity"` Quantity string `json:"quantity"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
IsHTML bool `json:"is_html"` // Flag to indicate description contains HTML
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"` TotalPrice float64 `json:"total_price"`
NetUnitPrice float64 `json:"net_unit_price"` NetUnitPrice float64 `json:"net_unit_price"`
@ -35,6 +45,7 @@ type InvoiceLineItemRequest struct {
// InvoicePDFRequest is the expected payload from the PHP app. // InvoicePDFRequest is the expected payload from the PHP app.
type InvoicePDFRequest struct { type InvoicePDFRequest struct {
DocumentID int32 `json:"document_id"` DocumentID int32 `json:"document_id"`
InvoiceNumber string `json:"invoice_number"` // e.g. "INV-001234"
InvoiceTitle string `json:"invoice_title"` InvoiceTitle string `json:"invoice_title"`
CustomerName string `json:"customer_name"` CustomerName string `json:"customer_name"`
ContactEmail string `json:"contact_email"` ContactEmail string `json:"contact_email"`
@ -106,11 +117,15 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems)) lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems { for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{ lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber, ItemNumber: li.ItemNumber,
Quantity: li.Quantity, Quantity: li.Quantity,
Title: li.Title, Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true}, Description: li.Description,
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true}, GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
DiscountPercent: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountPercent), Valid: li.DiscountPercent > 0},
DiscountAmountUnit: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountUnit), Valid: li.DiscountAmountUnit > 0},
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
} }
} }
@ -154,6 +169,111 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename}) _ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
} }
// GenerateInvoicePDFHTML generates invoice using HTML template and chromedp
func GenerateInvoicePDFHTML(w http.ResponseWriter, r *http.Request) {
var req InvoicePDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.InvoiceTitle == "" || req.CustomerName == "" {
http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
return
}
issueDate := time.Now()
if req.IssueDate != "" {
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
issueDate = parsed
}
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GenerateInvoicePDFHTML: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
// Map request into the existing PDF generation types
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
inv := &db.Invoice{Title: req.InvoiceTitle}
cust := &db.Customer{Name: req.CustomerName}
log.Printf("GenerateInvoicePDFHTML: Setting invoice number to: %s", req.InvoiceNumber)
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
// Escape description if it's not HTML
desc := li.Description
if !li.IsHTML {
// Escape plain text to HTML entities
desc = escapeToHTML(desc)
}
// Calculate the final price after discount
finalPrice := li.TotalPrice - li.DiscountAmountTotal
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
Description: desc,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", finalPrice), Valid: true},
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
}
}
data := &pdf.InvoicePDFData{
Document: doc,
Invoice: inv,
Customer: cust,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
ShipVia: req.ShipVia,
FOB: req.FOB,
IssueDate: issueDate,
IssueDateString: req.IssueDateString,
EmailTo: req.ContactEmail,
Attention: req.ContactName,
FromName: fmt.Sprintf("%s %s", req.UserFirstName, req.UserLastName),
FromEmail: req.UserEmail,
YourReference: req.YourReference,
BillTo: req.BillTo,
ShipTo: req.ShipTo,
ShippingDetails: req.ShippingDetails,
CustomerOrderNumber: req.CustomerOrderNumber,
JobTitle: req.JobTitle,
PaymentTerms: req.PaymentTerms,
CustomerABN: req.CustomerABN,
Subtotal: req.Subtotal,
GSTAmount: req.GSTAmount,
Total: req.Total,
}
// Use HTML generator instead of gofpdf
htmlGen := pdf.NewHTMLInvoiceGenerator(outputDir)
filename, err := htmlGen.GenerateInvoicePDF(data)
if err != nil {
log.Printf("GenerateInvoicePDFHTML: failed to generate PDF: %v", err)
http.Error(w, fmt.Sprintf("failed to generate PDF: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// QuoteLineItemRequest reuses the invoice item shape // QuoteLineItemRequest reuses the invoice item shape
type QuoteLineItemRequest = InvoiceLineItemRequest type QuoteLineItemRequest = InvoiceLineItemRequest

View file

@ -0,0 +1,7 @@
package pdf
// formatDescription passes through descriptions as-is
// HTML formatting should be applied in PHP before sending to this API
func formatDescription(text string) string {
return text
}

View file

@ -0,0 +1,115 @@
package pdf
import (
"strings"
"testing"
)
func TestFormatDescription(t *testing.T) {
input := `Item Code: B25SEZ22B0
Item Description: SE Sensor Zone 22
Type: SE - To control the function of the rupture disc
Cable Length: 2m
Ui< 40V
li<57mA
Li, Ci negligible
II 2G Ex ib IIC T6 (Gb)
II 2D Ex ib IIC T 80 deg C IP65 ((Db) -25 deg C < Ta < +80 deg C
IBEx U1ATEX1017
Includes installation instruction
With standard Angle Bracket to suit Brilex Non-Insulated Explosion Vents
(If Insulated panels are used a modified (vertically extended) bracket needs to be used)
See attached EC Conformity Declaration for the SE Sensor
Testing at
1. -4 deg C
2. -30 deg C
3. 20 deg C`
output := formatDescription(input)
// Check that key: value pairs are bolded
if !strings.Contains(output, "<strong>Item Code:</strong>") {
t.Error("Item Code should be bolded")
}
if !strings.Contains(output, "<strong>Item Description:</strong>") {
t.Error("Item Description should be bolded")
}
// Check that list items are in <ol><li> tags
if !strings.Contains(output, "<ol>") {
t.Error("Ordered list should have <ol> tag")
}
if !strings.Contains(output, "<li>-4 deg C</li>") {
t.Error("List item 1 not formatted correctly")
}
// Check that italic patterns are applied
if !strings.Contains(output, "<em>See attached EC Conformity Declaration for the SE Sensor</em>") {
t.Error("Italic pattern not applied to EC Conformity Declaration text")
}
// Verify HTML tags are properly balanced (count opening/closing tag pairs)
strongOpens := strings.Count(output, "<strong>")
strongCloses := strings.Count(output, "</strong>")
if strongOpens != strongCloses {
t.Errorf("Unbalanced <strong> tags: %d opens, %d closes", strongOpens, strongCloses)
}
emOpens := strings.Count(output, "<em>")
emCloses := strings.Count(output, "</em>")
if emOpens != emCloses {
t.Errorf("Unbalanced <em> tags: %d opens, %d closes", emOpens, emCloses)
}
olOpens := strings.Count(output, "<ol>")
olCloses := strings.Count(output, "</ol>")
if olOpens != olCloses {
t.Errorf("Unbalanced <ol> tags: %d opens, %d closes", olOpens, olCloses)
}
t.Logf("Formatted output:\n%s", output)
}
func TestIsOrderedListItem(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"1. Item one", true},
{"2. Item two", true},
{"10. Item ten", true},
{"Item without number", false},
{"1 Item without dot", false},
{"Item: with colon", false},
}
for _, test := range tests {
result := isOrderedListItem(test.input)
if result != test.expected {
t.Errorf("isOrderedListItem(%q) = %v, want %v", test.input, result, test.expected)
}
}
}
func TestFormatLine(t *testing.T) {
tests := []struct {
input string
contains string
}{
{"Item Code: B25SEZ22B0", "<strong>Item Code:</strong>"},
{"Type: SE - To control", "<strong>Type:</strong>"},
{"Random text here", "Random text here"},
}
for _, test := range tests {
result := formatLine(test.input)
if !strings.Contains(result, test.contains) {
t.Errorf("formatLine(%q) should contain %q, got %q", test.input, test.contains, result)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,389 @@
package pdf
import (
"bytes"
"context"
"database/sql"
"fmt"
"html/template"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// HTMLInvoiceGenerator generates PDF invoices from HTML templates using chromedp
type HTMLInvoiceGenerator struct {
outputDir string
}
// NewHTMLInvoiceGenerator creates a new HTML-based invoice generator
func NewHTMLInvoiceGenerator(outputDir string) *HTMLInvoiceGenerator {
return &HTMLInvoiceGenerator{
outputDir: outputDir,
}
}
// GenerateInvoicePDF creates a PDF invoice from HTML template
func (g *HTMLInvoiceGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string, error) {
fmt.Println("=== HTML Generator: Starting invoice generation (two-pass with T&C page count) ===")
// FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C)
fmt.Println("=== HTML Generator: First pass - generating without page count ===")
html := g.buildInvoiceHTML(data, 0, 0)
fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html))
tempHTML := filepath.Join(g.outputDir, "temp_invoice.html")
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
return "", fmt.Errorf("failed to write temp HTML: %w", err)
}
defer os.Remove(tempHTML)
defer os.Remove(filepath.Join(g.outputDir, "invoice_logo.png")) // Clean up temp logo
defer os.Remove(filepath.Join(g.outputDir, "temp_logo.png")) // Clean up temp logo
// Generate temp PDF
tempPDFPath := filepath.Join(g.outputDir, "temp_invoice_first_pass.pdf")
if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil {
return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err)
}
// Get initial page count from invoice
invoicePageCount, err := g.getPageCount(tempPDFPath)
if err != nil {
fmt.Printf("Warning: Could not extract invoice page count: %v\n", err)
invoicePageCount = 1
}
// Check if T&C exists and merge to get total page count
totalPageCount := invoicePageCount
termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf")
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_first_pass.pdf", data.Invoice.Title))
if _, err := os.Stat(termsPath); err == nil {
fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===")
if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil {
// Get total page count from merged PDF
totalPageCount, err = g.getPageCount(tempMergedPath)
if err != nil {
fmt.Printf("Warning: Could not extract merged page count, using invoice count: %v\n", err)
totalPageCount = invoicePageCount
} else {
fmt.Printf("=== HTML Generator: Total pages (invoice + T&C): %d ===\n", totalPageCount)
}
} else {
fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err)
}
}
fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount)
os.Remove(tempPDFPath)
os.Remove(tempMergedPath)
// SECOND PASS: Generate final PDF with correct page count
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
html = g.buildInvoiceHTML(data, totalPageCount, 1)
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
}
// Generate final PDF filename using invoice number; fallback to a stable default
invoiceNumber := ""
if data.Document != nil {
invoiceNumber = data.Document.CmcReference
}
filenameBase := invoiceNumber
if filenameBase == "" {
filenameBase = "CMC Invoice"
}
filename := fmt.Sprintf("%s.pdf", filenameBase)
pdfPath := filepath.Join(g.outputDir, filename)
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err)
}
fmt.Println("=== HTML Generator: PDF generation complete ===")
// Merge with T&C PDF if it exists
if _, err := os.Stat(termsPath); err == nil {
fmt.Println("=== HTML Generator: Found T&C PDF, merging ===")
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase))
if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil {
fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning invoice without T&C.\n", err)
return filename, nil
}
// Replace original with merged version
if err := os.Rename(tempMergedPath, pdfPath); err != nil {
fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err)
}
}
return filename, nil
}
// htmlToPDF converts an HTML file to PDF using chromedp
func (g *HTMLInvoiceGenerator) htmlToPDF(htmlPath, pdfPath string) error {
// Read the HTML file to get its content
htmlContent, err := ioutil.ReadFile(htmlPath)
if err != nil {
return fmt.Errorf("failed to read HTML file: %w", err)
}
fmt.Printf("=== htmlToPDF: Read HTML file, size=%d bytes ===\n", len(htmlContent))
// Create chromedp context
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
// Set timeout
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Navigate to file URL and print to PDF
var pdfBuf []byte
fileURL := "file://" + htmlPath
fmt.Printf("=== htmlToPDF: Using file URL: %s ===\n", fileURL)
fmt.Println("=== htmlToPDF: Starting chromedp navigation ===")
if err := chromedp.Run(ctx,
chromedp.Navigate(fileURL),
chromedp.ActionFunc(func(ctx context.Context) error {
fmt.Println("=== htmlToPDF: Executing PrintToPDF ===")
var err error
pdfBuf, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(8.27). // A4 width in inches
WithPaperHeight(11.69). // A4 height in inches
Do(ctx)
if err != nil {
fmt.Printf("=== htmlToPDF: PrintToPDF error: %v ===\n", err)
} else {
fmt.Printf("=== htmlToPDF: PrintToPDF succeeded, PDF size=%d bytes ===\n", len(pdfBuf))
}
return err
}),
); err != nil {
return fmt.Errorf("chromedp failed: %w", err)
}
// Write PDF to file
if err := ioutil.WriteFile(pdfPath, pdfBuf, 0644); err != nil {
return fmt.Errorf("failed to write PDF: %w", err)
}
return nil
}
// getPageCount extracts the page count from a PDF file
func (g *HTMLInvoiceGenerator) getPageCount(pdfPath string) (int, error) {
pageCount, err := api.PageCountFile(pdfPath)
if err != nil {
return 0, fmt.Errorf("failed to get page count: %w", err)
}
return pageCount, nil
}
// loadLogoAsBase64 loads the logo image and returns it as a relative path
func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
// Use canonical path: /app/static/images in Docker, go/static/images locally
logoPath := "/app/static/images/CMC-Mobile-Logo.png"
if _, err := os.Stat(logoPath); err != nil {
// Local development path
logoPath = filepath.Join("go", "static", "images", "CMC-Mobile-Logo.png")
}
logoData, err := ioutil.ReadFile(logoPath)
if err != nil {
fmt.Printf("Warning: Could not read logo at %s: %v\n", logoPath, err)
return ""
}
// Copy logo to output directory for chromedp to access
destPath := filepath.Join(g.outputDir, "invoice_logo.png")
if err := ioutil.WriteFile(destPath, logoData, 0644); err != nil {
fmt.Printf("Warning: Could not write logo to output dir: %v\n", err)
return ""
}
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
// Return relative path (same directory as HTML file)
return "invoice_logo.png"
}
// buildInvoiceHTML generates the complete HTML for an invoice using templates
func (g *HTMLInvoiceGenerator) buildInvoiceHTML(data *InvoicePDFData, totalPages int, currentPage int) string {
// Get invoice number, fall back to invoice title so the template always shows something meaningful
invoiceNum := ""
if data.Document != nil {
invoiceNum = data.Document.CmcReference
}
if invoiceNum == "" {
invoiceNum = data.Invoice.Title
}
fmt.Printf("=== buildInvoiceHTML: Invoice number: %s ===\n", invoiceNum)
// Prepare template data
templateData := struct {
InvoiceNumber string
IssueDateString string
BillTo template.HTML
ShipTo template.HTML
CustomerOrderNumber string
JobTitle string
FOB string
PaymentTerms string
CustomerABN string
CurrencyCode string
CurrencySymbol string
LineItems []LineItemTemplateData
Subtotal interface{}
GSTAmount interface{}
Total interface{}
ShowGST bool
PageCount int
CurrentPage int
LogoDataURI string
}{
InvoiceNumber: invoiceNum,
IssueDateString: data.IssueDateString,
BillTo: template.HTML(data.BillTo),
ShipTo: template.HTML(data.ShipTo),
CustomerOrderNumber: data.CustomerOrderNumber,
JobTitle: data.JobTitle,
FOB: data.FOB,
PaymentTerms: data.PaymentTerms,
CustomerABN: data.CustomerABN,
CurrencyCode: data.CurrencyCode,
CurrencySymbol: data.CurrencySymbol,
Subtotal: data.Subtotal,
GSTAmount: data.GSTAmount,
Total: data.Total,
ShowGST: data.ShowGST,
PageCount: totalPages,
CurrentPage: currentPage,
LogoDataURI: g.loadLogoAsBase64(),
}
// Convert line items to template format
for _, item := range data.LineItems {
templateData.LineItems = append(templateData.LineItems, LineItemTemplateData{
Title: item.Title,
Description: template.HTML(item.Description), // Allow HTML in description
Quantity: item.Quantity,
GrossUnitPrice: item.GrossUnitPrice,
DiscountAmountTotal: item.DiscountAmountTotal,
GrossPrice: item.GrossPrice,
})
}
// Define template functions
funcMap := template.FuncMap{
"formatPrice": func(price sql.NullString) template.HTML {
if !price.Valid || price.String == "" {
return ""
}
formatted := formatPriceWithCommas(price.String)
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
},
"formatDiscount": func(discount sql.NullString) template.HTML {
if !discount.Valid || discount.String == "" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
if discount.String == "0" || discount.String == "0.00" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
formatted := formatPriceWithCommas(discount.String)
return template.HTML(fmt.Sprintf("-%s%s", data.CurrencySymbol, formatted))
},
"formatTotal": func(amount interface{}) template.HTML {
if amount == nil {
return ""
}
if val, ok := amount.(float64); ok {
formatted := formatPriceWithCommas(fmt.Sprintf("%.2f", val))
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
}
return template.HTML(fmt.Sprintf("%v", amount))
},
}
// Parse and execute template
tmplPath := filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html")
tmpl, err := template.New("invoice.html").Funcs(funcMap).ParseFiles(tmplPath)
if err != nil {
fmt.Printf("Error parsing template: %v\n", err)
return ""
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
fmt.Printf("Error executing template: %v\n", err)
return ""
}
return buf.String()
}
// LineItemTemplateData represents a line item for template rendering
type LineItemTemplateData struct {
Title string
Description template.HTML
Quantity string
GrossUnitPrice sql.NullString
DiscountAmountTotal sql.NullString
GrossPrice sql.NullString
}
// formatPriceWithCommas formats a price string with comma separators
func formatPriceWithCommas(priceStr string) string {
if priceStr == "" {
return ""
}
// Try to parse as float
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
// Return as-is if not parseable
return priceStr
}
// Format with 2 decimal places and commas
amountStr := strconv.FormatFloat(price, 'f', 2, 64)
parts := strings.Split(amountStr, ".")
intPart := parts[0]
decPart := "." + parts[1]
// Add comma separators
var result strings.Builder
for i, ch := range intPart {
if i > 0 && (len(intPart)-i)%3 == 0 {
result.WriteString(",")
}
result.WriteRune(ch)
}
return result.String() + decPart
}

View file

@ -202,11 +202,12 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
gen.AddPage() gen.AddPage()
gen.Page1Header() gen.Page1Header()
// Title // Title - positioned center with proper spacing
gen.pdf.SetFont("Helvetica", "B", 18) gen.pdf.SetFont("Helvetica", "B", 20)
gen.pdf.SetTextColor(0, 0, 0) gen.pdf.SetTextColor(0, 0, 0)
gen.pdf.CellFormat(0, 10, "TAX INVOICE", "", 1, "C", false, 0, "") gen.pdf.SetY(54)
gen.pdf.Ln(5) gen.pdf.CellFormat(0, 8, "TAX INVOICE", "", 1, "C", false, 0, "")
gen.pdf.Ln(3)
// Sold To / Delivery Address boxes // Sold To / Delivery Address boxes
gen.AddInvoiceAddressBoxes(data) gen.AddInvoiceAddressBoxes(data)
@ -230,9 +231,10 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
} }
// Merge with actual terms and conditions PDF using pdfcpu // Merge with actual terms and conditions PDF using pdfcpu
// The T&C file should be mounted in the same directory as output PDFs in stg/prod
termsPath := filepath.Join(outputDir, "CMC_terms_and_conditions2006_A4.pdf") termsPath := filepath.Join(outputDir, "CMC_terms_and_conditions2006_A4.pdf")
// Check if terms file exists // Check if terms file exists and merge
if _, err := os.Stat(termsPath); err == nil { if _, err := os.Stat(termsPath); err == nil {
// Terms file exists, merge it // Terms file exists, merge it
finalPath := invoicePath finalPath := invoicePath

View file

@ -0,0 +1,441 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{if .InvoiceNumber}}{{.InvoiceNumber}}{{else}}CMC Invoice{{end}}</title>
<style>
@page {
size: A4;
margin: 15mm;
}
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.4;
margin: 0;
padding: 0;
color: #000;
}
a {
color: #0000FF;
text-decoration: underline;
}
.header {
text-align: center;
margin-bottom: 10mm;
}
.header h1 {
font-size: 20pt;
margin: 0 0 5mm 0;
font-weight: bold;
}
.address-boxes {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
}
.address-box {
width: 48%;
border: 1px solid #000;
padding: 3mm;
}
.address-box h3 {
font-size: 10pt;
margin: 0 0 2mm 0;
font-weight: bold;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 5mm;
margin-right: 0.2mm;
}
.details-table td {
border: 1px solid #000;
padding: 2mm;
}
.details-table td:first-child {
font-weight: bold;
width: 25%;
}
.line-items {
width: 99.5%;
border-collapse: collapse;
margin-bottom: 5mm;
table-layout: fixed;
margin-right: 1mm;
margin-left: auto;
}
.line-items th {
background-color: #f0f0f0;
border: 1px solid #000;
padding: 2mm;
font-weight: bold;
text-align: left;
}
.line-items td {
border: 1px solid #000;
padding: 2mm;
vertical-align: top;
}
.line-items .description {
width: 50%;
}
.line-items .qty {
width: 10%;
text-align: center;
}
.line-items .unit-price {
width: 13%;
text-align: right;
}
.line-items .discount {
width: 13%;
text-align: right;
}
.line-items .total {
width: 14%;
margin-top: 5mm;
}
.totals-table {
width: auto;
border-collapse: collapse;
border: 1px solid #000;
margin-right: 0.2mm;
}
.totals-table td {
padding: 1mm 3mm;
border: 1px solid #000;
}
.totals-table td:first-child {
text-align: left;
font-weight: bold;
width: 50%;
background-color: #f0f0f0; /* header-like label cell */
}
.totals-table td:last-child {
text-align: right;
width: 50%;
}
.totals-table .total-row td {
font-weight: bold;
font-size: 11pt;
padding-top: 2mm;
border-top: 1px solid #000;
}
.description strong {
font-weight: bold;
}
.description em {
font-style: italic;
}
.description ul {
margin: 2mm 0;
padding-left: 5mm;
}
.description ol {
margin: 2mm 0;
padding-left: 5mm;
}
.description li {
margin: 1mm 0;
}
.company-header {
text-align: center;
margin-bottom: 8mm;
border-bottom: 2px solid #000;
padding-bottom: 5mm;
}
.company-header h1 {
font-size: 24pt;
margin: 0;
font-weight: bold;
color: #003399;
}
.company-header p {
margin: 2mm 0;
font-size: 9pt;
}
.contact-details {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
font-size: 9pt;
}
.contact-col {
flex: 1;
}
.contact-col strong {
font-weight: bold;
}
.invoice-header {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
font-size: 10pt;
}
.invoice-info {
text-align: right;
}
.invoice-info div {
margin: 1mm 0;
}
.payment-box {
border: 1px solid #000;
padding: 3mm;
margin-top: 5mm;
font-size: 9pt;
}
.payment-box h4 {
margin: 0 0 2mm 0;
font-weight: bold;
font-size: 10pt;
}
.payment-box div {
margin: 1mm 0;
}
.logo {
width: 40mm;
height: auto;
display: block;
}
.company-info {
display: flex;
align-items: flex-start;
gap: 10mm;
margin-bottom: 0;
padding-bottom: 0;
}
.company-header {
flex: 1;
}
.company-header h1 {
color: #003399;
margin: 0;
font-size: 20pt;
}
.company-header p {
margin: 1mm 0;
font-size: 9pt;
}
.main-content {
display: flex;
gap: 10mm;
margin-bottom: 5mm;
}
.addresses-section {
flex: 1;
}
.payment-section {
display: none;
}
.payment-section h4 {
margin: 0 0 2mm 0;
font-weight: bold;
font-size: 10pt;
}
.payment-section div {
margin: 1mm 0;
font-size: 9pt;
}
.payment-box {
border: 1px solid #000;
padding: 3mm;
margin-top: 5mm;
font-size: 9pt;
}
.payment-box h4 {
margin: 0 0 2mm 0;
font-weight: bold;
font-size: 10pt;
}
.payment-box div {
margin: 1mm 0;
}
</style>
</head>
<body>
<!-- Company Header with Logo and Centered Title -->
<div style="display: flex; align-items: flex-start; gap: 2mm; margin-bottom: 0;">
<img src="{{.LogoDataURI}}" class="logo" alt="CMC Logo" style="width: 40mm; height: auto; display: block; padding-right: 5mm;">
<div style="flex: 1;">
<div style="border-bottom: 2px solid #000; padding-bottom: 0mm; text-align: center;">
<h1 style="font-size: 32pt; color: #003399; margin: 0 0 -1mm 0;">CMC TECHNOLOGIES</h1>
<p style="margin: 0mm 0 1mm 0; font-size: 10pt;">PTY LIMITED ACN: 085 991 224&nbsp;&nbsp;ABN: 47 085 991 224</p>
</div>
<!-- Contact Details Below Line -->
<div style="display: flex; justify-content: space-between; margin: 2mm 2mm 2mm 2mm; font-size: 10pt;">
<div>
<strong>Phone:</strong> +61 2 9669 4000<br>
<strong>Fax:</strong> +61 2 9669 4111<br>
<strong>Email:</strong> <a href="mailto:sales@cmctechnologies.com.au" style="color: #0000FF; text-decoration: underline;">sales@cmctechnologies.com.au</a><br>
<strong>Web Site:</strong> <a href="http://www.cmctechnologies.net.au" style="color: #0000FF; text-decoration: underline;">www.cmctechnologies.net.au</a>
</div>
<div style="text-align: right;">
Unit 19, 77 Bourke Rd<br>
Alexandria NSW 2015<br>
AUSTRALIA
</div>
</div>
</div>
</div>
<!-- TAX INVOICE Section with Invoice Number and Date -->
<div style="display: flex; justify-content: space-between; align-items: center; margin: 2mm 0 3mm 0;">
<div style="flex: 1;"></div>
<div style="flex: 1; text-align: center;">
<h2 style="font-size: 16pt; margin: 0; font-weight: bold;">TAX INVOICE</h2>
</div>
<div style="flex: 1; text-align: right; font-size: 10pt;">
{{if .InvoiceNumber}}
<div style="font-weight: bold; margin-bottom: 1mm;">INVOICE# {{.InvoiceNumber}}</div>
{{end}}
{{if .IssueDateString}}
<div><strong>Date:</strong> {{.IssueDateString}}</div>
{{end}}
{{if .PageCount}}
<div><strong>Page:</strong> {{.CurrentPage}} of {{.PageCount}}</div>
{{end}}
</div>
</div>
<!-- Address Boxes -->
<div class="address-boxes" style="display: flex; gap: 5mm; margin-bottom: 3mm;">
<div class="address-box" style="flex: 1;">
<h3>Sold To / Invoice Address:</h3>
<div>{{.BillTo}}</div>
</div>
<div class="address-box" style="flex: 1; margin-right: 1mm; margin-left: auto;">
<h3>Delivery Address:</h3>
<div>{{.ShipTo}}</div>
</div>
</div>
<!-- Details Table - 5 Columns -->
<table class="line-items" style="margin-bottom: 3mm;">
<thead>
<tr>
<th style="width: 20%;">CUSTOMER ORDER NO</th>
<th style="width: 20%;">CMC JOB #</th>
<th style="width: 20%;">INCOTERMS 2010</th>
<th style="width: 20%;">PAYMENT TERMS</th>
<th style="width: 20%;">CUSTOMER ABN</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{.CustomerOrderNumber}}</td>
<td>{{.JobTitle}}</td>
<td>{{.FOB}}</td>
<td>{{.PaymentTerms}}</td>
<td>{{.CustomerABN}}</td>
</tr>
</tbody>
</table>
<!-- Currency Code Header (appears once, not repeated) -->
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
Shown in {{.CurrencyCode}}
</div>
<!-- Line Items -->
<table class="line-items">
<thead>
<tr>
<th class="description">DESCRIPTION</th>
<th class="qty">QTY</th>
<th class="unit-price">UNIT PRICE</th>
<th class="discount">DISCOUNT</th>
<th class="total">TOTAL</th>
</tr>
</thead>
<tbody>
{{range .LineItems}}
<tr>
<td class="description"><strong>{{.Title}}</strong><br>{{.Description}}</td>
<td class="qty">{{.Quantity}}</td>
<td class="unit-price">{{formatPrice .GrossUnitPrice}}</td>
<td class="discount">{{formatDiscount .DiscountAmountTotal}}</td>
<td class="total">{{formatPrice .GrossPrice}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Payment and Totals Side by Side -->
<div style="display: flex; gap: 10mm; align-items: flex-start; margin-top: 5mm;">
<div style="width: 50%; padding: 3mm; font-size: 9pt; border: 1px solid #000;">
<h4 style="margin: 0 0 1mm 0; font-weight: bold; font-size: 10pt;">MAKE PAYMENT TO:</h4>
<table style="width: 100%; border-collapse: collapse; font-size: 9pt; table-layout: fixed;">
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">Account Name:</td><td style="padding: 0.3mm 0; white-space: nowrap;">CMC Technologies Pty Ltd</td></tr>
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">BSB:</td><td style="padding: 0.3mm 0; white-space: nowrap;">062-458</td></tr>
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">Account Number:</td><td style="padding: 0.3mm 0; white-space: nowrap;">10067982</td></tr>
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">SWIFT Code:</td><td style="padding: 0.3mm 0; white-space: nowrap;">CTBAAU2S</td></tr>
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">IBAN:</td><td style="padding: 0.3mm 0; white-space: nowrap;">0624581006782</td></tr>
</table>
</div>
<div style="width: 50%; margin-left: auto; margin-right: 1mm;">
<table class="totals-table" style="width: 100%;">
<tr><td>Subtotal:</td><td>{{formatTotal .Subtotal}}</td></tr>
{{if .ShowGST}}
<tr><td>GST (10%):</td><td>{{formatTotal .GSTAmount}}</td></tr>
{{end}}
<tr class="total-row"><td>TOTAL:</td><td>{{formatTotal .Total}}</td></tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,214 @@
<?php
/**
* DescriptionFormatter: Converts plain text product descriptions to formatted HTML
* This ensures rich text formatting (bold, italics, lists) is properly supported
* in PDF generation.
*/
class DescriptionFormatter {
/**
* Convert plain text description to HTML with formatting
*
* Handles:
* - Bold formatting for key labels (Item Code:, Type:, etc.)
* - Ordered lists (lines starting with "1. ", "2. ", etc.)
* - Italic formatting for specific phrases
* - Proper HTML entity escaping for special characters
*
* @param string $text Plain text description
* @return string HTML-formatted description
*/
public static function format($text) {
if (empty($text)) {
return '';
}
// Escape HTML special characters that aren't part of our formatting
// We need to do this carefully to preserve our HTML tags
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
// Split into lines for processing
$lines = explode("\n", $text);
$result = array();
$inOrderedList = false;
$listItems = array();
foreach ($lines as $line) {
$trimmed = trim($line);
if (empty($trimmed)) {
// Flush any pending list before adding empty line
if ($inOrderedList && count($listItems) > 0) {
$result[] = self::formatOrderedList($listItems);
$listItems = array();
$inOrderedList = false;
}
$result[] = '';
continue;
}
// Check if this is an ordered list item (starts with "1. ", "2. ", etc.)
if (self::isOrderedListItem($trimmed)) {
if (!$inOrderedList) {
$inOrderedList = true;
$listItems = array();
}
// Extract text after the number
$listItems[] = self::extractListItemText($trimmed);
} else {
// Flush any pending list before processing non-list line
if ($inOrderedList && count($listItems) > 0) {
$result[] = self::formatOrderedList($listItems);
$listItems = array();
$inOrderedList = false;
}
// Process regular line
$formatted = self::formatLine($trimmed);
$result[] = $formatted;
}
}
// Flush any remaining list
if ($inOrderedList && count($listItems) > 0) {
$result[] = self::formatOrderedList($listItems);
}
// Join with line breaks
$html = implode('<br>', $result);
// Clean up excessive line breaks
$html = preg_replace('/<br><br><br>+/', '<br><br>', $html);
return $html;
}
/**
* Check if a line is an ordered list item (e.g., "1. text")
*/
private static function isOrderedListItem($line) {
return preg_match('/^\d+\.\s+/', $line) === 1;
}
/**
* Extract the text part of a list item, removing the number
*/
private static function extractListItemText($line) {
$matches = array();
if (preg_match('/^\d+\.\s+(.*)$/', $line, $matches)) {
return isset($matches[1]) ? $matches[1] : $line;
}
return $line;
}
/**
* Convert an array of list items to HTML ordered list
*/
private static function formatOrderedList($items) {
if (count($items) === 0) {
return '';
}
$html = '<ol>';
foreach ($items as $item) {
$html .= '<li>' . $item . '</li>';
}
$html .= '</ol>';
return $html;
}
/**
* Format a single line - mainly applying bold to key labels
*/
private static function formatLine($line) {
// Match "Key: value" pattern
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $matches)) {
$key = $matches[1];
$value = isset($matches[2]) ? $matches[2] : '';
// Check if this key should be bolded
if (self::shouldBoldKey($key)) {
$line = '<strong>' . htmlspecialchars($key, ENT_QUOTES, 'UTF-8') . ':</strong> ' . $value;
}
}
// Apply italics to specific phrases
$line = self::applyItalics($line);
return $line;
}
/**
* Determine if a key should be bolded (heuristic)
*/
private static function shouldBoldKey($key) {
$key = trim($key);
// List of known keys that should be bolded
$knownKeys = array(
'Item Code' => true,
'Item Description' => true,
'Type' => true,
'Cable Length' => true,
'Ui' => true,
'li' => true,
'Li, Ci' => true,
'II 2G' => true,
'II 2D' => true,
'IBEx' => true,
'Includes' => true,
'With standard' => true,
'See attached' => true,
'Testing at' => true,
);
// Check exact matches
if (isset($knownKeys[$key])) {
return true;
}
// Check partial matches for common patterns
$lowerKey = strtolower($key);
$commonPatterns = array(
'code',
'description',
'type',
'cable',
'temperature',
'pressure',
'includes',
'testing',
);
foreach ($commonPatterns as $pattern) {
if (strpos($lowerKey, $pattern) !== false) {
return true;
}
}
return false;
}
/**
* Apply italic formatting to specific phrases
*/
private static function applyItalics($line) {
$italicPatterns = array(
'See attached EC Conformity Declaration for the SE Sensor',
'If Insulated panels are used',
);
foreach ($italicPatterns as $pattern) {
if (strpos($line, $pattern) !== false) {
$escapedPattern = htmlspecialchars($pattern, ENT_QUOTES, 'UTF-8');
$line = str_replace($escapedPattern, '<em>' . $escapedPattern . '</em>', $line);
}
}
return $line;
}
}
?>

View file

@ -2,7 +2,7 @@
// Generate the Invoice PDF by calling the Go service instead of TCPDF. // Generate the Invoice PDF by calling the Go service instead of TCPDF.
$goBaseUrl = AppController::getGoBaseUrlOrFail(); $goBaseUrl = AppController::getGoBaseUrlOrFail();
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice'; $goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice-html';
$outputDir = Configure::read('pdf_directory'); $outputDir = Configure::read('pdf_directory');
@ -13,6 +13,7 @@ foreach ($document['LineItem'] as $li) {
'quantity' => $li['quantity'], 'quantity' => $li['quantity'],
'title' => $li['title'], 'title' => $li['title'],
'description' => isset($li['description']) ? $li['description'] : '', 'description' => isset($li['description']) ? $li['description'] : '',
'is_html' => true, // Description is always HTML
'unit_price' => floatval($li['gross_unit_price']), 'unit_price' => floatval($li['gross_unit_price']),
'total_price' => floatval($li['gross_price']), 'total_price' => floatval($li['gross_price']),
'net_unit_price' => floatval($li['net_unit_price']), 'net_unit_price' => floatval($li['net_unit_price']),
@ -27,8 +28,17 @@ foreach ($document['LineItem'] as $li) {
); );
} }
$invoiceNumber = '';
if (!empty($document['Document']['cmc_reference'])) {
$invoiceNumber = $document['Document']['cmc_reference'];
} elseif (!empty($document['Invoice']['title'])) {
// Fallback so the Go service always receives a number-like value
$invoiceNumber = $document['Invoice']['title'];
}
$payload = array( $payload = array(
'document_id' => intval($document['Document']['id']), 'document_id' => intval($document['Document']['id']),
'invoice_number' => $invoiceNumber,
'invoice_title' => $document['Invoice']['title'], 'invoice_title' => $document['Invoice']['title'],
'customer_name' => $enquiry['Customer']['name'], 'customer_name' => $enquiry['Customer']['name'],
'contact_email' => $enquiry['Contact']['email'], 'contact_email' => $enquiry['Contact']['email'],

Binary file not shown.

Binary file not shown.