/** * Skrift – Bestell-Endpunkt * --------------------------------------------------------------------------- * POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt) * POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben) * * Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer * serverseitig aus products/price_items/pricing_settings neu berechnet. * * Preislogik: * - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet * von einem sicheren Mini-Interpreter (kein eval). * - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im * Code, Werte aus price_items. * - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung. */ // ── Sicherer Formel-Interpreter ───────────────────────────────────────────── // Erlaubt: Zahlen, Variablen, + - * / ( ) und unäres Minus. Sonst nichts. function evalFormula(expr, vars) { const tokens = String(expr).match(/\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]/g); if (!tokens) throw new Error('Formel ist leer oder ungültig.'); let pos = 0; const peek = () => tokens[pos]; const next = () => tokens[pos++]; // Ausdruck := Term (('+'|'-') Term)* function parseExpr() { let left = parseTerm(); while (peek() === '+' || peek() === '-') { const op = next(); const right = parseTerm(); left = op === '+' ? left + right : left - right; } return left; } // Term := Faktor (('*'|'/') Faktor)* function parseTerm() { let left = parseFactor(); while (peek() === '*' || peek() === '/') { const op = next(); const right = parseFactor(); if (op === '/' && right === 0) throw new Error('Division durch 0 in der Preisformel.'); left = op === '*' ? left * right : left / right; } return left; } // Faktor := '-' Faktor | '(' Ausdruck ')' | Zahl | Variable function parseFactor() { const t = next(); if (t === undefined) throw new Error('Formel unvollständig.'); if (t === '-') return -parseFactor(); if (t === '(') { const v = parseExpr(); if (next() !== ')') throw new Error('Fehlende schließende Klammer.'); return v; } if (/^\d/.test(t)) return parseFloat(t); if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0; throw new Error(`Unbekannte Variable in der Preisformel: "${t}"`); } const result = parseExpr(); if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.'); if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Betrag.'); return result; } const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100; const handler = (router, { services, getSchema, logger }) => { const { ItemsService } = services; const norm = (e) => String(e || '').trim().toLowerCase(); const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); /** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */ function makeSanitizer(whitelist) { let re; try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); } catch { re = null; } return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v); } async function loadPricingContext(schema) { const svc = (c) => new ItemsService(c, { schema, accountability: null }); const [settingsRaw, priceItems] = await Promise.all([ svc('pricing_settings').readSingleton({}), svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1, fields: ['key', 'price', 'unit', 'category'] }), ]); const settings = settingsRaw || {}; const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p])); return { settings, priceItems: byKey }; } /** Berechnet den Preis vollständig serverseitig. */ async function calculatePrice(schema, input) { const { settings, priceItems } = await loadPricingContext(schema); const svc = (c) => new ItemsService(c, { schema, accountability: null }); const product = (await svc('products').readByQuery({ filter: { key: { _eq: input.product } }, limit: 1, fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'active'], }))?.[0]; if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.'); if (product.pricing_mode === 'auf_anfrage') throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`); const menge = Math.max(0, parseInt(input.quantity, 10) || 0); if (menge < 1) throw new Error('Menge muss mindestens 1 sein.'); const lines = []; const add = (label, amount) => { if (amount) lines.push({ label, amount: round2(amount) }); }; const priceOf = (key) => Number(priceItems[key]?.price) || 0; const unitOf = (key) => priceItems[key]?.unit || 'einmalig'; const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key)); // 1) Grundpreis über die editierbare Formel const formula = settings.formula || 'base_price * menge'; const grund = evalFormula(formula, { base_price: Number(product.base_price) || 0, menge }); add(`${product.name} (${menge} Stk.)`, grund); // 2) Versand – Kuvert ist bei Einzelversand Pflicht if (input.shipping_type === 'einzeln') { const auslandCount = (input.entries || []) .filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length; const inlandCount = menge - auslandCount; add('Porto Inland', priceOf('porto_inland') * inlandCount); add('Porto Ausland', priceOf('porto_ausland') * auslandCount); } else { add('Sammelversand', priceOf('sammelversand')); } // 3) Kuvert + Beschriftung if (input.needs_envelope) { add('Kuvert', applyUnit('kuvert')); if (input.envelope_labeling && input.envelope_labeling !== 'keine') add('Kuvert-Beschriftung', applyUnit('beschriftung')); } // 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.) if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload')); for (const key of input.addons || []) { const item = priceItems[key]; if (!item || item.category !== 'zusatzleistung') continue; add(item.key, applyUnit(key)); } let net = lines.reduce((s, l) => s + l.amount, 0); // 5) Gutschein let voucher = null; if (input.voucher_code) { const v = (await svc('vouchers').readByQuery({ filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } }, limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'], }))?.[0]; const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now(); const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses; if (v && !expired && !exhausted) { const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value); const capped = Math.min(discount, net); add(`Gutschein ${v.code}`, -capped); net -= capped; voucher = v; } } net = round2(Math.max(0, net)); const mwst = Number(settings.mwst_percent ?? 19); const vat = round2(net * (mwst / 100)); const gross = round2(net + vat); // 6) Zahlungsart: ab Limit nur noch Rechnung const limit = Number(settings.paypal_limit_net ?? 200); const paypalAllowed = net < limit; return { product, lines, net_total: net, vat_amount: vat, gross_total: gross, mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher }; } function validate(body) { const errors = []; if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.'); if (!body?.product) errors.push('Produkt fehlt.'); const entries = Array.isArray(body?.entries) ? body.entries : []; if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.'); // Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller) if (body?.shipping_type === 'einzeln' && !body?.needs_envelope) errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.'); return errors; } // ── Nur Preis berechnen (Live-Anzeige) ─────────────────────────────────── router.post('/quote', async (req, res) => { try { const schema = await getSchema(); const body = req.body || {}; const quote = await calculatePrice(schema, { ...body, quantity: body.quantity ?? (body.entries || []).length, }); return res.json({ lines: quote.lines, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, mwst_percent: quote.mwst_percent, paypal_allowed: quote.paypal_allowed, }); } catch (err) { return res.status(400).json({ error: err.message }); } }); // ── Auftrag anlegen ────────────────────────────────────────────────────── router.post('/', async (req, res) => { const body = req.body || {}; const errors = validate(body); if (errors.length) return res.status(400).json({ errors }); try { const schema = await getSchema(); const svc = (c) => new ItemsService(c, { schema, accountability: null }); const email = norm(body.email); const entries = body.entries; const { settings } = await loadPricingContext(schema); const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); const quote = await calculatePrice(schema, { ...body, quantity: entries.length }); // Zahlungsart erzwingen, wenn über dem PayPal-Limit let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung'; if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung'; // Kunde finden oder anlegen – Zuordnung immer per E-Mail const customers = svc('customers'); let customer = (await customers.readByQuery({ filter: { email: { _eq: email } }, limit: 1, fields: ['id'], }))?.[0]; if (!customer) { const id = await customers.createOne({ email, person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat', first_name: clean(body.first_name) ?? null, last_name: clean(body.last_name) ?? null, company: clean(body.company) ?? null, phone: clean(body.phone) ?? null, }); customer = { id }; } // Format auflösen (optional) let formatId = null; if (body.format) { formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'], }))?.[0]?.id ?? null; } // Umschlagformat automatisch: A4 → DIN Lang, A6 → C6 const envelopeFormat = body.needs_envelope ? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6') : null; const orderNumber = `SK-${new Date().toISOString().slice(0, 10)}-${ String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; const orderId = await svc('orders').createOne({ order_number: orderNumber, customer: customer.id, customer_email: email, product: quote.product.id, format: formatId, person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat', font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda', source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'), status: 'wartet_auf_zahlung', shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel', needs_envelope: !!body.needs_envelope, envelope_labeling: body.envelope_labeling ?? null, envelope_format: envelopeFormat, motif_mode: body.motif_mode ?? null, own_cards: body.motif_mode === 'eigene_karten', text_template: clean(body.text_template) ?? null, text_briefing: clean(body.text_briefing) ?? null, entries_count: entries.length, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, voucher: quote.voucher?.id ?? null, payment_method: paymentMethod, payment_status: 'offen', }); // Empfängerzeilen – Briefnummer wird IMMER vom System vergeben const entriesSvc = svc('order_entries'); let n = 0; for (const e of entries) { n += 1; await entriesSvc.createOne({ order: orderId, letter_number: n, salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null, street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null, city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null, placeholders: e.placeholders && typeof e.placeholders === 'object' ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null, }); } // Zusatzleistungen verknüpfen if (Array.isArray(body.addons) && body.addons.length) { const items = await svc('price_items').readByQuery({ filter: { key: { _in: body.addons }, category: { _eq: 'zusatzleistung' } }, limit: -1, fields: ['id'], }); for (const it of items || []) await svc('order_addons').createOne({ order: orderId, price_item: it.id }); } // Gutschein-Einlösung protokollieren if (quote.voucher) { await svc('voucher_redemptions').createOne({ voucher: quote.voucher.id, order: orderId }); await svc('vouchers').updateOne(quote.voucher.id, { used_count: (quote.voucher.used_count ?? 0) + 1 }); } await svc('status_history').createOne({ order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.', }); return res.json({ order_number: orderNumber, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, payment_method: paymentMethod, paypal_allowed: quote.paypal_allowed, }); } catch (err) { logger.error(`[skrift-orders] ${err.stack || err.message}`); return res.status(400).json({ error: err.message }); } }); }; export default { id: 'skrift-orders', handler };