/**
* 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) => ``
// 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 }
})