/** * Branded "Angebot LogShip Cloud" PDF — the SaaS offer type next to the 3PL * fulfillment quote (quotePdf.ts). Same brand header/footer, A4, pdfmake. * * Payload = buildSoftwareQuotePayload() from app/composables/useOfferSoftwareQuote.ts * (offerType 'software'). Sections (each switchable via payload.sections): * overview (package card) → pricing (base fee, overage staffel, extra users, * one-time) → features (4-column comparison with the chosen package highlighted, * or a plain list) → onboarding (price, highlights, phases) → addons → * positions / requirements → forecast (monthly, one-time, first year) → notes. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos, resolveQuoteValidity } from './quotePdf' const CHECK_SVG = '' const CROSS_SVG = '' const DASH_SVG = '' const numberBadge = (n: number) => `${n}` // Catalog (packages + feature matrix) comes with the payload as a snapshot of // what the modal used (`payload.catalog`, see useOfferSoftwareQuote.ts); older // payloads without one render from the code defaults. import { DEFAULT_SOFTWARE_CATALOG, normalizeSoftwareCatalog, activePlans, featureDefaultState, catalogLabel, type SoftwareCatalog } from '../../../app/utils/softwareCatalogDefaults' export const SOFTWARE_QUOTE_TEXTS: any = { de: { docTitle: 'Angebot LogShip Cloud', dateWord: 'Angebot vom', date: 'Datum', validUntil: 'Gültig bis', plannedStart: 'Geplanter Start', billing: 'Abrechnung', billingYearly: 'jährlich (Vorauszahlung)', billingMonthly: 'monatlich', billingYearlyShort: 'jährliche Zahlung', billingMonthlyShort: 'monatliche Zahlung', channelsIncl: '{n} Verkaufskanäle', channelsUnlimitedIncl: 'unbegrenzte Verkaufskanäle', minTerm: 'Mindestlaufzeit', months: '{n} Monate', noMinTerm: 'keine (monatlich kündbar)', intro: 'Vielen Dank für Ihr Interesse an LogShip Cloud — der Lagerverwaltungs- und Versandsoftware, mit der LogYou täglich das eigene Fulfillment betreibt. Gerne unterbreiten wir Ihnen folgendes Angebot.', overviewTitle: 'Ihr Paket', popular: 'Beliebtestes Paket', perMonth: 'pro Monat', onRequest: 'auf Anfrage', priceYearlyNote: 'bei jährlicher Zahlung · monatlich: {price}', priceMonthlyNote: 'bei monatlicher Zahlung · jährlich: {price} pro Monat', ordersPerMonth: 'Bestellungen / Monat', users: 'Benutzer', channels: 'Verkaufskanäle', support: 'Support', supportTicket: 'Ticket', supportPhone: 'Ticket + Telefon', supportSla: 'Ticket + Telefon + SLA', unlimited: 'unbegrenzt', individual: 'individuell', included: 'inklusive', pricingTitle: 'Preise & Konditionen', service: 'Leistung', unit: 'Einheit', price: 'Preis', baseFee: 'Paket {plan} — Grundgebühr', monthlyFee: 'monatliche Softwaregebühr', baseFeeIncl: 'inkl. {orders} Bestellungen/Monat, {users} Benutzer, {channels}, Support: {support}', baseFeeInclEnterprise: 'Volumen, Benutzer und Leistungsumfang individuell nach Vereinbarung', extraOrders: 'Weitere Bestellungen ({range})', extraOrdersFlat: 'Weitere Bestellungen (über {n}/Monat)', rangeFromTo: '{from} – {to}', rangeFrom: 'ab {from}', perOrder: 'pro Bestellung', extraUser: 'Weiterer Benutzer', onboardingRow: 'Onboarding-Paket (Einrichtung, Datenimport, Schulung, Hypercare)', onboardingRowOptional: 'Onboarding-Paket – optional (Einrichtung, Datenimport, Schulung, Hypercare)', priceFrom: 'ab {price}', onboardingOptional: 'optional', onboardingTravelNote: 'Zzgl. Reise- und Übernachtungskosten für Termine vor Ort (nach tatsächlichem Aufwand).', onboardingFromNote: 'Der genannte Preis ist ein Ab-Preis; der endgültige Festpreis wird nach dem Kickoff-Gespräch auf Basis Ihres Setups festgelegt.', onboardingSelfService: 'Einrichtung im Self-Service (ohne Onboarding-Paket)', free: 'kostenfrei', once: 'einmalig', perHour: 'pro Stunde', overageNote: 'Zusätzliche Bestellungen werden monatlich nachträglich nach tatsächlichem Verbrauch abgerechnet.', featuresTitle: 'Leistungsumfang', featureCol: 'Funktion', group_core: 'Kern-WMS', group_warehouse: 'Lager & Automatisierung', group_advanced: 'Erweitert & Schnittstellen', group_enterprise: 'Enterprise', stateOnRequest: 'möglich', stateAddon: 'gegen Angebot', stateIncluded: 'enthalten', stateExcluded: 'nicht enthalten', featuresIncludedTitle: 'Im Paket {plan} enthalten', featuresUpgradeTitle: 'Nicht enthalten (Upgrade / Zusatzvereinbarung)', yourPlan: 'Ihr Paket', onboardingTitle: 'Onboarding-Paket', onboardingPriceOnce: '{price} einmalig', onboardingIncluded: 'im Angebot enthalten', onboardingSteps: 'So läuft Ihr Onboarding', addonsTitle: 'Optionale Zusatzleistungen', addonSelected: 'im Angebot', addonOptional: 'optional', unitMonth: 'pro Monat', unitOnce: 'einmalig', unitHour: 'pro Stunde', extraPositions: 'Weitere Positionen', dimension: 'Dimension', requirementsTitle: 'Kundenanforderungen', requirementsAnswer: 'Möglich', forecastTitle: 'Beispielrechnung — Ihre voraussichtlichen Kosten', fcPlan: 'Paket {plan} ({billing})', fcOverage: 'Weitere Bestellungen ({n} über Inklusivvolumen)', fcExtraUsers: 'Weitere Benutzer ({n})', fcAddons: 'Zusatzleistungen (monatlich)', fcTotalMonthly: 'Gesamt pro Monat', fcOneTime: 'Einmalig', fcOnboarding: 'Onboarding-Paket', fcAddonsOnce: 'Zusatzleistungen (einmalig)', fcFirstYear: 'Gesamt im ersten Jahr (12 × monatlich + einmalig)', fcPerOrder: 'Ø Softwarekosten pro Bestellung', fcAssumption: 'Annahme: {orders} Bestellungen/Monat, {users} Benutzer.', fcDisclaimer: 'Unverbindliche, beispielhafte Kalkulation auf Basis der genannten Annahmen — kein Vertragsbestandteil. Abgerechnet werden die Grundgebühr des gewählten Pakets sowie tatsächlich genutzte Zusatzbestellungen, Benutzer und Zusatzleistungen.', notesTitle: 'Hinweise', noteVat: 'Alle Preise verstehen sich netto zzgl. 19 % MwSt.', noteValidity: 'Das Angebot ist {days} Tage gültig (bis {date}).', noteTermYearly: 'Laufzeit {months} Monate, Abrechnung jährlich im Voraus; danach Verlängerung um jeweils 12 Monate, kündbar mit 3 Monaten Frist zum Laufzeitende.', noteTermMonthly: 'Abrechnung monatlich im Voraus{term}.', noteTermMonthlyMin: ', Mindestlaufzeit {months} Monate, danach monatlich kündbar', noteTermMonthlyNone: ', keine Mindestlaufzeit — monatlich kündbar', noteOverage: 'Bestellungen über dem Inklusivvolumen und zusätzliche Benutzer werden monatlich nachträglich abgerechnet; ein Paketwechsel nach oben ist jederzeit zum Monatswechsel möglich.', noteHosting: 'Hosting in Deutschland, tägliche Backups, Auftragsverarbeitungsvertrag (AVV) auf Wunsch. Es gelten die AGB der LogYou GmbH für LogShip Cloud.', noteOnboarding: 'Das Onboarding-Paket ist optional; ohne Paket erfolgt die Einrichtung im Self-Service mit Ticket-Support.', noteOnboardingTravel: 'Reise- und Übernachtungskosten für Vor-Ort-Termine im Rahmen des Onboardings werden nach tatsächlichem Aufwand gesondert berechnet.', closing: 'Wir freuen uns darauf, Ihr Lager mit LogShip live zu bringen.', closingTeam: 'Ihr LogShip Team · LogYou GmbH · app.logship.de', page: 'Seite', of: 'von' }, en: { docTitle: 'LogShip Cloud offer', dateWord: 'Offer dated', date: 'Date', validUntil: 'Valid until', plannedStart: 'Planned start', billing: 'Billing', billingYearly: 'yearly (in advance)', billingMonthly: 'monthly', billingYearlyShort: 'yearly payment', billingMonthlyShort: 'monthly payment', channelsIncl: '{n} sales channels', channelsUnlimitedIncl: 'unlimited sales channels', minTerm: 'Minimum term', months: '{n} months', noMinTerm: 'none (cancel monthly)', intro: 'Thank you for your interest in LogShip Cloud — the warehouse and shipping software LogYou runs its own fulfillment on every day. We are pleased to submit the following offer.', overviewTitle: 'Your package', popular: 'Most popular', perMonth: 'per month', onRequest: 'on request', priceYearlyNote: 'with yearly payment · monthly: {price}', priceMonthlyNote: 'with monthly payment · yearly: {price} per month', ordersPerMonth: 'Orders / month', users: 'Users', channels: 'Sales channels', support: 'Support', supportTicket: 'Ticket', supportPhone: 'Ticket + phone', supportSla: 'Ticket + phone + SLA', unlimited: 'unlimited', individual: 'individual', included: 'included', pricingTitle: 'Prices & conditions', service: 'Service', unit: 'Unit', price: 'Price', baseFee: '{plan} package — base fee', monthlyFee: 'monthly software fee', baseFeeIncl: 'incl. {orders} orders/month, {users} users, {channels}, support: {support}', baseFeeInclEnterprise: 'volume, users and scope individually as agreed', extraOrders: 'Additional orders ({range})', extraOrdersFlat: 'Additional orders (above {n}/month)', rangeFromTo: '{from} – {to}', rangeFrom: 'from {from}', perOrder: 'per order', extraUser: 'Additional user', onboardingRow: 'Onboarding package (setup, data import, training, hypercare)', onboardingRowOptional: 'Onboarding package – optional (setup, data import, training, hypercare)', priceFrom: 'from {price}', onboardingOptional: 'optional', onboardingTravelNote: 'Plus travel and accommodation costs for on-site appointments (billed as incurred).', onboardingFromNote: 'The stated price is a starting price; the final fixed price is agreed after the kickoff call based on your setup.', onboardingSelfService: 'Self-service setup (no onboarding package)', free: 'free of charge', once: 'one-time', perHour: 'per hour', overageNote: 'Additional orders are billed monthly in arrears based on actual usage.', featuresTitle: 'Scope of features', featureCol: 'Feature', group_core: 'Core WMS', group_warehouse: 'Warehouse & automation', group_advanced: 'Advanced & integrations', group_enterprise: 'Enterprise', stateOnRequest: 'possible', stateAddon: 'on quote', stateIncluded: 'included', stateExcluded: 'not included', featuresIncludedTitle: 'Included in the {plan} package', featuresUpgradeTitle: 'Not included (upgrade / separate agreement)', yourPlan: 'Your package', onboardingTitle: 'Onboarding package', onboardingPriceOnce: '{price} one-time', onboardingIncluded: 'included in this offer', onboardingSteps: 'How your onboarding works', addonsTitle: 'Optional add-ons', addonSelected: 'in this offer', addonOptional: 'optional', unitMonth: 'per month', unitOnce: 'one-time', unitHour: 'per hour', extraPositions: 'Additional positions', dimension: 'Dimension', requirementsTitle: 'Customer requirements', requirementsAnswer: 'Possible', forecastTitle: 'Example calculation — your expected costs', fcPlan: '{plan} package ({billing})', fcOverage: 'Additional orders ({n} above included volume)', fcExtraUsers: 'Additional users ({n})', fcAddons: 'Add-ons (monthly)', fcTotalMonthly: 'Total per month', fcOneTime: 'One-time', fcOnboarding: 'Onboarding package', fcAddonsOnce: 'Add-ons (one-time)', fcFirstYear: 'Total first year (12 × monthly + one-time)', fcPerOrder: 'Ø software cost per order', fcAssumption: 'Assumption: {orders} orders/month, {users} users.', fcDisclaimer: 'Non-binding example calculation based on the stated assumptions — not part of the contract. Billed are the base fee of the chosen package plus actually used additional orders, users and add-ons.', notesTitle: 'Notes', noteVat: 'All prices are net, plus 19 % VAT.', noteValidity: 'This offer is valid for {days} days (until {date}).', noteTermYearly: 'Term {months} months, billed yearly in advance; thereafter renewal by 12 months each, cancellable with 3 months notice to the end of the term.', noteTermMonthly: 'Billed monthly in advance{term}.', noteTermMonthlyMin: ', minimum term {months} months, cancellable monthly thereafter', noteTermMonthlyNone: ', no minimum term — cancellable monthly', noteOverage: 'Orders above the included volume and additional users are billed monthly in arrears; an upgrade to a higher package is possible at any month change.', noteHosting: 'Hosted in Germany, daily backups, data processing agreement (DPA) on request. The LogYou GmbH terms for LogShip Cloud apply.', noteOnboarding: 'The onboarding package is optional; without it, setup is self-service with ticket support.', noteOnboardingTravel: 'Travel and accommodation costs for on-site onboarding appointments are billed separately as incurred.', closing: 'We look forward to taking your warehouse live with LogShip.', closingTeam: 'Your LogShip team · LogYou GmbH · app.logship.de', page: 'Page', of: 'of' } } 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 sanitizeSoftwareQuoteFilename = (company: 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) // Different prefix from the fulfillment quote on purpose: the contract package // picks "Angebot_LogYou_*.pdf" as Anlage 1 and must never grab a SaaS offer. return `Angebot_LogShip_${safe}_${d}.pdf` } export const buildSoftwareQuoteDocDefinition = (payload: any, logos: { light: string | null; dark: string | null }) => { const language = payload?.meta?.language === 'en' ? 'en' : 'de' const T = SOFTWARE_QUOTE_TEXTS[language] const c = payload.customer || {} const pl = payload.plan || {} const a = payload.assumptions || {} const catalog: SoftwareCatalog = normalizeSoftwareCatalog(payload?.catalog && Array.isArray(payload.catalog.plans) && payload.catalog.plans.length ? payload.catalog : DEFAULT_SOFTWARE_CATALOG) const plans = activePlans(catalog) const planKey = plans.some((p) => p.key === String(pl.key)) ? String(pl.key) : (plans[0]?.key || '') const planRow = plans.find((p) => p.key === planKey) const planName = String(pl.name || (planRow ? catalogLabel(planRow, language) : planKey)) // Monthly software-fee line: name + subtext, each overridable per offer const feeLabel = String(pl.feeLabel || '').trim() || planName const planTagline = String(pl.description || planRow?.description || '').trim() const planIsPopular = pl.popular === true || planRow?.popular === true const yearly = pl.billing !== 'monthly' const on = (key: string) => payload?.sections?.[key] !== false const dateIso = payload?.meta?.date || new Date().toISOString().slice(0, 10) const { validDays, validUntil } = resolveQuoteValidity({ ...(payload?.meta || {}), date: dateIso }) const visuals = payload?.visuals || {} const heroShot = visuals.includeScreenshot && visuals.screenshotDataUrl ? String(visuals.screenshotDataUrl) : null const customerLogo = visuals.includeLogo && visuals.logoDataUrl ? String(visuals.logoDataUrl) : null const websiteDomain = String(visuals.websiteUrl || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '') const locale = language === 'en' ? 'en-IE' : 'de-DE' const euro = (amount: any) => new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(Number(amount) || 0) const int = (n: any) => new Intl.NumberFormat(locale).format(Math.round(Number(n) || 0)) const n0 = (v: any) => (Number.isFinite(Number(v)) ? Number(v) : 0) 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, bold: true, color: BRAND.navy, characterSpacing: 0.4, margin: [0, 5, 0, 0] } ], margin: [0, 16, 0, 8] }) const zebraLayout = { hLineWidth: (i: number) => (i === 1 ? 0.75 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, fillColor: (rowIndex: number) => (rowIndex === 0 ? BRAND.navy : rowIndex % 2 === 0 ? BRAND.zebra : null), paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 5, paddingBottom: () => 5 } const th = (text: string, alignment: string = 'left', extra: any = {}) => ({ text, style: 'tableHeader', alignment, ...extra }) const td = (text: any, opts: any = {}) => ({ text: String(text), fontSize: 9, ...opts }) const tdPrice = (value: any) => td(typeof value === 'number' ? euro(value) : value, { alignment: 'right', bold: typeof value === 'number' }) const priceTable = (widths: any[], headers: any[], rows: any[][]) => ({ table: { headerRows: 1, widths, body: [headers, ...rows] }, layout: zebraLayout }) const kv = (label: string, value: any) => [td(label, { color: BRAND.grayText }), td(value || '—', { bold: true })] const supportLabel = (s: any) => (s === 'sla' ? T.supportSla : s === 'phone' ? T.supportPhone : T.supportTicket) const channelsLabel = n0(pl.salesChannels) > 0 ? int(pl.salesChannels) : T.unlimited const includedOrdersLabel = n0(pl.includedOrders) > 0 ? int(pl.includedOrders) : T.individual const includedUsersLabel = n0(pl.includedUsers) > 0 ? int(pl.includedUsers) : T.individual const priceMain = yearly ? n0(pl.priceYearly) : n0(pl.priceMonthly) const priceOther = yearly ? n0(pl.priceMonthly) : n0(pl.priceYearly) const isOnRequest = priceMain <= 0 const minTerm = n0(pl.minTermMonths) const billingLabel = yearly ? T.billingYearly : T.billingMonthly // ===== Head: address + logo + meta card, intro, optional website hero ===== const head: any[] = [ { columns: [ { width: '*', stack: [ { text: c.company || '', bold: true, fontSize: 12, color: BRAND.navy }, { text: c.contactName || '', margin: [0, 2, 0, 0] }, ...(c.street ? [{ text: c.street, margin: [0, 1, 0, 0] }] : []), ...((c.zip || c.city) ? [{ text: [c.zip, c.city].filter(Boolean).join(' '), margin: [0, 1, 0, 0] }] : []), { text: c.country || 'Deutschland', margin: [0, 1, 0, 0] } ] }, ...(customerLogo ? [{ width: 130, image: customerLogo, fit: [118, 54], alignment: 'center', margin: [0, 4, 6, 0] }] : []), { width: 210, table: { widths: [92, '*'], body: [ kv(T.date, formatDateLocal(dateIso, language)), kv(T.validUntil, formatDateLocal(validUntil, language)), kv(T.plannedStart, formatDateLocal(pl.plannedStart, language)), kv(T.billing, billingLabel), kv(T.minTerm, minTerm > 0 ? fmt(T.months, { n: minTerm }) : T.noMinTerm) ] }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, fillColor: () => BRAND.zebra, paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ], columnGap: 24, margin: [0, 4, 0, 0] }, { text: T.intro, color: BRAND.grayText, margin: [0, 14, 0, 0] }, ...(heroShot ? [{ unbreakable: true, margin: [0, 14, 0, 0], table: { widths: ['*'], body: [ [{ fillColor: BRAND.navy, margin: [10, 6, 10, 6], columns: [ { width: 34, canvas: [{ type: 'ellipse', x: 4, y: 5, r1: 3.2, r2: 3.2, color: '#FF5F57' }, { type: 'ellipse', x: 15, y: 5, r1: 3.2, r2: 3.2, color: '#FEBC2E' }, { type: 'ellipse', x: 26, y: 5, r1: 3.2, r2: 3.2, color: '#28C840' }] }, { text: websiteDomain || 'Website', color: BRAND.white, fontSize: 8.5, alignment: 'center', margin: [0, 1, 34, 0] } ] }], [{ image: heroShot, width: 513.8, margin: [0, 0, 0, 0] }] ] }, layout: { hLineWidth: (i: number, node: any) => (i === 0 || i === node.table.body.length ? 0.75 : 0), vLineWidth: () => 0.75, hLineColor: () => BRAND.grayLine, vLineColor: () => BRAND.grayLine, paddingLeft: () => 0, paddingRight: () => 0, paddingTop: () => 0, paddingBottom: () => 0 } }] : []) ] const sections: Array<{ key: string; node: any }> = [] // ===== ÜBERSICHT: package card ===== if (on('overview')) { const fact = (label: string, value: string) => ({ stack: [ { text: value, bold: true, fontSize: 13, color: BRAND.navy }, { text: label, fontSize: 7.5, color: BRAND.grayText, margin: [0, 1, 0, 0] } ] }) sections.push({ key: 'overview', node: { unbreakable: true, stack: [ sectionTitle(T.overviewTitle), { table: { widths: [190, '*'], body: [[ { fillColor: BRAND.navy, margin: [14, 14, 14, 14], stack: [ ...(planIsPopular ? [{ text: T.popular.toUpperCase(), fontSize: 6.5, bold: true, color: BRAND.orange, characterSpacing: 0.6 }] : []), { text: planName, fontSize: 20, bold: true, color: BRAND.white, margin: [0, 2, 0, planTagline ? 2 : 6] }, ...(planTagline ? [{ text: planTagline, fontSize: 7.5, italics: true, color: BRAND.headerSub, margin: [0, 0, 0, 6] }] : []), isOnRequest ? { text: T.onRequest, fontSize: 15, bold: true, color: BRAND.orange } : { text: [{ text: euro(priceMain), fontSize: 19, bold: true, color: BRAND.orange }, { text: ` ${T.perMonth}`, fontSize: 8, color: BRAND.headerSub }] }, ...(!isOnRequest && priceOther > 0 ? [{ text: fmt(yearly ? T.priceYearlyNote : T.priceMonthlyNote, { price: euro(priceOther) }), fontSize: 7, color: BRAND.headerSub, margin: [0, 4, 0, 0] }] : []) ] }, { margin: [16, 12, 12, 12], stack: [ { columns: [fact(T.ordersPerMonth, includedOrdersLabel), fact(T.users, includedUsersLabel)], columnGap: 10 }, { columns: [fact(T.channels, channelsLabel), fact(T.support, supportLabel(pl.support))], columnGap: 10, margin: [0, 10, 0, 0] } ] } ]] }, layout: { hLineWidth: () => 0.75, vLineWidth: () => 0.75, hLineColor: () => BRAND.grayLine, vLineColor: () => BRAND.grayLine, paddingLeft: () => 0, paddingRight: () => 0, paddingTop: () => 0, paddingBottom: () => 0 } } ] } }) } // ===== PREISE & KONDITIONEN ===== const overageTiers: Array<{ upTo: number | null; price: number }> = (Array.isArray(pl.overageTiers) ? pl.overageTiers : []) .map((t: any) => ({ upTo: t?.upTo === '' || t?.upTo == null ? null : n0(t.upTo), price: n0(t?.price) })) .filter((t: any) => t.price > 0) .sort((x: any, y: any) => (x.upTo == null ? 1 : y.upTo == null ? -1 : x.upTo - y.upTo)) const onboardingOn = on('onboarding') && payload?.onboarding?.enabled === true const onboardingPrice = n0(payload?.onboarding?.price) const onboardingFrom = payload?.onboarding?.priceIsFrom !== false const onboardingTravel = payload?.onboarding?.travelExcluded !== false if (on('pricing')) { const autoIncl = isOnRequest ? T.baseFeeInclEnterprise : fmt(T.baseFeeIncl, { orders: includedOrdersLabel, users: includedUsersLabel, channels: n0(pl.salesChannels) > 0 ? fmt(T.channelsIncl, { n: int(pl.salesChannels) }) : T.channelsUnlimitedIncl, support: supportLabel(pl.support) }) const feeSubtextCustom = String(pl.feeSubtext || '').trim() const feeSubLines: string[] = feeSubtextCustom ? [feeSubtextCustom] : [...(planTagline ? [planTagline] : []), autoIncl] const rows: any[][] = [[ { stack: [ td(`${feeLabel} — ${T.monthlyFee}`, { bold: true }), ...feeSubLines.map((line, i) => td(line, { fontSize: 7.5, color: BRAND.grayText, italics: i === 0 && !feeSubtextCustom && !!planTagline, margin: [0, 2, 0, 0] })) ] }, td(T.perMonth, { alignment: 'center' }), isOnRequest ? td(T.onRequest, { alignment: 'right', italics: true }) : tdPrice(priceMain) ]] if (!isOnRequest && n0(pl.includedOrders) > 0) { if (overageTiers.length <= 1) { rows.push([td(fmt(T.extraOrdersFlat, { n: int(pl.includedOrders) })), td(T.perOrder, { alignment: 'center' }), overageTiers.length ? tdPrice(overageTiers[0].price) : td(T.onRequest, { alignment: 'right', italics: true })]) } else { let prev = n0(pl.includedOrders) for (const t of overageTiers) { const range = t.upTo == null ? fmt(T.rangeFrom, { from: int(prev + 1) }) : fmt(T.rangeFromTo, { from: int(prev + 1), to: int(t.upTo) }) rows.push([td(fmt(T.extraOrders, { range })), td(T.perOrder, { alignment: 'center' }), tdPrice(t.price)]) if (t.upTo != null) prev = t.upTo } } } if (!isOnRequest) { rows.push([td(T.extraUser), td(T.perMonth, { alignment: 'center' }), n0(pl.extraUserPrice) > 0 ? tdPrice(n0(pl.extraUserPrice)) : td(T.onRequest, { alignment: 'right', italics: true })]) } if (onboardingOn) { const obPriceCell = onboardingPrice > 0 ? (onboardingFrom ? td(fmt(T.priceFrom, { price: euro(onboardingPrice) }), { alignment: 'right', bold: true }) : tdPrice(onboardingPrice)) : td(T.included, { alignment: 'right', italics: true }) const obLabel: any = onboardingTravel ? { stack: [td(T.onboardingRowOptional, { bold: true }), td(T.onboardingTravelNote, { fontSize: 7.5, color: BRAND.grayText, margin: [0, 2, 0, 0] })] } : td(T.onboardingRowOptional, { bold: true }) rows.push([obLabel, td(T.once, { alignment: 'center' }), obPriceCell]) } else if (on('onboarding')) { rows.push([td(T.onboardingSelfService), td(T.once, { alignment: 'center' }), td(T.free, { alignment: 'right', italics: true })]) } sections.push({ key: 'pricing', node: { unbreakable: true, stack: [ sectionTitle(T.pricingTitle), priceTable(['*', 80, 110], [th(T.service), th(T.unit, 'center'), th(T.price, 'right')], rows), ...(!isOnRequest ? [{ text: T.overageNote, fontSize: 7.5, color: BRAND.grayText, margin: [0, 5, 0, 0] }] : []) ] } }) } // ===== LEISTUNGSUMFANG: 4-column comparison (chosen column highlighted) or plain list ===== const FEATURES = catalog.features.filter((f) => f.active !== false) const stateOf = (f: any, col: string): string => { if (col === planKey) { const v = payload?.features?.[f.key] if (v === 'included' || v === 'excluded' || v === 'addon' || v === 'onRequest') return v } return featureDefaultState(catalog, f, col) } const groupKeys = Array.from(new Set(['core', 'warehouse', 'advanced', 'enterprise', ...FEATURES.map((f) => f.group)])) const groupTitle = (g: string) => T['group_' + g] || g const stateCell = (state: string, highlight: boolean) => { const fill = highlight ? '#FDEEE7' : undefined if (state === 'included') return { svg: CHECK_SVG, width: 11, alignment: 'center', fillColor: fill } if (state === 'excluded') return { svg: DASH_SVG, width: 11, alignment: 'center', fillColor: fill } return { text: state === 'addon' ? T.stateAddon : T.stateOnRequest, fontSize: 6.8, italics: true, alignment: 'center', color: BRAND.grayText, fillColor: fill } } const customFeatures: any[] = Array.isArray(payload?.customFeatures) ? payload.customFeatures.filter((f: any) => String(f?.label || '').trim() !== '') : [] if (on('features')) { // More than 5 packages don't fit as columns on A4 → plain list of the chosen package. const compare = payload?.featuresCompare !== false && plans.length >= 2 && plans.length <= 5 let body: any if (compare) { const planKeys = plans.map((p) => p.key) const colW = plans.length <= 3 ? 70 : plans.length === 4 ? 58 : 48 const headerRow = [th(T.featureCol), ...plans.map((p) => th(catalogLabel(p, language), 'center', p.key === planKey ? { fillColor: BRAND.orange } : {}))] const rows: any[][] = [] const span = planKeys.length + 1 for (const g of groupKeys) { const inGroup = FEATURES.filter((x) => x.group === g) if (!inGroup.length) continue rows.push([{ text: groupTitle(g), colSpan: span, bold: true, fontSize: 8, color: BRAND.navy, fillColor: BRAND.zebra, margin: [0, 1, 0, 1] }, ...planKeys.map(() => ({}))]) for (const f of inGroup) { rows.push([td(catalogLabel(f, language)), ...planKeys.map((k) => stateCell(stateOf(f, k), k === planKey))]) } } for (const cf of customFeatures) { rows.push([td(String(cf.label).trim()), ...planKeys.map((k) => (k === planKey ? stateCell(cf.state || 'included', true) : { text: '', fillColor: undefined }))]) } body = { table: { headerRows: 1, widths: ['*', ...plans.map((p, i) => (i === plans.length - 1 ? colW + 6 : colW))], body: [headerRow, ...rows] }, layout: { hLineWidth: (i: number) => (i === 1 ? 0.75 : 0.25), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, fillColor: (rowIndex: number) => (rowIndex === 0 ? BRAND.navy : null), paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 3.5, paddingBottom: () => 3.5 } } } else { const incl: string[] = [] const rest: string[] = [] for (const f of FEATURES) { const s = stateOf(f, planKey) const label = catalogLabel(f, language) if (s === 'included') incl.push(label) else if (s === 'onRequest') incl.push(`${label} (${T.stateOnRequest})`) else rest.push(s === 'addon' ? `${label} (${T.stateAddon})` : label) } for (const cf of customFeatures) (cf.state === 'excluded' || cf.state === 'addon' ? rest : incl).push(String(cf.label).trim()) const half = Math.ceil(incl.length / 2) // Each row unbreakable: otherwise the badge can stay on one page while its text moves to the next. const list = (items: string[]) => ({ stack: items.map((t) => ({ unbreakable: true, columns: [{ width: 12, svg: CHECK_SVG, margin: [0, 1, 0, 0] }, { width: '*', text: t, fontSize: 9 }], columnGap: 5, margin: [0, 0, 0, 4] })) }) body = { stack: [ { text: fmt(T.featuresIncludedTitle, { plan: planName }), bold: true, fontSize: 9.5, color: BRAND.navy, margin: [0, 0, 0, 6] }, { columns: [list(incl.slice(0, half)), list(incl.slice(half))], columnGap: 16 }, ...(rest.length ? [ { text: T.featuresUpgradeTitle, bold: true, fontSize: 9, color: BRAND.grayText, margin: [0, 8, 0, 4] }, { text: rest.join(' · '), fontSize: 8, color: BRAND.grayText } ] : []) ] } } // Deliberately NOT unbreakable — the matrix is ~30 rows and pdfmake drops // whatever overflows an unbreakable block; the header row repeats instead. // The 4-column comparison fills most of a page, so it starts on a fresh // page (page 1 = address, package card, prices) unless the website hero // already pushed the price table down — then a forced break would leave a // half-empty page, so let it flow. sections.push({ key: 'features', node: { ...(compare && !heroShot ? { pageBreak: 'before' } : {}), stack: [sectionTitle(T.featuresTitle), body] } }) } // ===== ONBOARDING-PAKET ===== if (onboardingOn) { const ob = payload.onboarding || {} const highlights: any[] = (Array.isArray(ob.highlights) ? ob.highlights : []).filter((h: any) => String(h?.value || '').trim() !== '' || String(h?.label || '').trim() !== '') const phases: any[] = (Array.isArray(ob.phases) ? ob.phases : []).filter((p: any) => String(p?.title || '').trim() !== '' || String(p?.text || '').trim() !== '') const priceText = onboardingPrice > 0 ? `${T.onboardingOptional} · ${fmt(T.onboardingPriceOnce, { price: onboardingFrom ? fmt(T.priceFrom, { price: euro(onboardingPrice) }) : euro(onboardingPrice) })}` : T.onboardingIncluded const obNotes = [ ...(onboardingPrice > 0 && onboardingFrom ? [T.onboardingFromNote] : []), ...(onboardingTravel ? [T.onboardingTravelNote] : []) ] sections.push({ key: 'onboarding', node: { stack: [ { columns: [ { width: '*', ...sectionTitle(T.onboardingTitle) }, { width: 'auto', margin: [0, 18, 0, 0], table: { body: [[{ text: priceText, bold: true, fontSize: 9.5, color: BRAND.white, fillColor: BRAND.orange, margin: [8, 3, 8, 3] }]] }, layout: 'noBorders' } ] }, ...(String(ob.intro || '').trim() ? [{ text: String(ob.intro).trim(), fontSize: 9.5, color: BRAND.text, margin: [0, 0, 0, obNotes.length ? 4 : 10] }] : []), ...(obNotes.length ? [{ text: obNotes.join(' '), fontSize: 7.5, italics: true, color: BRAND.grayText, margin: [0, 0, 0, 10] }] : []), ...(highlights.length ? [{ unbreakable: true, table: { widths: highlights.map(() => '*'), body: [highlights.map((h: any) => ({ fillColor: BRAND.zebra, margin: [6, 8, 6, 8], stack: [ { text: String(h.value || ''), bold: true, fontSize: 15, color: BRAND.orange, alignment: 'center' }, { text: String(h.label || ''), fontSize: 7.5, color: BRAND.grayText, alignment: 'center', margin: [0, 2, 0, 0] } ] }))] }, layout: { hLineWidth: () => 0, vLineWidth: () => 2, vLineColor: () => BRAND.white, paddingLeft: () => 0, paddingRight: () => 0, paddingTop: () => 0, paddingBottom: () => 0 }, margin: [0, 0, 0, 10] }] : []), ...(phases.length ? [ { text: T.onboardingSteps, bold: true, fontSize: 9.5, color: BRAND.navy, margin: [0, 4, 0, 6] }, ...phases.map((p: any, i: number) => ({ unbreakable: true, columns: [ { width: 22, svg: numberBadge(i + 1), margin: [0, 1, 0, 0] }, { width: '*', stack: [ { text: String(p.title || ''), bold: true, fontSize: 9.5, color: BRAND.navy }, ...(String(p.text || '').trim() ? [{ text: String(p.text).trim(), fontSize: 8.5, color: BRAND.text, margin: [0, 1.5, 0, 0] }] : []) ] } ], columnGap: 8, margin: [0, 0, 0, 7] })) ] : []) ] } }) } // ===== ZUSATZLEISTUNGEN (all rows; chosen ones marked) ===== const addons: any[] = (Array.isArray(payload?.addons) ? payload.addons : []).filter((x: any) => String(x?.label || '').trim() !== '') if (on('addons') && addons.length) { const unitLabel = (u: string) => (u === 'once' ? T.unitOnce : u === 'hour' ? T.unitHour : u === 'month' ? T.unitMonth : '—') const rows: any[][] = addons.map((x: any) => { const note = String(x.note || '').trim() const labelCell = note ? { stack: [td(x.label, { bold: x.enabled === true }), td(note, { fontSize: 7.5, italics: true, color: BRAND.grayText, margin: [0, 1, 0, 0] })] } : td(x.label, { bold: x.enabled === true }) const priceCell = x.unit === 'text' || !(n0(x.price) > 0) ? td(note || T.onRequest, { alignment: 'right', italics: true }) : tdPrice(n0(x.price)) return [ { svg: x.enabled === true ? CHECK_SVG : DASH_SVG, width: 11, alignment: 'center', margin: [0, 1, 0, 0] }, labelCell, td(x.unit === 'text' ? '—' : unitLabel(x.unit), { alignment: 'center' }), priceCell ] }) // Legend line: check = chosen for this offer, dash = optional const legend = { columns: [ { width: 10, svg: CHECK_SVG, margin: [0, 1, 0, 0] }, { width: 'auto', text: T.addonSelected, fontSize: 7.5, color: BRAND.grayText, margin: [3, 0, 10, 0] }, { width: 10, svg: DASH_SVG, margin: [0, 1, 0, 0] }, { width: '*', text: T.addonOptional, fontSize: 7.5, color: BRAND.grayText, margin: [3, 0, 0, 0] } ], margin: [0, 5, 0, 0] } sections.push({ key: 'addons', node: { unbreakable: true, stack: [ sectionTitle(T.addonsTitle), priceTable([22, '*', 80, 110], [th(''), th(T.service), th(T.unit, 'center'), th(T.price, 'right')], rows), legend ] } }) } // ===== Weitere Positionen / Kundenanforderungen (same look as the fulfillment quote) ===== const customPositions = Array.isArray(payload?.customPositions) ? payload.customPositions.filter((r: any) => String(r?.description || '').trim() !== '') : [] if (customPositions.length) { const posRows: any[][] = customPositions.map((r: any) => { const raw = String(r?.price ?? '').trim() const numeric = Number(raw.replace(/€/g, '').replace(/\s/g, '').replace(',', '.')) return [td(String(r.description).trim()), td(String(r?.dimension || '').trim() || '—', { alignment: 'center' }), raw !== '' && Number.isFinite(numeric) ? tdPrice(numeric) : td(raw || '—', { alignment: 'right', italics: raw !== '' })] }) sections.push({ key: 'positions', node: { unbreakable: true, stack: [sectionTitle(T.extraPositions), priceTable(['*', 100, 120], [th(T.service), th(T.dimension, 'center'), th(T.price, 'right')], posRows)] } }) } const requirements = Array.isArray(payload?.requirements) ? payload.requirements.filter((r: any) => String(r?.text || '').trim() !== '') : [] if (requirements.length) { const reqRows: any[][] = requirements.map((r: any) => [ td(String(r.text).trim()), r?.answerType === 'text' ? td(String(r?.answerText || '').trim() || '—', { alignment: 'right', italics: true }) : { svg: r?.possible !== false ? CHECK_SVG : CROSS_SVG, width: 16, alignment: 'center' } ]) sections.push({ key: 'requirements', node: { unbreakable: true, stack: [sectionTitle(T.requirementsTitle), priceTable(['*', 90], [th(T.service), th(T.requirementsAnswer, 'center')], reqRows)] } }) } // ===== BEISPIELRECHNUNG ===== const fc = payload?.forecast || {} if (on('forecast') && fc.includeInPdf && Array.isArray(fc.lines) && fc.lines.length && !isOnRequest) { const label = (l: any) => { if (l.key === 'plan') return fmt(T.fcPlan, { plan: feeLabel, billing: yearly ? T.billingYearlyShort : T.billingMonthlyShort }) if (l.key === 'overage') return fmt(T.fcOverage, { n: int(l.qty) }) if (l.key === 'extraUsers') return fmt(T.fcExtraUsers, { n: int(l.qty) }) if (l.key === 'addons') return T.fcAddons return String(l.key) } const rows: any[][] = fc.lines.map((l: any) => [td(label(l)), tdPrice(n0(l.monthly))]) rows.push([ { text: T.fcTotalMonthly, bold: true, fontSize: 10, color: BRAND.white, fillColor: BRAND.navy }, { text: euro(n0(fc.totalMonthly)), bold: true, fontSize: 10, color: BRAND.orange, alignment: 'right', fillColor: BRAND.navy } ]) const oneTime: any[] = Array.isArray(fc.oneTime) ? fc.oneTime : [] for (const o of oneTime) { const isOb = o.key === 'onboarding' const label = `${T.fcOneTime}: ${isOb ? T.fcOnboarding : T.fcAddonsOnce}${isOb ? ` (${T.onboardingOptional})` : ''}` rows.push([td(label), isOb && onboardingFrom ? td(fmt(T.priceFrom, { price: euro(n0(o.amount)) }), { alignment: 'right', bold: true }) : tdPrice(n0(o.amount))]) } rows.push([td(T.fcFirstYear, { bold: true }), tdPrice(n0(fc.firstYear))]) if (n0(fc.perOrder) > 0) rows.push([td(T.fcPerOrder, { color: BRAND.grayText }), td(euro(n0(fc.perOrder)), { alignment: 'right', color: BRAND.grayText })]) sections.push({ key: 'forecast', node: { unbreakable: true, stack: [ sectionTitle(T.forecastTitle), priceTable(['*', 130], [th(T.service), th(T.price, 'right')], rows), { text: `${fmt(T.fcAssumption, { orders: int(a.ordersPerMonth), users: int(a.users) })} ${T.fcDisclaimer}`, fontSize: 7.5, italics: true, color: BRAND.grayText, margin: [0, 5, 0, 0] } ] } }) } // ===== Free text blocks anchored after sections ===== const textBlocks: any[] = (Array.isArray(payload?.customTexts) ? payload.customTexts : []).filter((b: any) => String(b?.text || '').trim() !== '') const textBlockNode = (b: any) => { const title = String(b?.title || '').trim() const text = String(b.text).replace(/\r\n?/g, '\n').trim() return { unbreakable: text.length < 1500, stack: [...(title ? [sectionTitle(title)] : []), { text, fontSize: 9.5, margin: [0, title ? 2 : 16, 0, 0] }] } } const renderedKeys = new Set(sections.map((s) => s.key)) const blocksByAnchor = new Map() for (const b of textBlocks) { const anchor = renderedKeys.has(String(b?.after)) ? String(b.after) : 'end' blocksByAnchor.set(anchor, [...(blocksByAnchor.get(anchor) || []), textBlockNode(b)]) } const body: any[] = [] for (const s of sections) body.push(s.node, ...(blocksByAnchor.get(s.key) || [])) body.push(...(blocksByAnchor.get('end') || [])) // ===== Hinweise + closing ===== const notes: any[] = [ { text: T.noteVat }, { text: fmt(T.noteValidity, { days: validDays, date: formatDateLocal(validUntil, language) }) }, { text: yearly ? fmt(T.noteTermYearly, { months: minTerm > 0 ? minTerm : 12 }) : fmt(T.noteTermMonthly, { term: minTerm > 0 ? fmt(T.noteTermMonthlyMin, { months: minTerm }) : T.noteTermMonthlyNone }) } ] if (!isOnRequest) notes.push({ text: T.noteOverage }) if (on('onboarding')) notes.push({ text: T.noteOnboarding }) if (onboardingOn && onboardingTravel) notes.push({ text: T.noteOnboardingTravel }) notes.push({ text: T.noteHosting }) const notesNode: any = { unbreakable: true, stack: [ sectionTitle(T.notesTitle), { ul: notes, fontSize: 8, color: BRAND.grayText, markerColor: BRAND.orange }, { columns: [ { width: 'auto', canvas: [{ type: 'rect', x: 0, y: 2, w: 3, h: 26, color: BRAND.orange }] }, { width: '*', stack: [{ text: T.closing, bold: true, fontSize: 11, color: BRAND.navy }, { text: T.closingTeam, fontSize: 9.5, color: BRAND.grayText, margin: [0, 3, 0, 0] }], margin: [10, 0, 0, 0] } ], margin: [0, 18, 0, 0] } ] } return { pageSize: 'A4', pageMargins: [40, 106, 40, 86], info: { title: `${T.docTitle} – ${c.company || ''}`, author: 'LogYou GmbH', subject: T.docTitle }, header: buildBrandHeader(T.docTitle, `app.logship.de · ${T.dateWord} ${formatDateLocal(dateIso, language)}`, logos.dark), footer: buildBrandFooter(T.page, T.of), content: [...head, ...body, notesNode], styles: { tableHeader: { fontSize: 8.5, bold: true, color: BRAND.white } }, defaultStyle: { font: 'Roboto', fontSize: 9.5, color: BRAND.text, lineHeight: 1.25 } } } /** Build the final PDF buffer + authoritative filename. */ export const generateSoftwareQuotePdf = async (payload: any): Promise<{ buffer: Buffer; filename: string }> => { // Same defensive vfs extraction as generateQuotePdf (export shape varies by bundler). const pdfMake = await import('pdfmake/build/pdfmake.js') const pdfFonts = await import('pdfmake/build/vfs_fonts.js') // @ts-ignore const pdfMakeInstance = pdfMake.default || pdfMake let vfs: any = 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 && Object.keys((pdfFonts as any).default).some((k: string) => k.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 logos = await getQuoteLogos() if (payload?.visuals && (payload.visuals.logoDataUrl || payload.visuals.screenshotDataUrl)) { const { normalizePdfImage } = await import('./pdfImages') const [logo, shot] = await Promise.all([ payload.visuals.includeLogo && payload.visuals.logoDataUrl ? normalizePdfImage(payload.visuals.logoDataUrl) : Promise.resolve(null), payload.visuals.includeScreenshot && payload.visuals.screenshotDataUrl ? normalizePdfImage(payload.visuals.screenshotDataUrl) : Promise.resolve(null) ]) payload = { ...payload, visuals: { ...payload.visuals, logoDataUrl: logo || '', includeLogo: !!logo && payload.visuals.includeLogo, screenshotDataUrl: shot || '', includeScreenshot: !!shot && payload.visuals.includeScreenshot } } } const docDefinition = buildSoftwareQuoteDocDefinition(payload, logos) const filename = sanitizeSoftwareQuoteFilename(payload?.customer?.company, 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 } } /** Persistable conditions of a software offer (offer_conditions.softwareQuote). */ export const conditionsFromSoftwareQuotePayload = (p: any) => ({ offerType: 'software', language: p?.meta?.language === 'en' ? 'en' : 'de', customer: p?.customer || {}, plan: p?.plan || {}, assumptions: p?.assumptions || {}, features: p?.features && typeof p.features === 'object' ? p.features : {}, customFeatures: Array.isArray(p?.customFeatures) ? p.customFeatures : [], featuresCompare: p?.featuresCompare !== false, addons: Array.isArray(p?.addons) ? p.addons : [], onboarding: p?.onboarding || {}, customPositions: Array.isArray(p?.customPositions) ? p.customPositions : [], requirements: Array.isArray(p?.requirements) ? p.requirements : [], sections: p?.sections && typeof p.sections === 'object' ? p.sections : {}, customTexts: Array.isArray(p?.customTexts) ? p.customTexts : [], forecast: { includeInPdf: p?.forecast?.includeInPdf === true }, validDays: resolveQuoteValidity(p?.meta).validDays, confirmation: { enabled: p?.confirmation?.enabled !== false, attachPdf: p?.confirmation?.attachPdf === true } })