Files
Skrift-Kofnigurator/skrift-configurator/assets/js/configurator-pricing.js
2026-02-07 13:04:04 +01:00

1036 lines
32 KiB
JavaScript

/**
* 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 };