/** * Offer/quote (Angebot) business logic — single source of truth for the numbers. * The client computes defaults, forecast and recommendations; the server routes * (server/api/offers/quote/*) only render/send what they receive, so the PDF * always matches what the user saw in the modal. * * Extensible for the later contract (Vertrag) generator via the payload's * `docType` discriminator. */ // Standard list prices (status 2026, from the reference Word Angebot). // Used as defaults for new quotes and as floors for below-list warnings. export const QUOTE_LIST_PRICES = { orderBaseprice: 2.90, orderBaseprice2: 0, orderBaseprice3: 0, orderPickprice: 0.35, qtyPickfree: 1, customsSurcharge: 1.00, palletMovePrice: 9.50, returnBaseprice: 1.50, returnPickprice: 0.30, hourlyRate: 49, monthlyFee: 89.90, shelfSmallPrice: 3.50, shelfLargePrice: 5.50, palletPrice: 9.50, volumeM3Price: 0, einwegpalettePrice: 9.50, cartons: { s: { price: 0.55, paper: 0.15 }, m: { price: 0.60, paper: 0.20 }, l: { price: 0.85, paper: 0.25 }, xl: { price: 1.25, paper: 0.70 } }, dhlTiers: { kg1: 3.49, kg2: 4.50, kg5: 4.80, kg10: 5.20, kg20: 5.50, kg30: 7.50 } } // Volume-tier price recommendations keyed on orders/month. Tune here only. export const QUOTE_VOLUME_TIERS = [ { minOrders: 0, orderBaseprice: 2.90, monthlyFee: 89.90, hourlyRate: 49 }, { minOrders: 500, orderBaseprice: 2.50, monthlyFee: 89.90, hourlyRate: 49 }, { minOrders: 1000, orderBaseprice: 2.20, monthlyFee: 49.90, hourlyRate: 45 }, { minOrders: 2500, orderBaseprice: 1.90, monthlyFee: 49.90, hourlyRate: 42 }, { minOrders: 5000, orderBaseprice: 1.70, monthlyFee: 49.90, hourlyRate: 42 } ] // Default seed for the optional, customer-facing volume-price matrix (a // printed staffel table, distinct from the internal QUOTE_VOLUME_TIERS // recommendations above). Both tier ranges and row count are fully // user-editable in the modal — these are just the starting columns/row. export const QUOTE_VOLUME_MATRIX_DEFAULTS = { tierLabels: ['< 500', '> 500', '> 1.000', '> 2.000'], rowLabel: 'Basispreis pro Auftrag (inkl. Verpackung, Einlagerung, Lagerung, Versicherung)' } // PDF sections the user can switch off per offer (PDF order). Everything else // (address card, intro, Hinweise, closing) always prints; Preisstaffel / // Weitere Positionen / Prognose keep their own existing toggles. // Keep in sync with PAGE2_KEYS / the section keys in server/utils/offers/quotePdf.ts. export const QUOTE_SECTION_KEYS = ['assumptions', 'fulfillment', 'inbound', 'storage', 'monthly', 'packaging', 'shipping'] as const // Anchors a custom text block can be printed after (PDF order) + 'end' // (= after the Prognose, before the Hinweise). export const QUOTE_TEXT_ANCHORS = ['assumptions', 'fulfillment', 'volumeMatrix', 'inbound', 'storage', 'monthly', 'packaging', 'shipping', 'positions', 'end'] as const const isTextAnchor = (v: any): boolean => (QUOTE_TEXT_ANCHORS as readonly string[]).includes(String(v)) // Price floors — anything below triggers an internal "needs approval" warning. export const QUOTE_PRICE_FLOORS: Record = { orderBaseprice: 1.90, orderPickprice: 0.35, returnBaseprice: 1.50, returnPickprice: 0.30, hourlyRate: 42, monthlyFee: 49.90, shelfSmallPrice: 3.50, shelfLargePrice: 5.50, palletPrice: 9.50 } const num = (v: any): number => { const n = Number(v) return Number.isFinite(n) ? n : 0 } // Prefer the partner's value, but never let a 0/empty price into the quote. const priceOr = (v: any, fallback: number): number => { const n = num(v) return n > 0 ? n : fallback } const firstOfNextMonth = (): string => { const d = new Date() const next = new Date(d.getFullYear(), d.getMonth() + 1, 1) const mm = String(next.getMonth() + 1).padStart(2, '0') return `${next.getFullYear()}-${mm}-01` } /** * Build the reactive quote form model from a host-page prefill. * prefill = { source, recordId, recordUu?, company, contactName, email, phone, pricing?, storageQty? } * pricing === null/undefined → pure list prices (lead case). */ export const createQuoteForm = (prefill: any = {}) => { const p = prefill?.pricing || {} const sq = prefill?.storageQty || {} const L = QUOTE_LIST_PRICES return { source: prefill?.source || 'partner', recordId: num(prefill?.recordId), recordUu: prefill?.recordUu || '', language: 'de', customer: { company: prefill?.company || '', contactName: prefill?.contactName || '', email: prefill?.email || '', phone: prefill?.phone || '', street: '', zip: '', city: '', country: 'Deutschland', website: String(prefill?.website || '').trim() }, // Optional PDF visuals — two independent, combinable options: a hero // screenshot of the customer's website (captured server-side from // customer.website) and the customer's logo (uploaded in the modal or // prefilled from the record's stored image). Data URLs live only in the // form/payload — they are NOT persisted into offer_conditions. visuals: { includeScreenshot: String(prefill?.website || '').trim() !== '', screenshotDataUrl: '', includeLogo: !!prefill?.logoDataUrl, logoDataUrl: prefill?.logoDataUrl || '' }, assumptions: { businessModel: 'B2C', plannedStart: firstOfNextMonth(), shopSystem: '', avgPicksPerOrder: 1.5, ordersPerMonth: 500, skuCount: 100, returnRatePct: 5, typicalCarton: 'm', storage: { shelfSmallQty: num(sq.shelfSmall), shelfLargeQty: num(sq.shelfLarge), palletQty: num(sq.pallet), volumeM3: 0 } }, pricing: { orderBaseprice: priceOr(p.orderBaseprice, L.orderBaseprice), orderBaseprice2: num(p.orderBaseprice2), orderBaseprice3: num(p.orderBaseprice3), orderPickprice: priceOr(p.orderPickprice, L.orderPickprice), qtyPickfree: num(p.qtyPickfree) > 0 ? num(p.qtyPickfree) : L.qtyPickfree, customsSurcharge: L.customsSurcharge, palletMovePrice: L.palletMovePrice, returnBaseprice: priceOr(p.returnBaseprice, L.returnBaseprice), returnPickprice: priceOr(p.returnPickprice, L.returnPickprice), hourlyRate: priceOr(p.hourlyRate, L.hourlyRate), monthlyFee: priceOr(p.monthlyFee, L.monthlyFee), shelfSmallPrice: priceOr(p.shelfSmallPrice, L.shelfSmallPrice), shelfLargePrice: priceOr(p.shelfLargePrice, L.shelfLargePrice), palletPrice: priceOr(p.palletPrice, L.palletPrice), volumeM3Price: num(p.volumeM3Price), einwegpalettePrice: L.einwegpalettePrice, cartons: { s: { price: priceOr(p.cartons?.s, L.cartons.s.price), paper: L.cartons.s.paper }, m: { price: priceOr(p.cartons?.m, L.cartons.m.price), paper: L.cartons.m.paper }, l: { price: priceOr(p.cartons?.l, L.cartons.l.price), paper: L.cartons.l.paper }, xl: { price: priceOr(p.cartons?.xl, L.cartons.xl.price), paper: L.cartons.xl.paper } }, dhlTiers: { ...L.dhlTiers }, speditionNote: '', // When false, the quote hides all shipping-fee pricing (table + forecast // line) and instead states that the customer's own freight contract is // used — shippingCarrier is a free-text service name (DHL, DPD, ...). showShippingFees: p.showShippingFees !== false, shippingCarrier: String(p.shippingCarrier || '') }, // Wareneingang section configuration (how goods receipt is billed in the PDF). // Mode 'hourly' prints "nach tatsächlichem Zeitaufwand*" — no rate; the hourly // rate is already stated by the Arbeitszeit row + footnote. inbound: { mode: 'included', // 'included' | 'hourly' | 'custom' includedPricing: 'included', // standard receipt row: 'included' | 'free' | 'byEffort' | 'fixed' includedFixedPrice: 0, extraPricing: 'byEffort', // extra-effort row: same options extraFixedPrice: 0, customText: '' // mode 'custom': replaces the whole section content }, // By-effort services — when enabled, printed as rows in the Fulfillment & Retoure table services: { grading: false, // Grading / Aufbereitung kitting: false, // Konfektionierung preFba: false // Pre-FBA (Amazon-Vorbereitung) }, // Free-form extra positions, printed as an own PDF section customPositions: [] as Array<{ description: string; dimension: string; price: string }>, // Which PDF sections print (all on by default) — see QUOTE_SECTION_KEYS. sections: Object.fromEntries(QUOTE_SECTION_KEYS.map((k) => [k, true])) as Record, // Free text blocks: optional heading + text, printed like a regular PDF // section right after the chosen anchor (`after`, see QUOTE_TEXT_ANCHORS). customTexts: [] as Array<{ title: string; text: string; after: string }>, // Optional customer-facing volume-price matrix (order-volume staffel), // printed as its own PDF table when enabled. Tier columns and price rows // are both fully flexible — add/remove either in the modal. volumeMatrix: { enabled: false, tiers: QUOTE_VOLUME_MATRIX_DEFAULTS.tierLabels.map((label) => ({ label })), rows: [{ label: QUOTE_VOLUME_MATRIX_DEFAULTS.rowLabel, prices: QUOTE_VOLUME_MATRIX_DEFAULTS.tierLabels.map(() => '') }] } as { enabled: boolean; tiers: Array<{ label: string }>; rows: Array<{ label: string; prices: string[] }> }, forecast: { includeInPdf: false }, meta: { validDays: 7 }, // Opt-in e-mail attachments beyond the quote PDF (marketing flyer, default off). attachments: { flyer: false }, // Online confirmation: an "Angebot öffnen" button in the e-mail (viewing only — // accepting is a separate, optional step on the page) // (public /confirm/ page, valid as long as the quote). Default on; // the customer can still simply reply by mail. `attachPdf` (default off) // additionally attaches the quote PDF to the link e-mail; without the // link the PDF is ALWAYS attached (server-enforced fallback). confirmation: { enabled: true, attachPdf: false }, email: { to: prefill?.email || '', cc: '', subject: '', message: '' } } } // Copy every non-empty scalar from src onto keys that exist in target // (objects are skipped — nested structures are merged explicitly by callers). const fillNonEmpty = (target: any, src: any) => { if (!target || !src || typeof src !== 'object') return for (const k of Object.keys(target)) { const v = src[k] if (v !== undefined && v !== null && String(v) !== '' && typeof v !== 'object') target[k] = v } } /** * Apply the record's last-sent (saved) quote conditions onto a freshly created * form. Saved values win over defaults/prefill; empty saved fields keep the * fresh prefill. A planned start in the past is NOT resurrected. */ export const applySavedQuoteConditions = (form: any, saved: any) => { if (!saved || typeof saved !== 'object') return form if (saved.language === 'en' || saved.language === 'de') form.language = saved.language fillNonEmpty(form.customer, saved.customer) if (saved.assumptions) { fillNonEmpty(form.assumptions, saved.assumptions) fillNonEmpty(form.assumptions.storage, saved.assumptions.storage) const today = new Date().toISOString().slice(0, 10) if (String(form.assumptions.plannedStart || '') < today) form.assumptions.plannedStart = firstOfNextMonth() } if (saved.pricing) { fillNonEmpty(form.pricing, saved.pricing) for (const s of ['s', 'm', 'l', 'xl']) fillNonEmpty(form.pricing.cartons[s], saved.pricing.cartons?.[s]) fillNonEmpty(form.pricing.dhlTiers, saved.pricing.dhlTiers) } fillNonEmpty(form.inbound, saved.inbound) fillNonEmpty(form.services, saved.services) if (Array.isArray(saved.customPositions)) { form.customPositions = saved.customPositions.map((r: any) => ({ description: String(r?.description || ''), dimension: String(r?.dimension || ''), price: String(r?.price ?? '') })) } if (saved.volumeMatrix && typeof saved.volumeMatrix === 'object') { form.volumeMatrix.enabled = !!saved.volumeMatrix.enabled if (Array.isArray(saved.volumeMatrix.tiers) && saved.volumeMatrix.tiers.length) { form.volumeMatrix.tiers = saved.volumeMatrix.tiers.map((t: any) => ({ label: String(t?.label ?? '') })) } if (Array.isArray(saved.volumeMatrix.rows) && saved.volumeMatrix.rows.length) { form.volumeMatrix.rows = saved.volumeMatrix.rows.map((r: any) => ({ label: String(r?.label ?? ''), prices: Array.isArray(r?.prices) ? r.prices.map((v: any) => String(v ?? '')) : [] })) } } // Section toggles are booleans — fillNonEmpty skips objects, so merge explicitly. // Blobs saved before this feature have no `sections` → everything stays on. if (saved.sections && typeof saved.sections === 'object') { for (const k of QUOTE_SECTION_KEYS) { if (typeof saved.sections[k] === 'boolean') form.sections[k] = saved.sections[k] } } if (Array.isArray(saved.customTexts)) { form.customTexts = saved.customTexts.map((b: any) => ({ title: String(b?.title || ''), text: String(b?.text || ''), after: isTextAnchor(b?.after) ? String(b.after) : 'end' })) } if (Number(saved.validDays) > 0) form.meta.validDays = Number(saved.validDays) if (saved.confirmation && saved.confirmation.enabled === false) form.confirmation.enabled = false if (saved.confirmation && saved.confirmation.attachPdf === true) form.confirmation.attachPdf = true if (!form.email.to && saved.customer?.email) form.email.to = saved.customer.email return form } /** * Monthly cost forecast. Pure function of the form model. * Shipping (DHL) is its own position, estimated with the ≤ 2 kg tier price * per order, and included in the monthly total. */ export const computeQuoteForecast = (form: any) => { const a = form.assumptions const p = form.pricing // A section switched off in the PDF is out of scope for this customer, so // its cost line is dropped from the forecast as well (same idea as the // shipping line vanishing when the customer's own freight contract is used). const on = (key: string) => form.sections?.[key] !== false const showShipping = p.showShippingFees !== false && on('shipping') const orders = num(a.ordersPerMonth) const billablePicks = Math.max(0, num(a.avgPicksPerOrder) - num(p.qtyPickfree)) const carton = p.cartons[a.typicalCarton] || p.cartons.m const fulfillment = on('fulfillment') ? orders * (num(p.orderBaseprice) + billablePicks * num(p.orderPickprice)) : 0 const returns = on('fulfillment') ? orders * (num(a.returnRatePct) / 100) * (num(p.returnBaseprice) + num(a.avgPicksPerOrder) * num(p.returnPickprice)) : 0 const storage = on('storage') ? num(a.storage.shelfSmallQty) * num(p.shelfSmallPrice) + num(a.storage.shelfLargeQty) * num(p.shelfLargePrice) + num(a.storage.palletQty) * num(p.palletPrice) + num(a.storage.volumeM3) * num(p.volumeM3Price) : 0 const serviceFee = on('monthly') ? num(p.monthlyFee) : 0 const packaging = on('packaging') ? orders * (num(carton.price) + num(carton.paper)) : 0 // No LogYou shipping fee when the customer's own freight contract is used. const shipping = showShipping ? orders * num(p.dhlTiers.kg2) : 0 const totalMonthly = fulfillment + returns + storage + serviceFee + packaging + shipping // Dedicated fulfillment price per order: fulfillment + packaging + shipping ONLY // (storage, service fee and returns are deliberately excluded). const fulfillmentPerOrder = orders > 0 ? (fulfillment + packaging + shipping) / orders : 0 const lines: Array<{ key: string; monthly: number }> = [] if (on('fulfillment')) lines.push({ key: 'fulfillment', monthly: fulfillment }, { key: 'returns', monthly: returns }) if (on('storage')) lines.push({ key: 'storage', monthly: storage }) if (on('monthly')) lines.push({ key: 'serviceFee', monthly: serviceFee }) if (on('packaging')) lines.push({ key: 'packaging', monthly: packaging }) if (showShipping) lines.push({ key: 'shipping', monthly: shipping }) return { lines, totalMonthly, fulfillmentPerOrder } } /** * Volume-tier suggestions + below-list warnings for the modal side panel. * Internal sales tool only — never printed into the PDF. */ export const computeQuoteRecommendations = (form: any) => { const orders = num(form.assumptions.ordersPerMonth) const tier = [...QUOTE_VOLUME_TIERS].reverse().find(t => orders >= t.minOrders) || QUOTE_VOLUME_TIERS[0] const suggestions: any[] = [] const tierFields: Array<'orderBaseprice' | 'monthlyFee' | 'hourlyRate'> = ['orderBaseprice', 'monthlyFee', 'hourlyRate'] for (const field of tierFields) { const current = num(form.pricing[field]) const suggested = num((tier as any)[field]) if (Math.abs(current - suggested) >= 0.01) { suggestions.push({ field, current, suggested, tierMinOrders: tier.minOrders, orders }) } } const warnings: any[] = [] for (const [field, floor] of Object.entries(QUOTE_PRICE_FLOORS)) { const current = num(form.pricing[field]) if (current > 0 && current < floor) { warnings.push({ field, current, floor }) } } return { suggestions, warnings } } // Umlaut-safe filename part (mail clients mangle RFC-2231-encoded names). export const sanitizeCompanyForFilename = (company: string): string => { return 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' } export const quoteFilename = (company: string): string => { const date = new Date().toISOString().slice(0, 10) return `Angebot_LogYou_${sanitizeCompanyForFilename(company)}_${date}.pdf` } /** Assemble the payload for the preview/send server routes. */ export const buildQuotePayload = (form: any, forecast: any, withEmail = false) => { const payload: any = { docType: 'quote', source: form.source, recordId: form.recordId, recordUu: form.recordUu || undefined, customer: { ...form.customer, country: form.customer.country || 'Deutschland' }, assumptions: JSON.parse(JSON.stringify(form.assumptions)), pricing: JSON.parse(JSON.stringify(form.pricing)), inbound: JSON.parse(JSON.stringify(form.inbound || { mode: 'included' })), services: JSON.parse(JSON.stringify(form.services || {})), customPositions: (form.customPositions || []) .filter((r: any) => String(r?.description || '').trim() !== '') .map((r: any) => ({ description: String(r.description).trim(), dimension: String(r?.dimension || '').trim(), price: String(r?.price ?? '').trim() })), sections: Object.fromEntries(QUOTE_SECTION_KEYS.map((k) => [k, form.sections?.[k] !== false])), // Title-only blocks are dropped (nothing to print); unknown anchors go to the end. customTexts: (form.customTexts || []) .filter((b: any) => String(b?.text || '').trim() !== '') .map((b: any) => ({ title: String(b?.title || '').trim(), text: String(b.text).replace(/\r\n?/g, '\n').trim(), after: isTextAnchor(b?.after) ? String(b.after) : 'end' })), volumeMatrix: { enabled: !!form.volumeMatrix?.enabled, tiers: (form.volumeMatrix?.tiers || []).map((t: any) => ({ label: String(t?.label || '').trim() })), rows: (form.volumeMatrix?.rows || []) .map((r: any) => ({ label: String(r?.label || '').trim(), prices: (r?.prices || []).map((v: any) => String(v ?? '').trim()) })) .filter((r: any) => r.label !== '' || r.prices.some((v: string) => v !== '')) }, visuals: (() => { const v = form.visuals || {} const websiteUrl = String(form.customer?.website || '').trim() const withShot = !!(v.includeScreenshot && websiteUrl && v.screenshotDataUrl) const withLogo = !!(v.includeLogo && v.logoDataUrl) return { websiteUrl, includeScreenshot: withShot, screenshotDataUrl: withShot ? v.screenshotDataUrl : '', includeLogo: withLogo, logoDataUrl: withLogo ? v.logoDataUrl : '' } })(), attachments: { flyer: form.attachments?.flyer === true }, confirmation: { enabled: form.confirmation?.enabled !== false, attachPdf: form.confirmation?.attachPdf === true }, forecast: { includeInPdf: !!form.forecast.includeInPdf, lines: forecast.lines, totalMonthly: forecast.totalMonthly, fulfillmentPerOrder: forecast.fulfillmentPerOrder }, meta: { validDays: num(form.meta.validDays) || 7, language: form.language === 'en' ? 'en' : 'de', date: new Date().toISOString().slice(0, 10) } } if (withEmail) { payload.email = { to: form.email.to.trim(), cc: form.email.cc.trim(), subject: form.email.subject, message: form.email.message } } return payload } /** * Language-dependent email defaults (subject + body). * Conversion-focused: ONE low-friction primary action (reply with the accept * phrase), a numbered what-happens-next path, the validity as a soft deadline, * and risk-reversal claims that are actually stated in the quote (free * onboarding, billing by actual consumption). The user can still edit freely. */ export const quoteEmailDefaults = (language: string, contactName: string, company: string, validDays: number, onlineConfirm = false, attachPdf = true) => { // Link-only send: the PDF is not attached, the customer reads/downloads it via the button. const linkOnly = onlineConfirm && !attachPdf if (language === 'en') { return { subject: `Your fulfillment quote from LogYou – ${company}`, message: `Dear ${contactName || 'Sir or Madam'},\n\nthank you for your interest in LogYou fulfillment. ${linkOnly ? `Your individual quote is ready for you online — simply click "Open quote" in this email to read it and download it as PDF. Clicking the button does not accept anything; you can look at the quote as often as you like. It is valid for ${validDays} days.` : `Please find attached your individual quote as PDF — it is valid for ${validDays} days.`}\n\nGetting started is easy:\n1. Accept the quote: ${onlineConfirm ? 'once you have read the quote, you can accept it on the same page in a separate step (takes under a minute) — or simply reply to this email with "Quote accepted".' : 'simply reply to this email with "Quote accepted" — that is all it takes, no formalities.'}\n2. Onboarding: we will get back to you within one business day to plan shop integration, goods delivery and your start date together.\n3. Go live: we store your goods and ship your orders.\n\nOnboarding and setup are free of charge, and you are only billed for what you actually use.\n\nAny questions first? Just reply to this email or call us at +49 60 33 / 91 60 57-0 — we are happy to help.\n\nBest regards\nYour LogYou team` } } return { subject: `Ihr Fulfillment-Angebot von LogYou – ${company}`, message: `Guten Tag ${contactName || 'Damen und Herren'},\n\nvielen Dank für Ihr Interesse an LogYou Fulfillment. ${linkOnly ? `Ihr individuelles Angebot steht für Sie online bereit — klicken Sie einfach in dieser E-Mail auf "Angebot öffnen", um es zu lesen und als PDF herunterzuladen. Der Klick auf den Button ist unverbindlich, Sie können sich das Angebot beliebig oft ansehen. Es ist ${validDays} Tage gültig.` : `Anbei erhalten Sie Ihr individuelles Angebot als PDF — es ist ${validDays} Tage gültig.`}\n\nSo einfach geht es weiter:\n1. Angebot annehmen: ${onlineConfirm ? 'Wenn Sie das Angebot gelesen haben, können Sie es auf derselben Seite in einem separaten Schritt online annehmen (dauert unter einer Minute) — oder antworten Sie einfach formlos mit "Angebot angenommen".' : 'Antworten Sie auf diese E-Mail einfach mit "Angebot angenommen" — das genügt, ganz formlos.'}\n2. Onboarding: Wir melden uns innerhalb von einem Werktag bei Ihnen und planen gemeinsam Shop-Anbindung, Warenanlieferung und Starttermin.\n3. Start: Wir lagern Ihre Ware ein und versenden Ihre Bestellungen.\n\nOnboarding und Setup sind für Sie kostenfrei, abgerechnet wird nur, was Sie tatsächlich nutzen.\n\nSie haben vorab noch Fragen? Antworten Sie einfach auf diese E-Mail oder rufen Sie uns an: +49 60 33 / 91 60 57-0 — wir helfen gerne weiter.\n\nMit freundlichen Grüßen\nIhr LogYou Team` } }