/** * 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, isContractSectionOn } from './contractTexts' /** Signature data rendered into the customer signature block (digital signing). */ export interface ContractSignature { imageDataUrl: string name: string position?: string company?: string street?: string zip?: string city?: string country?: string ustId?: string email?: string phone?: string signedAt: string // ISO datetime ip?: string userAgent?: string token?: string documents?: Array<{ title: string; filename: string; sha256: string; version?: number }> /** Exact wording of every checkbox the signer ticked, with timestamps (evidence). */ acceptances?: Array<{ key: string; text: string; acceptedAt: string }> } const formatDateTimeLocal = (iso: string, language: string): string => { 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') const hh = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') return language === 'en' ? `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}` : `${dd}.${mm}.${d.getFullYear()} ${hh}:${mi}` } 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 }, signature: ContractSignature | null = 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 || {} // Per-contract section toggles (absent = on). const on = (key: string) => isContractSectionOn(ct.sections, key) const dateIso = payload?.meta?.date || new Date().toISOString().slice(0, 10) // Placeholder values substituted into the legal texts // Notice period: 0 = terminable any time to month end, no notice. The 1-month // special termination right in the first 3 months only makes sense with a // longer regular notice period. const noticeMonths = Number.isFinite(Number(ct.noticeMonths)) && ct.noticeMonths !== '' && ct.noticeMonths !== null && ct.noticeMonths !== undefined ? Number(ct.noticeMonths) : 0 const noticeClause = language === 'en' ? (noticeMonths > 0 ? `with ${noticeMonths} month${noticeMonths === 1 ? '' : 's'}' notice` : 'at any time without notice period') : (noticeMonths > 0 ? `mit einer Frist von ${noticeMonths} Monat${noticeMonths === 1 ? '' : 'en'}` : 'jederzeit ohne Einhaltung einer Kündigungsfrist') const noticeSpecial = noticeMonths > 1 ? (language === 'en' ? ' Within the first three (3) months after the contract start date, a special termination right applies with a notice period of one (1) month.' : ' Innerhalb der ersten drei (3) Monate ab Vertragsbeginn gilt ein Sonderkündigungsrecht mit einer Kündigungsfrist von einem (1) Monat.') : '' const vars = { company: c.company || '', noticeClause, noticeSpecial, offerDate: formatDateLocal(ct.offerDate || dateIso, language), noticeMonths, priceNoticeMonths: Number(ct.priceNoticeMonths) || 1, objectionWeeks: Number(ct.objectionWeeks) || 2, 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] } // ---- Annexes: Annex 1/2 wording only when the matching attachment is part of // the send (payload.attachments), so the contract never references a document // the customer did not receive. Absent attachments meta = full annex list. const att = payload.attachments || null const annexSection = att ? { title: T.annex.title, blocks: T.annex.blocks.filter((b: any, idx: number) => !((idx === 0 && att.offer === false) || (idx === 1 && att.agb === false) || (idx === 2 && att.avv === false))) } : T.annex // ---- 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] }, ...(signature ? [ sigLine(S.date, formatDateTimeLocal(signature.signedAt, language)), { columns: [ { text: S.signature, width: 110, color: BRAND.grayText, fontSize: 9 }, signature.imageDataUrl ? { image: signature.imageDataUrl, fit: [170, 60], margin: [0, -6, 0, 0] } : { text: signature.name || '', italics: true, fontSize: 11, margin: [0, 2, 0, 0] } ], margin: [0, 0, 0, 6] }, { text: language === 'en' ? 'Signed electronically (see signature certificate)' : 'Elektronisch unterschrieben (siehe Signaturprotokoll)', fontSize: 7.5, color: BRAND.grayText, margin: [110, 0, 0, 6] }, sigLine(S.name, signature.name || c.contactName || S.dots), sigLine(S.position, signature.position || c.signerPosition || S.dots), sigLine(S.company, signature.company || c.company || S.dots), ...((signature.ustId || c.ustId) ? [sigLine(S.vatId, signature.ustId || c.ustId)] : []), sigLine(S.address, [signature.street || c.street, [signature.zip || c.zip, signature.city || c.city].filter(Boolean).join(' '), signature.country || c.country].filter(Boolean).join(' / ') || S.dots) ] : [ 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) ]) ] } ] // ---- Signature certificate (digital signing audit page) ---- const certificateSection: any[] = signature ? (() => { const CT = language === 'en' ? { title: 'Signature certificate', intro: 'This document was signed electronically via the LogYou signing portal. The following data was recorded at the moment of signing:', signer: 'Signer', company: 'Company', position: 'Position', address: 'Address', vat: 'VAT ID', email: 'E-mail', phone: 'Phone', time: 'Signed at', ip: 'IP address', ua: 'Browser', ref: 'Signing reference', docs: 'Documents presented and accepted', hash: 'SHA-256', decl: 'Declarations (exact wording as ticked by the signer)', note: 'The signer confirmed having read the documents listed above and consented to signing electronically. The hash values allow verification that the documents have not been altered since signing.' } : { title: 'Signaturprotokoll', intro: 'Dieses Dokument wurde elektronisch über das LogYou-Signaturportal unterzeichnet. Zum Zeitpunkt der Unterschrift wurden folgende Daten erfasst:', signer: 'Unterzeichner', company: 'Unternehmen', position: 'Position', address: 'Anschrift', vat: 'USt-IdNr.', email: 'E-Mail', phone: 'Telefon', time: 'Unterschrieben am', ip: 'IP-Adresse', ua: 'Browser', ref: 'Signatur-Referenz', docs: 'Vorgelegte und akzeptierte Dokumente', hash: 'SHA-256', decl: 'Erklärungen (exakter Wortlaut der bestätigten Kontrollkästchen)', note: 'Der Unterzeichner hat bestätigt, die aufgeführten Dokumente gelesen zu haben, und der elektronischen Unterzeichnung zugestimmt. Die Hashwerte ermöglichen die Prüfung, dass die Dokumente seit der Unterschrift nicht verändert wurden.' } const row = (label: string, value: any) => [ { text: label, color: BRAND.grayText, fontSize: 9 }, { text: String(value || '—'), fontSize: 9 } ] const addr = [signature.street, [signature.zip, signature.city].filter(Boolean).join(' '), signature.country].filter(Boolean).join(', ') const docRows = (signature.documents || []).map((d) => [ { text: d.title + (d.version ? ` (v${d.version})` : ''), fontSize: 8.5 }, { text: d.filename, fontSize: 8, color: BRAND.grayText }, { text: d.sha256, fontSize: 6, color: BRAND.grayText } ]) const declRows = (signature.acceptances || []).map((a) => ({ columns: [ { text: '☑', width: 14, fontSize: 9, color: BRAND.navy }, { stack: [{ text: a.text, fontSize: 8.5 }, { text: formatDateTimeLocal(a.acceptedAt, language), fontSize: 7, color: BRAND.grayText }], width: '*' } ], margin: [0, 0, 0, 5] })) return [ { text: '', pageBreak: 'before' }, sectionTitle(CT.title), { text: CT.intro, margin: [0, 0, 0, 10] }, { table: { widths: [140, '*'], body: [ row(CT.signer, signature.name), row(CT.company, signature.company), row(CT.position, signature.position), row(CT.address, addr), row(CT.vat, signature.ustId), row(CT.email, signature.email), row(CT.phone, signature.phone), row(CT.time, formatDateTimeLocal(signature.signedAt, language)), row(CT.ip, signature.ip), row(CT.ua, signature.userAgent), row(CT.ref, signature.token ? signature.token.slice(0, 16) : '') ] }, 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, 14] }, ...(signature.imageDataUrl ? [{ image: signature.imageDataUrl, fit: [200, 70], margin: [0, 0, 0, 14] }] : []), ...(docRows.length ? [ { text: CT.docs, bold: true, color: BRAND.navy, margin: [0, 0, 0, 6] }, { table: { widths: [130, 130, '*'], body: [[{ text: '', fontSize: 8 }, { text: '', fontSize: 8 }, { text: CT.hash, fontSize: 8, color: BRAND.grayText }], ...docRows] }, layout: 'lightHorizontalLines', margin: [0, 0, 0, 10] } ] : []), ...(declRows.length ? [{ text: CT.decl, bold: true, color: BRAND.navy, margin: [0, 4, 0, 6] }, ...declRows] : []), { text: CT.note, fontSize: 8.5, color: BRAND.grayText, alignment: 'justify', margin: [0, 6, 0, 0] } ] })() : [] const content: any[] = [ ...cover, { text: '', pageBreak: 'after' }, ...(on('master') ? renderSection(T.master) : []), ...(on('services') ? renderSection(T.services) : []), ...(on('term') ? renderSection(T.term) : []), ...(on('pricing') ? renderSection(T.pricing) : []), ...(on('assumptions') ? assumptionsSection : []), ...(on('adjustments') ? renderSection(T.adjustments) : []), ...(on('payment') ? renderSection(paymentSection) : []), ...(on('sla') ? renderSection(slaSection) : []), ...(on('liability') ? renderSection(T.liability) : []), ...(on('property') ? renderSection(T.property) : []), ...(on('compliance') ? renderSection(T.compliance) : []), ...(on('privacy') ? renderSection(T.privacy) : []), ...(on('marketing') ? renderSection(marketingSection) : []), ...(on('special') ? specialSection : []), ...(on('jurisdiction') ? renderSection(T.jurisdiction) : []), ...(on('final') ? renderSection(T.final) : []), ...(on('annex') ? renderSection(annexSection) : []), ...signaturesSection, ...certificateSection ] 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, options: { signature?: ContractSignature | null; filenameSuffix?: string } = {}): Promise<{ buffer: Buffer; filename: string }> => { // The drawn signature is a canvas PNG, but never trust the client: pdfmake // only renders PNG/JPEG, so normalise (or drop the image) before building. if (options.signature?.imageDataUrl) { const { normalizePdfImage } = await import('./pdfImages') const img = await normalizePdfImage(options.signature.imageDataUrl) options = { ...options, signature: { ...options.signature, imageDataUrl: img || '' } } } // 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, options.signature || null) let filename = sanitizeContractFilename(payload?.customer?.company, language, payload?.meta?.date) if (options.filenameSuffix) filename = filename.replace(/\.pdf$/, `${options.filenameSuffix}.pdf`) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } }