import type { InvoiceTemplateConfig } from "../db/schema"; import { formatCurrency } from "./templates/types"; // NOTE: only TYPE imports from the React `templates-html` module here — a value // import would pull the .tsx templates into non-JSX consumers (e.g. the CLI) // type-check graph. The neutral default config is imported dynamically inside // `sendInvoiceEmail`, alongside the renderer. import type { InvoiceTemplateData } from "./templates-html/types"; import type { EmailConfig, InvoicePDFData } from "./types"; /** * Adapt the email module's `InvoicePDFData` into the normalized * `templates-html` payload (invoice data + branding config) consumed by the * shared HTML -> PDF pipeline. Branding colors/layout fall back to the passed * neutral default config; company + payment details come from the invoice data. */ function toTemplatePayload( data: InvoicePDFData, defaultConfig: InvoiceTemplateConfig, ): { invoice: InvoiceTemplateData; config: InvoiceTemplateConfig; } { const config: InvoiceTemplateConfig = { ...defaultConfig, companyName: data.from.companyName, companyAddress: data.from.address, companyEmail: data.from.email, companyPhone: data.from.phone, paymentMethods: data.paymentMethods, }; const invoice: InvoiceTemplateData = { invoiceNumber: data.invoiceNumber, issueDate: data.invoiceDate, dueDate: data.dueDate, periodStart: data.periodStart, periodEnd: data.periodEnd, clientName: data.to.name, clientEmail: data.to.email, clientAddress: data.to.address, subtotal: data.subtotal, taxRate: data.taxRate, taxAmount: data.taxAmount, total: data.total, currency: data.currency, notes: data.notes, paymentNotes: data.paymentNotes, lineItems: data.lineItems.map((item) => ({ description: item.description, quantity: item.quantity, quantityType: item.quantityType, rate: item.rate, amount: item.amount, projectName: item.projectName, })), workBreakdown: data.workBreakdown, }; return { invoice, config }; } /** * Render an invoice PDF from the legacy `InvoicePDFData` shape through the * shared HTML -> Chromium pipeline — the same config-driven `templates-html` * template the web hub and desktop app preview and download. Branding falls * back to the neutral default config, overlaid with the invoice's own company * and payment details. * * The heavy renderer (playwright-core, plus @sparticuz/chromium on serverless) * and the .tsx templates are imported dynamically, so non-PDF consumers of * `@scribe.cool/core/invoice` (e.g. the CLI's type-check graph) never pull them * in. This is the single entry point every legacy-`InvoicePDFData` caller * (CLI PDF export, email attachment) shares, so the templates can no longer * drift between renderers. */ export async function renderInvoicePdfFromData( data: InvoicePDFData, templateName?: string, ): Promise { const [{ renderInvoiceHtmlPdf }, { DEFAULT_HTML_TEMPLATE_CONFIG }] = await Promise.all([ import("./render-html-pdf"), import("./templates-html"), ]); const { invoice, config } = toTemplatePayload( data, DEFAULT_HTML_TEMPLATE_CONFIG, ); return renderInvoiceHtmlPdf({ invoice, config, templateName }); } export interface SendInvoiceEmailOptions { data: InvoicePDFData; to: string; cc?: string; emailConfig: EmailConfig; templateName?: string; pdfBytes?: Uint8Array; // Pre-generated PDF; if omitted, generates one } function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } export async function sendInvoiceEmail( options: SendInvoiceEmailOptions, ): Promise<{ success: boolean; message: string }> { if (!options.emailConfig.resendApiKey) { throw new Error( "Email not configured: resendApiKey is required in emailConfig. Set it in ~/.scribe/config.json under email.resendApiKey.", ); } if (!options.emailConfig.defaultFrom) { throw new Error( "Email not configured: defaultFrom address is required in emailConfig. Set it in ~/.scribe/config.json under email.defaultFrom.", ); } const { Resend } = await import("resend"); const resend = new Resend(options.emailConfig.resendApiKey); // Render the attached PDF through the shared HTML -> PDF pipeline (the same // config-driven `templates-html` template the apps preview). Delegated to // `renderInvoicePdfFromData`, which dynamically imports the heavy renderer so // it is pulled in only when an email actually needs to generate a PDF. let pdfBytes = options.pdfBytes; if (!pdfBytes) { pdfBytes = await renderInvoicePdfFromData( options.data, options.templateName, ); } const { data, to, cc, emailConfig } = options; const fromName = data.from.companyName.replace(/[<>"]/g, ""); const fromEmail = emailConfig.defaultFrom; const clientFirstName = data.to.name.split(" ")[0] || data.to.name; const totalStr = formatCurrency(data.total, data.currency); const safeFilename = data.invoiceNumber.replace(/[^a-zA-Z0-9_\-.]/g, "_"); const { error } = await resend.emails.send({ from: `${fromName} <${fromEmail}>`, to, cc: cc || undefined, subject: `Invoice ${data.invoiceNumber} from ${data.from.companyName}`, html: buildEmailHtml({ greeting: `Hi ${escapeHtml(clientFirstName)},`, invoiceNumber: escapeHtml(data.invoiceNumber), formattedTotal: escapeHtml(totalStr), paymentNotes: data.paymentNotes, companyName: escapeHtml(data.from.companyName), }), attachments: [ { filename: `${safeFilename}.pdf`, content: uint8ArrayToBase64(pdfBytes), }, ], }); if (error) { return { success: false, message: `Failed to send: ${error.message}` }; } return { success: true, message: `Invoice sent to ${to}` }; } interface EmailHtmlParams { greeting: string; invoiceNumber: string; formattedTotal: string; paymentNotes?: string; companyName: string; } function buildEmailHtml(params: EmailHtmlParams): string { const { greeting, invoiceNumber, formattedTotal, paymentNotes, companyName } = params; const paymentSection = paymentNotes ? `

Payment Instructions

${escapeHtml(paymentNotes)}

` : ""; return `

${greeting}

Please find attached invoice ${invoiceNumber} for ${formattedTotal}.

${paymentSection}

Thank you for your business.

Best regards,
${companyName}


This invoice was sent via Scribe. If you have any questions, please reply to this email.

`; }