/** * Pricing Calculator für Skrift Konfigurator * Berechnet Preise basierend auf Kundentyp (B2B/B2C) und Produktkonfiguration */ /** * Sichere mathematische Formel-Auswertung ohne eval() * Unterstützt: +, -, *, /, Klammern, Zahlen, Dezimalzahlen, * Vergleichsoperatoren (>=, <=, >, <, ==, !=), ternärer Operator (?:), * und Math-Funktionen (sqrt, abs, min, max, pow, floor, ceil, round) */ function safeEvaluateMathFormula(formula) { // Erlaubte Math-Funktionen (Whitelist) const mathFunctions = { 'sqrt': Math.sqrt, 'abs': Math.abs, 'min': Math.min, 'max': Math.max, 'pow': Math.pow, 'floor': Math.floor, 'ceil': Math.ceil, 'round': Math.round, }; // Tokenizer: Zerlegt Formel in Tokens function tokenize(str) { const tokens = []; let i = 0; str = str.replace(/\s+/g, ''); // Whitespace entfernen while (i < str.length) { const char = str[i]; // Math.xxx Funktionen if (str.substring(i, i + 5) === 'Math.') { i += 5; let funcName = ''; while (i < str.length && /[a-zA-Z]/.test(str[i])) { funcName += str[i]; i++; } if (mathFunctions[funcName]) { tokens.push({ type: 'function', name: funcName }); } else { throw new Error(`Unbekannte Math-Funktion: Math.${funcName}`); } } // Zahl (inkl. Dezimalzahlen) else if (/[0-9.]/.test(char) || (char === '-' && (tokens.length === 0 || ['(', '+', '-', '*', '/', '>=', '<=', '>', '<', '==', '!=', '?', ':'].includes(tokens[tokens.length - 1]?.type || tokens[tokens.length - 1])))) { let numStr = ''; if (char === '-') { numStr = '-'; i++; } while (i < str.length && /[0-9.]/.test(str[i])) { numStr += str[i]; i++; } const num = parseFloat(numStr); if (isNaN(num)) { throw new Error(`Ungültige Zahl: ${numStr}`); } tokens.push({ type: 'number', value: num }); } // Zwei-Zeichen-Operatoren else if (str.substring(i, i + 2) === '>=') { tokens.push({ type: '>=' }); i += 2; } else if (str.substring(i, i + 2) === '<=') { tokens.push({ type: '<=' }); i += 2; } else if (str.substring(i, i + 2) === '==') { tokens.push({ type: '==' }); i += 2; } else if (str.substring(i, i + 2) === '!=') { tokens.push({ type: '!=' }); i += 2; } // Ein-Zeichen-Operatoren und Klammern else if (['+', '-', '*', '/', '(', ')', '>', '<', '?', ':', ','].includes(char)) { tokens.push({ type: char }); i++; } // Ungültiges Zeichen else { throw new Error(`Ungültiges Zeichen in Formel: ${char}`); } } return tokens; } // Rekursiver Parser mit Operator-Präzedenz function parse(tokens) { let pos = 0; function peek() { return tokens[pos]; } function consume(expectedType) { const token = tokens[pos]; if (expectedType && token?.type !== expectedType) { throw new Error(`Erwartet ${expectedType}, gefunden ${token?.type}`); } pos++; return token; } // Ternärer Operator (niedrigste Präzedenz) function parseTernary() { let condition = parseComparison(); if (peek()?.type === '?') { consume('?'); const trueValue = parseTernary(); consume(':'); const falseValue = parseTernary(); return condition ? trueValue : falseValue; } return condition; } // Vergleichsoperatoren function parseComparison() { let left = parseAddSub(); while (peek()?.type && ['>=', '<=', '>', '<', '==', '!='].includes(peek().type)) { const op = consume().type; const right = parseAddSub(); switch (op) { case '>=': left = left >= right ? 1 : 0; break; case '<=': left = left <= right ? 1 : 0; break; case '>': left = left > right ? 1 : 0; break; case '<': left = left < right ? 1 : 0; break; case '==': left = left === right ? 1 : 0; break; case '!=': left = left !== right ? 1 : 0; break; } } return left; } // Addition und Subtraktion function parseAddSub() { let left = parseMulDiv(); while (peek()?.type && ['+', '-'].includes(peek().type)) { const op = consume().type; const right = parseMulDiv(); left = op === '+' ? left + right : left - right; } return left; } // Multiplikation und Division function parseMulDiv() { let left = parseUnary(); while (peek()?.type && ['*', '/'].includes(peek().type)) { const op = consume().type; const right = parseUnary(); if (op === '/') { if (right === 0) throw new Error('Division durch Null'); left = left / right; } else { left = left * right; } } return left; } // Unäre Operatoren (negatives Vorzeichen) function parseUnary() { if (peek()?.type === '-') { consume('-'); return -parseUnary(); } return parsePrimary(); } // Primäre Ausdrücke (Zahlen, Klammern, Funktionen) function parsePrimary() { const token = peek(); if (!token) { throw new Error('Unerwartetes Ende der Formel'); } // Zahl if (token.type === 'number') { consume(); return token.value; } // Funktion if (token.type === 'function') { consume(); consume('('); // Argumente sammeln const args = []; if (peek()?.type !== ')') { args.push(parseTernary()); while (peek()?.type === ',') { consume(','); args.push(parseTernary()); } } consume(')'); const func = mathFunctions[token.name]; return func(...args); } // Geklammerter Ausdruck if (token.type === '(') { consume('('); const value = parseTernary(); consume(')'); return value; } throw new Error(`Unerwartetes Token: ${token.type}`); } const result = parseTernary(); if (pos < tokens.length) { throw new Error(`Unerwartete Tokens am Ende: ${tokens.slice(pos).map(t => t.type).join(', ')}`); } return result; } const tokens = tokenize(formula); return parse(tokens); } /** * Holt Preise aus Backend-Settings */ function getPrices() { return window.SkriftConfigurator?.settings?.prices || {}; } /** * Holt dynamische Pricing-Einstellungen aus Backend */ function getDynamicPricing() { return window.SkriftConfigurator?.settings?.dynamic_pricing || {}; } /** * Prüft ob es sich um Follow-ups handelt */ function isFollowups(state) { return state.ctx?.product?.key === "follow-ups"; } /** * Prüft ob Kunde Business-Kunde ist */ function isBusinessCustomer(state) { return state.answers?.customerType === "business"; } /** * Prüft ob ein Land Deutschland ist (DE oder Deutschland) * Case-insensitive Prüfung */ function isGermany(country) { if (!country || country.trim() === "") return true; // Leeres Land = Deutschland (Standardannahme) const normalized = country.trim().toLowerCase(); return normalized === "de" || normalized === "deutschland" || normalized === "germany" || normalized === "ger" || normalized === "deu"; } /** * Zählt Inland- und Ausland-Empfänger * Berücksichtigt sowohl klassische Adressen (recipientRows) als auch freie Adressen (freeAddressRows) */ export function countRecipientsByCountry(state) { const quantity = state.answers?.quantity || 0; const addressMode = state.addressMode || 'classic'; let domesticCount = 0; let internationalCount = 0; if (addressMode === 'classic') { const rows = Array.isArray(state.recipientRows) ? state.recipientRows : []; for (let i = 0; i < quantity; i++) { const country = rows[i]?.country || ""; if (isGermany(country)) { domesticCount++; } else { internationalCount++; } } } else { // Freie Adresse: line5 ist das Land const rows = Array.isArray(state.freeAddressRows) ? state.freeAddressRows : []; for (let i = 0; i < quantity; i++) { const country = rows[i]?.line5 || ""; if (isGermany(country)) { domesticCount++; } else { internationalCount++; } } } return { domesticCount, internationalCount }; } /** * Berechnet die Versandpreise pro Stück für Inland und Ausland * Formel: Porto + Serviceaufschlag + Kuvert + Aufschlag Beschriftung */ export function calculateDirectShippingPrices(state) { const prices = getPrices(); // Basis-Komponenten const portoDomestic = prices.shipping_domestic || 0.95; const portoInternational = prices.shipping_international || 1.25; const serviceCharge = prices.shipping_service || 0.95; const envelopeBase = prices.envelope_base || 0.50; const labelingCharge = prices.envelope_labeling || 0.50; // Versand Inland = Porto DE + Serviceaufschlag + Kuvert + Aufschlag Beschriftung const domesticPrice = portoDomestic + serviceCharge + envelopeBase + labelingCharge; // Versand Ausland = Porto Ausland + Serviceaufschlag + Kuvert + Aufschlag Beschriftung const internationalPrice = portoInternational + serviceCharge + envelopeBase + labelingCharge; return { domesticPrice, internationalPrice, portoDomestic, portoInternational, serviceCharge, envelopeBase, labelingCharge, }; } /** * Berechnet Multiplikator basierend auf monatlichem Volumen (nur Follow-ups) */ function getFollowupMultiplier(monthlyVolume) { const prices = getPrices(); if (monthlyVolume >= 1000) return prices.followup_mult_1000_plus || 1.0; if (monthlyVolume >= 500) return prices.followup_mult_500_999 || 1.2; if (monthlyVolume >= 200) return prices.followup_mult_200_499 || 1.4; if (monthlyVolume >= 50) return prices.followup_mult_50_199 || 1.7; if (monthlyVolume >= 5) return prices.followup_mult_5_49 || 2.0; return 2.0; // Default für < 5 } /** * Berechnet dynamischen Preis-Multiplikator basierend auf Menge * (außer für Follow-ups) */ function getDynamicPriceMultiplier(state) { // Follow-ups verwenden eigene Logik if (isFollowups(state)) { return 1.0; } const quantity = state.answers.quantity || 0; const isB2B = isBusinessCustomer(state); const dynamicPricing = getDynamicPricing(); // Welche Formel verwenden? const formula = isB2B ? dynamicPricing.business_formula : dynamicPricing.private_formula; // Welche Parameter verwenden? const normQty = isB2B ? (dynamicPricing.business_normal_quantity || 200) : (dynamicPricing.private_normal_quantity || 50); const minQty = isB2B ? (dynamicPricing.business_min_quantity || 50) : (dynamicPricing.private_min_quantity || 10); // Keine Formel definiert? Standard-Multiplikator 1 if (!formula || formula.trim() === '') { return 1.0; } try { // Platzhalter ersetzen let evaluableFormula = formula .replace(/%qty%/g, String(quantity)) .replace(/%norm_b%/g, String(dynamicPricing.business_normal_quantity || 200)) .replace(/%mind_b%/g, String(dynamicPricing.business_min_quantity || 50)) .replace(/%norm_p%/g, String(dynamicPricing.private_normal_quantity || 50)) .replace(/%mind_p%/g, String(dynamicPricing.private_min_quantity || 10)); // Sichere Formel-Auswertung ohne eval() const multiplier = safeEvaluateMathFormula(evaluableFormula); // Sicherheitsprüfung if (typeof multiplier !== 'number' || isNaN(multiplier) || !isFinite(multiplier) || multiplier < 0) { console.warn('Ungültiger Multiplikator aus Formel:', multiplier); return 1.0; } return multiplier; } catch (error) { console.error('Fehler beim Auswerten der dynamischen Preisformel:', error); return 1.0; } } /** * Berechnet Preis pro Schriftstück * Bei Direktversand wird der Durchschnittspreis basierend auf Inland/Ausland-Verteilung berechnet */ export function calculatePricePerPiece(state) { const prices = getPrices(); const product = state.ctx?.product; if (!product) return 0; let basePrice = product.basePrice || 0; // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) const needsA4Surcharge = (product.key === "einladungen" || product.key === "follow-ups") && state.answers.format === "a4"; if (needsA4Surcharge) { basePrice += prices.a4_upgrade_surcharge || 0; } // Multiplikator NUR auf Basispreis anwenden (nicht auf Versand/Umschlag) let pricePerPiece = basePrice; if (isFollowups(state) && state.answers.followupYearlyVolume) { // Follow-ups: Spezielle Mengenstaffel const monthlyVolume = parseInt(state.answers.followupYearlyVolume) || 0; const multiplier = getFollowupMultiplier(monthlyVolume); pricePerPiece *= multiplier; } else { // Alle anderen Produkte: Dynamische Preisberechnung const dynamicMultiplier = getDynamicPriceMultiplier(state); pricePerPiece *= dynamicMultiplier; } // Versandkosten bei Direktversand - immer Inlandspreis für "ab"-Preis if (state.answers.shippingMode === "direct") { const shippingPrices = calculateDirectShippingPrices(state); // Immer Inlandspreis verwenden für den "ab"-Preis pricePerPiece += shippingPrices.domesticPrice; } // Umschlag-Kosten (nur bei Bulk-Versand) if ( state.answers.shippingMode === "bulk" && state.answers.envelope === true ) { // Grundpreis für Kuvert pricePerPiece += prices.envelope_base || 0; // Zusätzliche Beschriftungskosten wenn Umschlag beschrieben wird const labelingPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { pricePerPiece += labelingPrice; } // Bei "none" nur Grundpreis } // Motiv-Kosten sind Einmalkosten und werden NICHT auf Preis pro Stück gerechnet // (werden separat in calculateSetupCosts berechnet) // Kaufmännische Rundung auf 2 Dezimalstellen return Math.round(pricePerPiece * 100) / 100; } /** * Berechnet Grundpreis pro Stück für Follow-ups OHNE Multiplikator * (für die Anzeige in der Kopfzeile) */ export function calculateFollowupBasePricePerPiece(state) { const prices = getPrices(); const product = state.ctx?.product; if (!product) return 0; let pricePerPiece = product.basePrice || 0; // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) const needsA4Surcharge = (product.key === "einladungen" || product.key === "follow-ups") && state.answers.format === "a4"; if (needsA4Surcharge) { pricePerPiece += prices.a4_upgrade_surcharge || 0; } // Versandkosten für Follow-ups (immer Direktversand, Inland-Preis als Basis) const shippingPrices = calculateDirectShippingPrices(state); pricePerPiece += shippingPrices.domesticPrice; // KEINE Einmalkosten (API-Anbindung, Motiv-Upload, etc.) - nur wiederkehrende Kosten pro Stück // KEIN Multiplikator hier - das ist der Grundpreis return pricePerPiece; } /** * Berechnet Versandkosten * Rückgabe: { total, shipping0Pct, shipping19Pct, domesticCount, internationalCount, shippingPrices } */ export function calculateShippingCosts(state) { const prices = getPrices(); const quantity = state.answers.quantity || 1; const result = { total: 0, shipping0Pct: 0, // Portoanteil mit 0% MwSt shipping19Pct: 0, // Service + Kuvert + Beschriftung mit 19% MwSt domesticCount: 0, internationalCount: 0, shippingPrices: null, }; if (state.answers.shippingMode === "direct") { // Direktversand: Preis abhängig vom Land // Komponenten: Porto (0% MwSt) + Serviceaufschlag + Kuvert + Beschriftung (alle 19% MwSt) const shippingPrices = calculateDirectShippingPrices(state); result.shippingPrices = shippingPrices; // Empfänger nach Land zählen const { domesticCount, internationalCount } = countRecipientsByCountry(state); result.domesticCount = domesticCount; result.internationalCount = internationalCount; // Porto (0% MwSt) const totalPorto = (domesticCount * shippingPrices.portoDomestic) + (internationalCount * shippingPrices.portoInternational); result.shipping0Pct = totalPorto; // Service + Kuvert + Beschriftung (19% MwSt) - gleich für alle const servicePerPiece = shippingPrices.serviceCharge + shippingPrices.envelopeBase + shippingPrices.labelingCharge; result.shipping19Pct = servicePerPiece * quantity; result.total = result.shipping0Pct + result.shipping19Pct; } else if (state.answers.shippingMode === "bulk") { // Bulkversand: Einmalig, 0% MwSt const shippingBulk = prices.shipping_bulk || 4.95; result.shipping0Pct = shippingBulk; result.total = shippingBulk; } return result; } /** * Berechnet einmalige Einrichtungskosten */ export function calculateSetupCosts(state) { const prices = getPrices(); let setupTotal = 0; // API-Anbindung (nur bei Follow-ups mit auto mode) if (isFollowups(state) && state.answers.followupCreateMode === "auto") { setupTotal += prices.api_connection || 0; } // Motiv Upload - NUR wenn motifNeed = true if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { setupTotal += prices.motif_upload || 0; } // Designservice - NUR wenn motifNeed = true if (state.answers.motifNeed === true && state.answers.motifSource === "design") { setupTotal += prices.motif_design || 0; } // Textservice if (state.answers.contentCreateMode === "textservice") { setupTotal += prices.textservice || 0; } return setupTotal; } /** * Formatiert Preis inkl/zzgl MwSt je nach Kundentyp */ export function formatPrice(netPrice, state, options = {}) { const isB2B = isBusinessCustomer(state); const taxRate = (getPrices().tax_rate || 19) / 100; // Option für 0% MwSt (Versand) const actualTaxRate = options.zeroTax ? 0 : taxRate; const grossPrice = netPrice * (1 + actualTaxRate); if (isB2B) { // Business: Netto + "zzgl. MwSt" return { display: `${formatEUR(netPrice)}`, net: netPrice, gross: grossPrice, isB2B: true, }; } else { // Endkunde: Brutto (inkl. MwSt) return { display: formatEUR(grossPrice), net: netPrice, gross: grossPrice, isB2B: false, }; } } /** * Hilfsfunktion: EUR formatieren */ function formatEUR(amount) { const safe = typeof amount === "number" && !isNaN(amount) ? amount : 0; return safe.toLocaleString("de-DE", { style: "currency", currency: "EUR" }); } /** * Berechnet vollständiges Quote für Nicht-Follow-ups */ export function calculateStandardQuote(state) { const quantity = state.answers.quantity || 1; const pricePerPiece = calculatePricePerPiece(state); const shipping = calculateShippingCosts(state); const setup = calculateSetupCosts(state); const taxRate = (getPrices().tax_rate || 19) / 100; const isB2B = isBusinessCustomer(state); // Erst die Zeilen erstellen (pricePerPiece übergeben um doppelte Berechnung zu vermeiden) const lines = buildQuoteLines(state, { productNet: pricePerPiece * quantity, pricePerPiece, shipping, setup, taxRate, }); // Gesamtpreise berechnen: // - Summe aller totalGross aus den Zeilen (NICHT aus isSubItem!) // - Alles hat 19% MwSt. (auch Porto!) // Gesamtsummen aus den Zeilen berechnen // Nur Zeilen die NICHT isSubItem sind, werden zur Summe addiert // (isSubItem sind nur Aufschlüsselungen innerhalb einer Hauptzeile) let subtotalNet = 0; let totalGross = 0; for (const line of lines) { // Sub-Items nicht zur Summe addieren (sind bereits in Hauptzeile enthalten) if (!line.isSubItem) { subtotalNet += line.totalNet || 0; totalGross += line.totalGross || 0; } } // Runden subtotalNet = Math.round(subtotalNet * 100) / 100; totalGross = Math.round(totalGross * 100) / 100; // MwSt. berechnen (Differenz zwischen Brutto und Netto) const vatAmount = Math.round((totalGross - subtotalNet) * 100) / 100; // Gutschein-Rabatt berechnen const voucher = state.order?.voucherStatus?.valid ? state.order.voucherStatus.voucher : null; let discountAmount = 0; let totalAfterDiscount = totalGross; let vatAfterDiscount = vatAmount; if (voucher) { if (voucher.type === 'percent') { // Prozentual: vom Bruttopreis abziehen discountAmount = Math.round(totalGross * (voucher.value / 100) * 100) / 100; } else { // Festbetrag discountAmount = voucher.value; } // Sicherstellen dass Rabatt nicht größer als Gesamtpreis ist discountAmount = Math.min(discountAmount, totalGross); // Neuer Gesamtpreis totalAfterDiscount = Math.round((totalGross - discountAmount) * 100) / 100; // MwSt. neu berechnen (proportional zum Rabatt) if (totalGross > 0) { vatAfterDiscount = Math.round(vatAmount * (totalAfterDiscount / totalGross) * 100) / 100; } } return { currency: "EUR", quantity, pricePerPiece, productNet: subtotalNet, shipping, setup, subtotalNet, vatRate: taxRate, vatAmount: vatAfterDiscount, totalGross: totalAfterDiscount, discountAmount, totalBeforeDiscount: totalGross, voucher, lines, }; } /** * Erstellt Zeilen für Quote-Tabelle mit detaillierter Aufschlüsselung */ function buildQuoteLines(state, calc) { const lines = []; const quantity = state.answers.quantity || 1; const product = state.ctx?.product; const prices = getPrices(); const taxRate = calc.taxRate || 0.19; // Produktzeile (vollständiger Preis inkl. Versand etc.) // Hilfsfunktion für kaufmännische Rundung auf 2 Dezimalstellen const round2 = (val) => Math.round(val * 100) / 100; if (product) { // pricePerPiece aus calc verwenden (bereits in calculateStandardQuote berechnet) // Falls nicht vorhanden, neu berechnen const pricePerPiece = calc.pricePerPiece !== undefined ? calc.pricePerPiece : calculatePricePerPiece(state); lines.push({ description: product.label, unitNet: pricePerPiece, unitGross: round2(pricePerPiece * (1 + taxRate)), quantity, totalNet: round2(pricePerPiece * quantity), totalGross: round2(pricePerPiece * quantity * (1 + taxRate)), isMainProduct: true, }); // Basispreis-Zeile: Berechne aus pricePerPiece minus Versand/Kuvert-Kosten // (statt nochmal den teuren Multiplier zu berechnen) let basePriceWithMultiplier = pricePerPiece; // Versandkosten abziehen (falls Direktversand) if (state.answers.shippingMode === "direct") { const shippingPrices = calc.shipping?.shippingPrices || calculateDirectShippingPrices(state); basePriceWithMultiplier -= shippingPrices.domesticPrice || 0; } // Kuvert-Kosten abziehen (falls Bulk mit Kuvert) if (state.answers.shippingMode === "bulk" && state.answers.envelope === true) { basePriceWithMultiplier -= prices.envelope_base || 0; if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { basePriceWithMultiplier -= prices.envelope_labeling || prices.envelope_recipient_address || 0; } } basePriceWithMultiplier = round2(basePriceWithMultiplier); lines.push({ description: "Basispreis Schriftstück", unitNet: basePriceWithMultiplier, unitGross: round2(basePriceWithMultiplier * (1 + taxRate)), quantity, totalNet: round2(basePriceWithMultiplier * quantity), totalGross: round2(basePriceWithMultiplier * quantity * (1 + taxRate)), isSubItem: true, }); // A4 Aufpreis (nur für Einladungen und Follow-ups) const needsA4Surcharge = (product.key === "einladungen" || product.key === "follow-ups") && state.answers.format === "a4"; if (needsA4Surcharge) { const a4Price = prices.a4_upgrade_surcharge || 0; lines.push({ description: "A4 Format Aufpreis", unitNet: a4Price, unitGross: a4Price * (1 + taxRate), quantity, totalNet: a4Price * quantity, totalGross: a4Price * quantity * (1 + taxRate), isSubItem: true, }); } // Direktversand - getrennte Zeilen für Inland und Ausland // WICHTIG: Porto hat jetzt auch 19% MwSt. (nicht mehr 0%) if (state.answers.shippingMode === "direct") { const shipping = calc.shipping || {}; const shippingPrices = shipping.shippingPrices || calculateDirectShippingPrices(state); const domesticCount = shipping.domesticCount || 0; const internationalCount = shipping.internationalCount || 0; // Service + Kuvert + Beschriftung + Porto - ALLES mit 19% MwSt const servicePerPiece = shippingPrices.serviceCharge + shippingPrices.envelopeBase + shippingPrices.labelingCharge; // Inland-Zeile (wenn Inland-Empfänger vorhanden) if (domesticCount > 0) { const domesticUnitNet = shippingPrices.portoDomestic + servicePerPiece; const domesticUnitGross = domesticUnitNet * (1 + taxRate); const domesticTotalNet = domesticUnitNet * domesticCount; const domesticTotalGross = domesticUnitGross * domesticCount; lines.push({ description: "Direktversand mit Kuvertierung (Inland)", unitNet: domesticUnitNet, unitGross: domesticUnitGross, quantity: domesticCount, totalNet: domesticTotalNet, totalGross: domesticTotalGross, isSubItem: true, isShippingLine: true, }); } // Ausland-Zeile (wenn Ausland-Empfänger vorhanden) if (internationalCount > 0) { const intlUnitNet = shippingPrices.portoInternational + servicePerPiece; const intlUnitGross = intlUnitNet * (1 + taxRate); const intlTotalNet = intlUnitNet * internationalCount; const intlTotalGross = intlUnitGross * internationalCount; lines.push({ description: "Direktversand mit Kuvertierung (Ausland)", unitNet: intlUnitNet, unitGross: intlUnitGross, quantity: internationalCount, totalNet: intlTotalNet, totalGross: intlTotalGross, isSubItem: true, isShippingLine: true, }); } } // Kuvert bei Bulkversand if ( state.answers.shippingMode === "bulk" && state.answers.envelope === true ) { const envelopeBase = prices.envelope_base || 0; lines.push({ description: "Kuvert", unitNet: envelopeBase, unitGross: envelopeBase * (1 + taxRate), quantity, totalNet: envelopeBase * quantity, totalGross: envelopeBase * quantity * (1 + taxRate), isSubItem: true, }); // Beschriftung (neues Feld: envelope_labeling, Fallback auf alte Felder) if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { const labelPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; const labelDescription = state.answers.envelopeMode === "recipientData" ? "Beschriftung mit Empfängeradresse" : "Beschriftung mit individuellem Text"; lines.push({ description: labelDescription, unitNet: labelPrice, unitGross: labelPrice * (1 + taxRate), quantity, totalNet: labelPrice * quantity, totalGross: labelPrice * quantity * (1 + taxRate), isSubItem: true, }); } } // Bedruckte Karten - NUR wenn motifNeed = true if (state.answers.motifNeed === true && state.answers.motifSource === "printed") { const printedPrice = prices.motif_printed || 0; if (printedPrice > 0) { lines.push({ description: "Bedruckte Karten zusenden", unitNet: printedPrice, unitGross: printedPrice * (1 + taxRate), quantity, totalNet: printedPrice * quantity, totalGross: printedPrice * quantity * (1 + taxRate), isSubItem: true, }); } } } // Sammelversand (einmalig) - jetzt auch mit 19% MwSt if (state.answers.shippingMode === "bulk") { const bulkPrice = prices.shipping_bulk || 0; lines.push({ description: "Sammelversand (einmalig)", unitNet: bulkPrice, unitGross: bulkPrice * (1 + taxRate), quantity: 1, totalNet: bulkPrice, totalGross: bulkPrice * (1 + taxRate), }); } // Einmalige Setup-Kosten const setupCosts = []; // Motiv Upload - NUR wenn motifNeed = true if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { const uploadPrice = prices.motif_upload || 0; if (uploadPrice > 0) { setupCosts.push({ description: "Motiv Upload (einmalig)", unitNet: uploadPrice, unitGross: uploadPrice * (1 + taxRate), quantity: 1, totalNet: uploadPrice, totalGross: uploadPrice * (1 + taxRate), }); } } // Designservice - NUR wenn motifNeed = true if (state.answers.motifNeed === true && state.answers.motifSource === "design") { const designPrice = prices.motif_design || 0; if (designPrice > 0) { setupCosts.push({ description: "Designservice (einmalig)", unitNet: designPrice, unitGross: designPrice * (1 + taxRate), quantity: 1, totalNet: designPrice, totalGross: designPrice * (1 + taxRate), }); } } // Textservice if (state.answers.contentCreateMode === "textservice") { const textPrice = prices.textservice || 0; if (textPrice > 0) { setupCosts.push({ description: "Textservice (einmalig)", unitNet: textPrice, unitGross: textPrice * (1 + taxRate), quantity: 1, totalNet: textPrice, totalGross: textPrice * (1 + taxRate), }); } } // API-Anbindung (nur bei Follow-ups) if (isFollowups(state) && state.answers.followupCreateMode === "auto") { const apiPrice = prices.api_connection || 0; if (apiPrice > 0) { setupCosts.push({ description: "API-Anbindung (einmalig)", unitNet: apiPrice, unitGross: apiPrice * (1 + taxRate), quantity: 1, totalNet: apiPrice, totalGross: apiPrice * (1 + taxRate), }); } } return [...lines, ...setupCosts]; } /** * Berechnet Follow-up Preisstaffelung (für Tabelle) */ export function calculateFollowupPricing(state) { if (!isFollowups(state)) return null; const pricePerPiece = calculatePricePerPiece(state); const taxRate = (getPrices().tax_rate || 19) / 100; const isB2B = isBusinessCustomer(state); // Preis-Stufen basierend auf monatlichem Volumen const tiers = [ { min: 5, max: 49, label: "5-49 Stück/Monat" }, { min: 50, max: 199, label: "50-199 Stück/Monat" }, { min: 200, max: 499, label: "200-499 Stück/Monat" }, { min: 500, max: 999, label: "500-999 Stück/Monat" }, { min: 1000, max: null, label: "1000+ Stück/Monat" }, ]; const pricing = tiers.map((tier) => { const avgVolume = tier.max ? (tier.min + tier.max) / 2 : 1000; const multiplier = getFollowupMultiplier(avgVolume); const piecePrice = pricePerPiece / multiplier || 0; // Basispreis vor Multiplikator const monthlyNet = piecePrice * avgVolume * multiplier; const monthlyGross = monthlyNet * (1 + taxRate); return { label: tier.label, multiplier, pricePerPiece: piecePrice * multiplier, monthlyNet, monthlyGross, displayNet: formatEUR(monthlyNet), displayGross: formatEUR(monthlyGross), }; }); return { tiers: pricing, setup: calculateSetupCosts(state), shippingNote: null, // Bei Follow-ups keine Versandkosten-Meldung isB2B, }; } export { formatEUR };