/** * Branded LogYou contract (Fulfillment-Rahmenvertrag) PDF builder. * Layout/brand components (BRAND, header, footer, logos) are reused from * quotePdf.ts; all legal wording lives in contractTexts.ts. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos } from './quotePdf' import { CONTRACT_TEXTS } from './contractTexts' const fmt = (template: string, vars: Record): string => String(template).replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')) const formatDateLocal = (iso: string, language: string): string => { if (!iso) return '—' const d = new Date(iso) if (isNaN(d.getTime())) return iso const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return language === 'en' ? `${d.getFullYear()}-${mm}-${dd}` : `${dd}.${mm}.${d.getFullYear()}` } export const sanitizeContractFilename = (company: string, language: string, date?: string): string => { const safe = String(company || 'Kunde') .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') .replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue') .replace(/ß/g, 'ss') .replace(/[^A-Za-z0-9-_ ]/g, '') .trim() .replace(/\s+/g, '-') || 'Kunde' const d = date || new Date().toISOString().slice(0, 10) const prefix = language === 'en' ? 'Contract' : 'Vertrag' return `${prefix}_LogYou_${safe}_${d}.pdf` } export const buildContractDocDefinition = (payload: any, logos: { light: string | null; dark: string | null }) => { const language = payload?.meta?.language === 'en' ? 'en' : 'de' const T = CONTRACT_TEXTS[language] const c = payload.customer || {} const a = payload.assumptions || {} const st = a.storage || {} const ct = payload.contract || {} const dateIso = payload?.meta?.date || new Date().toISOString().slice(0, 10) // Placeholder values substituted into the legal texts const vars = { company: c.company || '', offerDate: formatDateLocal(ct.offerDate || dateIso, language), noticeMonths: Number(ct.noticeMonths) || 3, priceNoticeMonths: Number(ct.priceNoticeMonths) || 2, objectionWeeks: Number(ct.objectionWeeks) || 6, curePeriodDays: Number(ct.curePeriodDays) || 3, cutoffTime: ct.cutoffTime || '14:00', shrinkagePct: Number(ct.shrinkagePct) || 1 } const sectionTitle = (text: string) => ({ stack: [ { canvas: [{ type: 'rect', x: 0, y: 0, w: 26, h: 3, color: BRAND.orange }] }, { text: String(text).toUpperCase(), fontSize: 11.5, bold: true, color: BRAND.navy, characterSpacing: 0.4, margin: [0, 5, 0, 0], tocItem: true, tocStyle: { fontSize: 9, color: BRAND.text } } ], margin: [0, 18, 0, 8] }) const renderBlock = (block: any, numberOffset: number = 0): any[] => { if (!block) return [] if (block.t === 'p') { return [{ text: fmt(block.text, vars), margin: [0, 0, 0, 7], alignment: 'justify' }] } if (block.t === 'b') { return [{ ul: block.items.map((item: string) => ({ text: fmt(item, vars), margin: [0, 0, 0, 5] })), markerColor: BRAND.orange, margin: [0, 0, 0, 7] }] } if (block.t === 'n') { // Each clause is individually unbreakable (heading stays with its text), // but the LIST is breakable — a fully-unbreakable section taller than one // page would be clipped by pdfmake. return block.items.map((item: any, idx: number) => ({ unbreakable: true, stack: [ ...(item.h ? [{ text: `${numberOffset + idx + 1}. ${fmt(item.h, vars)}`, bold: true, color: BRAND.navy, fontSize: 9.5, margin: [0, 0, 0, 2] }] : []), { text: item.h ? fmt(item.text, vars) : `${numberOffset + idx + 1}. ${fmt(item.text, vars)}`, alignment: 'justify' } ], margin: [0, 0, 0, 8] })) } return [] } const renderSection = (section: any): any[] => { if (!section?.blocks?.length) return [] const [first, ...rest] = section.blocks // Keep the title together with the first block (or, for numbered lists, // only the first clause) so headings never orphan at a page bottom while // the section itself stays breakable. if (first.t === 'n' && first.items.length > 1) { const [firstItem, ...restItems] = first.items return [ { unbreakable: true, stack: [sectionTitle(section.title), ...renderBlock({ t: 'n', items: [firstItem] })] }, ...renderBlock({ t: 'n', items: restItems }, 1), ...rest.flatMap((b: any) => renderBlock(b)) ] } return [ { unbreakable: true, stack: [sectionTitle(section.title), ...renderBlock(first)] }, ...rest.flatMap((b: any) => renderBlock(b)) ] } // ---- Cover ---- const cover: any[] = [ { text: T.docTitle, fontSize: 24, bold: true, color: BRAND.navy, alignment: 'center', margin: [0, 60, 0, 4] }, { text: `${T.dateWord}: ${formatDateLocal(dateIso, language)}`, fontSize: 10, color: BRAND.grayText, alignment: 'center', margin: [0, 0, 0, 50] }, { text: T.between, fontSize: 10, italics: true, color: BRAND.grayText, alignment: 'center', margin: [0, 0, 0, 10] }, { text: T.logyouParty, fontSize: 11, bold: true, alignment: 'center' }, { text: T.logyouRef, fontSize: 9, color: BRAND.grayText, alignment: 'center', margin: [0, 3, 0, 24] }, { text: T.and, fontSize: 10, italics: true, color: BRAND.grayText, alignment: 'center', margin: [0, 0, 0, 10] }, { text: c.company || '', fontSize: 12, bold: true, color: BRAND.navy, alignment: 'center' }, ...(c.contactName ? [{ text: c.contactName + (c.signerPosition ? ` — ${c.signerPosition}` : ''), fontSize: 10, alignment: 'center', margin: [0, 2, 0, 0] }] : []), ...(c.street ? [{ text: c.street, fontSize: 10, alignment: 'center', margin: [0, 2, 0, 0] }] : []), ...((c.zip || c.city) ? [{ text: [c.zip, c.city].filter(Boolean).join(' '), fontSize: 10, alignment: 'center', margin: [0, 2, 0, 0] }] : []), { text: c.country || (language === 'en' ? 'Germany' : 'Deutschland'), fontSize: 10, alignment: 'center', margin: [0, 2, 0, 0] }, ...(c.ustId ? [{ text: `${T.signatures.vatId}: ${c.ustId}`, fontSize: 9, color: BRAND.grayText, alignment: 'center', margin: [0, 2, 0, 0] }] : []), { text: T.customerRef, fontSize: 9, color: BRAND.grayText, alignment: 'center', margin: [0, 3, 0, 0] }, // Table of contents (native pdfmake TOC with real page numbers) { toc: { title: { text: T.tocTitle, fontSize: 13, bold: true, color: BRAND.navy, margin: [0, 0, 0, 8] } }, pageBreak: 'before' } ] // ---- Assumptions (dynamic) ---- const L = T.assumptions.labels const storageUnits = language === 'en' ? { small: 'shelf M', large: 'shelf L', pallet: 'pallet space(s)' } : { small: 'Fachboden M', large: 'Fachboden L', pallet: 'Palettenstellplatz/-plätze' } const storageParts: string[] = [] if (Number(st.shelfSmallQty) > 0) storageParts.push(`${st.shelfSmallQty}× ${storageUnits.small}`) if (Number(st.shelfLargeQty) > 0) storageParts.push(`${st.shelfLargeQty}× ${storageUnits.large}`) if (Number(st.palletQty) > 0) storageParts.push(`${st.palletQty}× ${storageUnits.pallet}`) if (Number(st.volumeM3) > 0) storageParts.push(`${st.volumeM3} m³`) const kvRow = (label: string, value: any) => [ { text: label, color: BRAND.grayText, fontSize: 9 }, { text: String(value || '—'), bold: true, fontSize: 9 } ] const assumptionsSection: any[] = [ { unbreakable: true, stack: [ sectionTitle(T.assumptions.title), { text: T.assumptions.intro, margin: [0, 0, 0, 8] }, { table: { widths: [210, '*'], body: [ kvRow(L.plannedStart, formatDateLocal(a.plannedStart, language)), kvRow(L.businessModel, a.businessModel), kvRow(L.shopSystem, a.shopSystem), kvRow(L.ordersPerMonth, a.ordersPerMonth), kvRow(L.avgPicks, a.avgPicksPerOrder), kvRow(L.skuCount, a.skuCount), kvRow(L.storage, storageParts.join(', ') || '—') ] }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, fillColor: (rowIndex: number) => (rowIndex % 2 === 0 ? BRAND.zebra : null), paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 }, margin: [0, 0, 0, 8] }, { text: T.assumptions.onboarding, bold: true, color: BRAND.navy, margin: [0, 2, 0, 8] } ] }, { text: fmt(T.assumptions.integration, vars), alignment: 'justify', margin: [0, 0, 0, 7] } ] // ---- SLA (with optional express clause) ---- const slaSection = JSON.parse(JSON.stringify(T.sla)) if (ct.includeExpress) { slaSection.blocks[0].items.push(T.sla.express) } // ---- Payment (method variant) ---- const paymentSection = { title: T.payment.title, blocks: [ct.paymentMethod === 'transfer' ? T.payment.transfer : T.payment.sepa, ...T.payment.common] } // ---- Marketing (variant) ---- const marketingSection = { title: T.marketing.title, blocks: [ct.marketingVariant === 'approval' ? T.marketing.approval : T.marketing.consent] } // ---- Custom special clauses (dynamic) ---- const customClauses = (ct.customClauses || []).filter((cl: any) => String(cl?.text || '').trim()) const specialSection: any[] = customClauses.length ? [ { unbreakable: true, stack: [sectionTitle(T.special.title), { text: T.special.intro, margin: [0, 0, 0, 8] }] }, ...customClauses.map((cl: any, idx: number) => ({ stack: [ { text: `${idx + 1}. ${cl.title || (language === 'en' ? 'Supplementary agreement' : 'Sondervereinbarung')}`, bold: true, color: BRAND.navy, fontSize: 9.5, margin: [0, 0, 0, 2] }, { text: String(cl.text), alignment: 'justify' } ], margin: [0, 0, 0, 8] })) ] : [] // ---- Signatures ---- const S = T.signatures const sigLine = (label: string, value: string) => ({ columns: [ { text: label, width: 110, color: BRAND.grayText, fontSize: 9 }, { text: value, width: '*', fontSize: 9.5 } ], margin: [0, 0, 0, 6] }) const signaturesSection: any[] = [ { unbreakable: true, stack: [ sectionTitle(S.title), { text: S.logyouCompany, bold: true, color: BRAND.navy, margin: [0, 6, 0, 2] }, { text: `${S.logyouCity}, ${formatDateLocal(dateIso, language)}`, fontSize: 9, color: BRAND.grayText, margin: [0, 0, 0, 26] }, { canvas: [{ type: 'line', x1: 0, y1: 0, x2: 220, y2: 0, lineWidth: 0.75, lineColor: BRAND.grayLine }] }, { text: `${S.logyouSigner} — ${S.logyouPosition}`, fontSize: 9, margin: [0, 4, 0, 24] }, { text: S.customerWord, bold: true, color: BRAND.navy, margin: [0, 6, 0, 8] }, sigLine(S.date, S.dots), sigLine(S.signature, S.dots), sigLine(S.name, c.contactName || S.dots), sigLine(S.position, c.signerPosition || S.dots), sigLine(S.company, c.company || S.dots), ...(c.ustId ? [sigLine(S.vatId, c.ustId)] : []), sigLine(S.address, [c.street, [c.zip, c.city].filter(Boolean).join(' '), c.country].filter(Boolean).join(' / ') || S.dots) ] } ] const content: any[] = [ ...cover, { text: '', pageBreak: 'after' }, ...renderSection(T.master), ...renderSection(T.services), ...renderSection(T.term), ...renderSection(T.pricing), ...assumptionsSection, ...renderSection(T.adjustments), ...renderSection(paymentSection), ...renderSection(slaSection), ...renderSection(T.liability), ...renderSection(T.property), ...renderSection(T.compliance), ...renderSection(T.privacy), ...renderSection(marketingSection), ...specialSection, ...renderSection(T.jurisdiction), ...renderSection(T.final), ...renderSection(T.annex), ...signaturesSection ] return { pageSize: 'A4', pageMargins: [40, 106, 40, 86], info: { title: `${T.docTitle} – ${c.company || ''}`, author: 'LogYou GmbH', subject: T.docTitle }, header: buildBrandHeader(T.docTitle, `logyou.de · ${T.dateWord} ${formatDateLocal(dateIso, language)}`, logos.dark), footer: buildBrandFooter(T.page, T.of), content, styles: { tableHeader: { fontSize: 8.5, bold: true, color: BRAND.white } }, defaultStyle: { font: 'Roboto', fontSize: 9.5, color: BRAND.text, lineHeight: 1.3 } } } export const generateContractPdf = async (payload: any): Promise<{ buffer: Buffer; filename: string }> => { // Import pdfmake with virtual file system for server-side use // (defensive vfs extraction — the export shape varies by pdfmake // version/bundler, do not simplify). const pdfMake = await import('pdfmake/build/pdfmake.js') const pdfFonts = await import('pdfmake/build/vfs_fonts.js') // @ts-ignore - Set up virtual file system const pdfMakeInstance = pdfMake.default || pdfMake let vfs = null if ((pdfFonts as any).pdfMake?.vfs) { vfs = (pdfFonts as any).pdfMake.vfs } else if ((pdfFonts as any).default?.pdfMake?.vfs) { vfs = (pdfFonts as any).default.pdfMake.vfs } else if ((pdfFonts as any).default?.vfs) { vfs = (pdfFonts as any).default.vfs } else if ((pdfFonts as any).vfs) { vfs = (pdfFonts as any).vfs } else if ((pdfFonts as any).default) { const defaultKeys = Object.keys((pdfFonts as any).default) if (defaultKeys.some((key: string) => key.endsWith('.ttf'))) { vfs = (pdfFonts as any).default } } if (!vfs) { throw new Error('Could not find vfs fonts in pdfmake fonts module') } pdfMakeInstance.vfs = vfs const language = payload?.meta?.language === 'en' ? 'en' : 'de' const logos = await getQuoteLogos() const docDefinition = buildContractDocDefinition(payload, logos) const filename = sanitizeContractFilename(payload?.customer?.company, language, payload?.meta?.date) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } }