/** * SEPA direct debit mandate PDF (pdf-lib). * * Why pdf-lib and not pdfmake: the mandate must carry NATIVE fillable form * fields (AcroForm) for account holder, IBAN, BIC, bank, address, place/date — * so the customer can complete it in any PDF viewer or on paper — and pdfmake * cannot emit form fields. The signed variant sets the values, draws the * signature image, appends a protocol line and flattens the form. * * One A4 page. Standard Helvetica (WinAnsi) — umlauts fine, exotic glyphs are * mapped by `winAnsi()` like in quoteConfirmation.ts. */ import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from 'pdf-lib' import { getQuoteLogos, LEGAL_FOOTER_LINES } from './quotePdf' import { normalizePdfImage, parseDataUrl } from './pdfImages' import { sepaTexts, type SepaMandateData, type SepaLang } from './sepaTexts' import { formatIbanDisplay } from '../../../app/utils/iban' const A4: [number, number] = [595.28, 841.89] const M = 48 // page margin const NAVY = rgb(0.11, 0.196, 0.294) const ORANGE = rgb(0.945, 0.349, 0.141) const GRAY = rgb(0.45, 0.5, 0.55) const LINE = rgb(0.85, 0.87, 0.9) const FIELD_BG = rgb(0.955, 0.965, 0.98) const TEXT = rgb(0.13, 0.16, 0.2) const winAnsi = (s: any) => String(s ?? '') .replace(/→/g, '->').replace(/—/g, '-').replace(/–/g, '-') .replace(/…/g, '...').replace(/[‘’]/g, "'").replace(/[“”„]/g, '"') .replace(/[^\x00-\xFF€]/g, '?') const wrap = (text: string, font: PDFFont, size: number, maxWidth: number): string[] => { const out: string[] = [] for (const para of String(text || '').split('\n')) { const words = para.split(/\s+/).filter(Boolean) if (!words.length) { out.push(''); continue } let line = '' for (const w of words) { const probe = line ? `${line} ${w}` : w if (font.widthOfTextAtSize(probe, size) <= maxWidth) { line = probe; continue } if (line) out.push(line) line = w } if (line) out.push(line) } return out } export interface SepaFilled { company?: string accountHolder?: string street?: string zipCity?: string country?: string iban?: string bic?: string bank?: string place?: string date?: string } export interface SepaSignature { imageDataUrl: string signedAt: string // ISO signerName: string ip?: string token?: string sha256OfBlank?: string } export interface SepaPdfOptions { data: SepaMandateData language: SepaLang /** Values to place into the fields (portal input / prefill). */ filled?: SepaFilled /** When present: values are set, signature drawn, protocol added, form flattened. */ signature?: SepaSignature } const fmtDateHuman = (iso: string, lang: SepaLang) => { const d = new Date(iso) if (isNaN(d.getTime())) return iso return d.toLocaleString(lang === 'en' ? 'en-GB' : 'de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } export const sepaFilename = (language: SepaLang, mandateReference: string, suffix = ''): string => { const ref = String(mandateReference || 'Mandat').replace(/[^A-Za-z0-9\-]/g, '_').slice(0, 40) return `${language === 'en' ? 'SEPA_Mandate' : 'SEPA-Lastschriftmandat'}_${ref}${suffix}.pdf` } export const buildSepaMandatePdf = async (opts: SepaPdfOptions): Promise => { const { data } = opts const lang: SepaLang = opts.language === 'en' ? 'en' : 'de' const t = sepaTexts(lang) const filled: SepaFilled = { ...(opts.filled || {}) } const pdf = await PDFDocument.create() pdf.setTitle(`${t.title} ${data.mandateReference}`) pdf.setAuthor(data.creditor.name) pdf.setLanguage(lang === 'en' ? 'en-GB' : 'de-DE') const font = await pdf.embedFont(StandardFonts.Helvetica) const bold = await pdf.embedFont(StandardFonts.HelveticaBold) const page = pdf.addPage(A4) const W = A4[0] const contentW = W - 2 * M let y = A4[1] - M const text = (s: string, x: number, yy: number, size: number, f: PDFFont = font, color = TEXT) => { page.drawText(winAnsi(s), { x, y: yy, size, font: f, color }) } const paragraph = (s: string, size: number, f: PDFFont = font, color = TEXT, lineGap = 1.35): number => { const lines = wrap(winAnsi(s), f, size, contentW) for (const l of lines) { text(l, M, y, size, f, color); y -= size * lineGap } return lines.length } // ---- header: logo + title try { const logos = await getQuoteLogos() const src = logos.light ? parseDataUrl(logos.light) : null if (src?.buffer) { const img = await pdf.embedPng(src.buffer) const h = 26 const w = (img.width / img.height) * h page.drawImage(img, { x: W - M - w, y: y - h + 6, width: w, height: h }) } } catch { /* logo optional */ } text(t.title, M, y - 14, 17, bold, NAVY) y -= 30 text(data.scheme === 'B2B' ? t.schemeB2B : t.schemeCore, M, y, 9.5, font, ORANGE) y -= 10 page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 1, color: ORANGE }) y -= 18 // ---- creditor block (two columns of key/value) const kv = (label: string, value: string, x: number, yy: number, colW: number) => { text(label.toUpperCase(), x, yy, 6.5, bold, GRAY) const lines = wrap(winAnsi(value), font, 9.5, colW) let yy2 = yy - 11 for (const l of lines) { text(l, x, yy2, 9.5, font, TEXT); yy2 -= 12 } return yy - yy2 } text(t.creditor, M, y, 8, bold, NAVY) y -= 14 const colW = (contentW - 16) / 2 const c = data.creditor const leftH = kv(t.creditor, `${c.name}\n${c.street}\n${c.zipCity}${c.country ? `\n${c.country}` : ''}`, M, y, colW) const rightY = y kv(t.creditorId, c.creditorId || '—', M + colW + 16, rightY, colW) kv(t.mandateRef, data.mandateReference, M + colW + 16, rightY - 30, colW) kv(t.paymentType, data.recurring ? t.recurring : t.oneOff, M + colW + 16, rightY - 60, colW) y -= Math.max(leftH, 90) + 4 page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.5, color: LINE }) y -= 16 // ---- authorisation + notice const auth = data.scheme === 'B2B' ? t.authB2B(c.name) : t.authCore(c.name) const notice = data.scheme === 'B2B' ? t.noticeB2B : t.noticeCore paragraph(auth, 9.5, font, TEXT) y -= 4 paragraph(notice, 8.5, font, GRAY) y -= 4 paragraph(t.prenote(c.prenotificationDays), 8.5, font, GRAY) y -= 10 page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.5, color: LINE }) y -= 16 // ---- debtor fields (AcroForm) text(t.debtor, M, y, 8, bold, NAVY) text(t.fillHint, M + 160, y, 7, font, GRAY) y -= 8 const form = pdf.getForm() const fieldH = 20 const field = (name: string, label: string, x: number, yy: number, w: number, value: string) => { text(label.toUpperCase(), x, yy - 8, 6.5, bold, GRAY) const fy = yy - 8 - fieldH - 3 page.drawRectangle({ x, y: fy, width: w, height: fieldH, color: FIELD_BG, borderColor: LINE, borderWidth: 0.6 }) const tf = form.createTextField(`sepa.${name}`) tf.addToPage(page, { x: x + 1, y: fy + 1, width: w - 2, height: fieldH - 2, borderWidth: 0, backgroundColor: FIELD_BG, textColor: TEXT, font }) tf.setFontSize(9.5) // after addToPage: the widget's /DA entry must exist first tf.setText(winAnsi(value || '')) return fy } const rowGap = 12 let fy = field('company', t.company, M, y, contentW, filled.company || '') y = fy - rowGap fy = field('accountHolder', t.accountHolder, M, y, contentW, filled.accountHolder || '') y = fy - rowGap fy = field('street', t.street, M, y, colW, filled.street || '') field('zipCity', t.zipCity, M + colW + 16, y, colW, filled.zipCity || '') y = fy - rowGap fy = field('country', t.country, M, y, colW, filled.country || '') field('bank', t.bank, M + colW + 16, y, colW, filled.bank || '') y = fy - rowGap fy = field('iban', t.iban, M, y, colW + 60, filled.iban ? formatIbanDisplay(filled.iban) : '') field('bic', t.bic, M + colW + 76, y, colW - 60, filled.bic || '') y = fy - 18 // ---- place/date + signature const sigTop = y fy = field('placeDate', t.placeDate, M, sigTop, colW, filled.place || filled.date ? `${filled.place || ''}${filled.place && filled.date ? ', ' : ''}${filled.date || ''}` : '') const sigX = M + colW + 16 const sigBoxH = 58 text(t.signature.toUpperCase(), sigX, sigTop - 8, 6.5, bold, GRAY) page.drawRectangle({ x: sigX, y: sigTop - 8 - sigBoxH - 3, width: colW, height: sigBoxH, color: rgb(1, 1, 1), borderColor: LINE, borderWidth: 0.6 }) page.drawLine({ start: { x: sigX + 8, y: sigTop - 8 - sigBoxH + 10 }, end: { x: sigX + colW - 8, y: sigTop - 8 - sigBoxH + 10 }, thickness: 0.5, color: GRAY }) y = Math.min(fy, sigTop - 8 - sigBoxH - 3) - 14 if (opts.signature) { try { const norm = await normalizePdfImage(opts.signature.imageDataUrl) const src = norm ? parseDataUrl(norm) : null if (src?.buffer) { const img = /jpe?g/i.test(src.mime) ? await pdf.embedJpg(src.buffer) : await pdf.embedPng(src.buffer) const maxW = colW - 20, maxH = sigBoxH - 16 const scale = Math.min(maxW / img.width, maxH / img.height) const w = img.width * scale, h = img.height * scale page.drawImage(img, { x: sigX + 10, y: sigTop - 8 - sigBoxH + 12, width: w, height: h }) } } catch (err: any) { console.warn('[sepa] signature image skipped:', err?.message || err) } text(`${opts.signature.signerName} · ${fmtDateHuman(opts.signature.signedAt, lang)}`, sigX + 8, sigTop - 8 - sigBoxH + 1, 6.5, font, GRAY) } // ---- footer texts paragraph(t.footer, 7.5, font, GRAY) if (opts.signature) { y -= 4 const proto = lang === 'en' ? `Electronically granted via the LogYou portal on ${fmtDateHuman(opts.signature.signedAt, 'en')}${opts.signature.ip ? ` · IP ${opts.signature.ip}` : ''}${opts.signature.token ? ` · Link ${opts.signature.token.slice(0, 12)}…` : ''}${opts.signature.sha256OfBlank ? ` · Template SHA-256 ${opts.signature.sha256OfBlank.slice(0, 16)}…` : ''}` : `Elektronisch erteilt über das LogYou-Portal am ${fmtDateHuman(opts.signature.signedAt, 'de')}${opts.signature.ip ? ` · IP ${opts.signature.ip}` : ''}${opts.signature.token ? ` · Link ${opts.signature.token.slice(0, 12)}…` : ''}${opts.signature.sha256OfBlank ? ` · Vorlage SHA-256 ${opts.signature.sha256OfBlank.slice(0, 16)}…` : ''}` paragraph(proto, 7, font, NAVY) } // ---- legal footer (bottom of page) let fyy = M - 8 for (const line of [...LEGAL_FOOTER_LINES].reverse()) { text(line, M, fyy, 6.5, font, GRAY) fyy += 9 } page.drawLine({ start: { x: M, y: fyy + 2 }, end: { x: W - M, y: fyy + 2 }, thickness: 0.5, color: LINE }) form.updateFieldAppearances(font) if (opts.signature) form.flatten() return Buffer.from(await pdf.save()) }