From 0f73a653b3820bea1f151a4e098940ce7f4df6fd Mon Sep 17 00:00:00 2001 From: s4luorth Date: Fri, 18 Sep 2026 07:44:28 +0200 Subject: [PATCH] sevDesk: automatische Rechnung (Entwurf) bei Konfigurator-Bestellung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Hook-Extension skrift-sevdesk: legt bei orders.items.create (source= configurator) eine sevDesk-Rechnung als ENTWURF an – Kontakt wird angelegt/ wiederverwendet (sevdesk_contact_id am Konto), Positionen aus price_lines (Produkt mit Menge; Versand/Motiv/Zusatz je eigene Position), 19% USt. Nicht-blockierend, per SEVDESK_API_TOKEN aktiviert; Versand NICHT über sevDesk. - orders.price_lines (JSON) speichert die Positionsaufstellung bei Bestellung (Konfigurator + manueller Auftrag). - bootstrap: orders.price_lines/sevdesk_invoice_id/sevdesk_invoice_number, directus_users.sevdesk_contact_id. Co-Authored-By: Claude Opus 4.8 --- bootstrap/bootstrap.mjs | 6 + .../dist/index.js | 2 + .../dist/index.js | 213 ++++++++++++++++++ .../package.json | 13 ++ 4 files changed, 234 insertions(+) create mode 100644 extensions/directus-extension-skrift-sevdesk/dist/index.js create mode 100644 extensions/directus-extension-skrift-sevdesk/package.json diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index e4ad860..c5a907a 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -137,6 +137,7 @@ const USER_FIELDS = [ // Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten. f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }), f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }), + f('sevdesk_contact_id', 'string', { interface: 'input', hidden: true, note: 'Verknüpfter sevDesk-Kontakt (für Rechnungen).' }), // Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code // anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails. f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }), @@ -312,6 +313,11 @@ const collections = [ f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }), f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }), m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }), + // Rechnungs-Positionsaufstellung (aus der Preisberechnung) – Basis für die sevDesk-Rechnung. + f('price_lines', 'json', { interface: 'input-code', note: 'Positionsaufstellung [{label, amount, per_unit}] zum Auftragszeitpunkt.' }), + // sevDesk-Rechnung (automatisch erzeugt). + f('sevdesk_invoice_id', 'string', { interface: 'input', note: 'sevDesk-Rechnungs-ID (Entwurf/gebucht).' }), + f('sevdesk_invoice_number', 'string', { interface: 'input', note: 'sevDesk-Rechnungsnummer.' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, { field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} }, // Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 1e71444..7f119c0 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -1807,6 +1807,7 @@ const handler = (router, { services, getSchema, logger }) => { entries_count: entries.length, count_inland: entries.length - countAusland, count_ausland: countAusland, net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null, + price_lines: quote?.lines ?? null, }; let orderId, orderNumber; @@ -1990,6 +1991,7 @@ const handler = (router, { services, getSchema, logger }) => { net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, + price_lines: quote.lines ?? null, voucher: quote.voucher?.id ?? null, payment_method: paymentMethod, payment_status: body.source === 'extern' ? 'bezahlt' : 'offen', diff --git a/extensions/directus-extension-skrift-sevdesk/dist/index.js b/extensions/directus-extension-skrift-sevdesk/dist/index.js new file mode 100644 index 0000000..8757c97 --- /dev/null +++ b/extensions/directus-extension-skrift-sevdesk/dist/index.js @@ -0,0 +1,213 @@ +/** + * Skrift – sevDesk-Rechnungen (Hook) + * --------------------------------------------------------------------------- + * Legt bei jeder Konfigurator-Bestellung automatisch eine sevDesk-RECHNUNG als + * ENTWURF an (Status 100) – inkl. Kontakt (wird angelegt/wiederverwendet). + * Positionen kommen aus der gespeicherten Preisaufstellung (orders.price_lines): + * • Produkt = 1 Position mit Menge (z. B. 50 Stk.) + * • Versand/Sammelversand, Motiv-Upload, jede Zusatzleistung = eigene Position + * 19 % USt (taxType „default"). Der VERSAND der Rechnung erfolgt NICHT über + * sevDesk (kommt separat über den eigenen Mailer, wird später nachgerüstet). + * + * Auslöser: orders.items.create mit source = 'configurator' und vorhandener + * Preisaufstellung. Läuft NIE blockierend – Fehler werden nur geloggt, die + * Bestellung wird davon nicht beeinträchtigt. + * + * Env: + * SEVDESK_API_TOKEN Pflicht – ohne Token passiert nichts. + * SEVDESK_BASE Default https://my.sevdesk.de/api/v1 + * SEVDESK_TAX_RATE Default 19 + * SEVDESK_INVOICE_SOURCES Komma-Liste der order.source, für die gebucht wird + * (Default „configurator"). + * + * Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-contacts. + */ + +const TOKEN = (process.env.SEVDESK_API_TOKEN || '').trim(); +const BASE = (process.env.SEVDESK_BASE || 'https://my.sevdesk.de/api/v1').replace(/\/$/, ''); +const TAX_RATE = Number(process.env.SEVDESK_TAX_RATE || 19); +const SOURCES = (process.env.SEVDESK_INVOICE_SOURCES || 'configurator') + .split(',').map((s) => s.trim()).filter(Boolean); + +const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100; + +export default (register, { services, getSchema, logger }) => { + const { ItemsService } = services; + const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null }); + + // ── sevDesk-API-Helfer ──────────────────────────────────────────────────── + async function sev(method, path, body) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { Authorization: TOKEN, 'Content-Type': 'application/json', Accept: 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch { /* kein JSON */ } + if (!res.ok) { + const detail = (json && (json.message || JSON.stringify(json.error || json))) || text || ''; + throw new Error(`sevDesk ${method} ${path} → ${res.status}: ${String(detail).slice(0, 400)}`); + } + return json; + } + + // Der „contactPerson" einer Rechnung ist ein sevUser des Kontos. Einmal holen. + let sevUserId = null; + async function getSevUserId() { + if (sevUserId) return sevUserId; + const r = await sev('GET', '/SevUser?limit=1'); + sevUserId = r?.objects?.[0]?.id || null; + if (!sevUserId) throw new Error('Kein sevUser gefunden (contactPerson).'); + return sevUserId; + } + + // Kontakt anlegen (Person oder Organisation) + E-Mail. Gibt die Kontakt-ID zurück. + async function createContact({ company, first, last, email, phone }) { + const catStr = (process.env.SEVDESK_CONTACT_CATEGORY || '3').trim(); // 3 = Kunde (Standard) + const isOrg = !!(company && company.trim()); + const body = { + objectName: 'Contact', + category: { id: Number(catStr) || 3, objectName: 'Category' }, + }; + if (isOrg) body.name = company.trim(); + else { body.surename = (first || '').trim(); body.familyname = (last || '').trim(); } + const c = await sev('POST', '/Contact', body); + const id = c?.objects?.id || c?.objects?.[0]?.id; + if (!id) throw new Error('Kontakt konnte nicht angelegt werden (keine ID).'); + // E-Mail hinterlegen (best effort). + if (email) { + try { + await sev('POST', '/CommunicationWay', { + objectName: 'CommunicationWay', contact: { id, objectName: 'Contact' }, + type: 'EMAIL', value: email, key: { id: 1, objectName: 'CommunicationWayKey' }, main: 1, + }); + } catch (e) { logger.warn(`[skrift-sevdesk] E-Mail am Kontakt fehlgeschlagen: ${e.message}`); } + } + if (phone) { + try { + await sev('POST', '/CommunicationWay', { + objectName: 'CommunicationWay', contact: { id, objectName: 'Contact' }, + type: 'PHONE', value: phone, key: { id: 1, objectName: 'CommunicationWayKey' }, main: 1, + }); + } catch { /* egal */ } + } + return String(id); + } + + // Positionen aus der Preisaufstellung. Produkt (erste Zeile) mit Menge, Rest je 1. + function buildPositions(order) { + const lines = Array.isArray(order.price_lines) ? order.price_lines : []; + const menge = Number(order.entries_count) || 1; + const suffix = order.product_name_suffix ? ` ${order.product_name_suffix}` : ''; + return lines.map((l, i) => { + const label = String(l.label || 'Position'); + if (i === 0 && menge > 1) { + // Produkt-Grundpreis → eine Position mit Menge; „(N Stk.)" aus dem Namen nehmen. + const name = label.replace(/\s*\(\s*\d+\s*Stk\.?\s*\)\s*$/i, '') + suffix; + return { objectName: 'InvoicePos', mapAll: 'true', name, quantity: menge, + price: round2(Number(l.amount) / menge), taxRate: TAX_RATE, + unity: { id: 1, objectName: 'Unity' } }; + } + return { objectName: 'InvoicePos', mapAll: 'true', name: label, quantity: 1, + price: round2(l.amount), taxRate: TAX_RATE, unity: { id: 1, objectName: 'Unity' } }; + }); + } + + function adressBlock(order) { + const b = order.billing && typeof order.billing === 'object' ? order.billing : {}; + const person = [order.first_name, order.last_name].filter(Boolean).join(' '); + const name = person || order.company || order.customer_email || ''; + const text = [ + order.company || null, + person || (order.company ? null : name), + b.street || null, + [b.zip, b.city].filter(Boolean).join(' ') || null, + b.country || null, + ].filter(Boolean).join('\n'); + return { name, text }; + } + + // ── Kernablauf je Auftrag ─────────────────────────────────────────────────── + async function rechnungFuerAuftrag(orderId) { + const schema = await getSchema(); + const order = await svc('orders', schema).readOne(orderId, { + fields: ['id', 'order_number', 'source', 'price_lines', 'entries_count', 'net_total', + 'gross_total', 'product_name_suffix', 'customer_email', 'billing', 'company', + 'sevdesk_invoice_id', 'customer.id', 'customer.first_name', 'customer.last_name', + 'customer.company', 'customer.phone', 'customer.sevdesk_contact_id'], + }); + if (!order) return; + if (order.sevdesk_invoice_id) return; // schon erstellt + if (!SOURCES.includes(String(order.source || ''))) return; // nur Konfigurator + const lines = Array.isArray(order.price_lines) ? order.price_lines : []; + if (!lines.length || !(Number(order.net_total) > 0)) { + logger.info(`[skrift-sevdesk] ${order.order_number}: keine Positionen/Betrag → keine Rechnung.`); + return; + } + + const cust = order.customer || {}; + const first = cust.first_name || ''; + const last = cust.last_name || ''; + const company = cust.company || order.company || ''; + + // Kontakt: am Konto vorhandene sevDesk-ID wiederverwenden, sonst anlegen + merken. + let contactId = cust.sevdesk_contact_id || null; + if (!contactId) { + contactId = await createContact({ company, first, last, email: order.customer_email, phone: cust.phone }); + if (cust.id) { + try { await svc('directus_users', schema).updateOne(cust.id, { sevdesk_contact_id: contactId }); } + catch (e) { logger.warn(`[skrift-sevdesk] sevDesk-Kontakt-ID nicht am Konto gespeichert: ${e.message}`); } + } + } + + const contactPersonId = await getSevUserId(); + const adr = adressBlock({ ...order, first_name: first, last_name: last, company }); + const heute = new Date().toISOString().slice(0, 10); + + const payload = { + invoice: { + objectName: 'Invoice', + invoiceType: 'RE', + status: '100', // Entwurf + invoiceDate: heute, + header: `Rechnung ${order.order_number}`, + contact: { id: Number(contactId), objectName: 'Contact' }, + contactPerson: { id: Number(contactPersonId), objectName: 'SevUser' }, + address: adr.text, + addressName: adr.name, + currency: 'EUR', + taxRate: TAX_RATE, + taxText: `Umsatzsteuer ${TAX_RATE}%`, + taxType: 'default', + discount: 0, + mapAll: 'true', + }, + invoicePosSave: buildPositions(order), + invoicePosDelete: null, + }; + + const res = await sev('POST', '/Invoice/Factory/saveInvoice', payload); + const inv = res?.objects?.invoice || res?.objects; + const invId = inv?.id; + const invNo = inv?.invoiceNumber || null; + if (invId) { + await svc('orders', schema).updateOne(orderId, { + sevdesk_invoice_id: String(invId), + sevdesk_invoice_number: invNo ? String(invNo) : null, + }); + logger.info(`[skrift-sevdesk] ${order.order_number}: Rechnungs-Entwurf angelegt (sevDesk #${invId}${invNo ? ` / ${invNo}` : ''}).`); + } else { + logger.warn(`[skrift-sevdesk] ${order.order_number}: Rechnung ohne ID zurückgekommen.`); + } + } + + register.action('orders.items.create', async ({ key, keys }) => { + if (!TOKEN) return; // ohne Token nichts tun + const ids = keys || (key != null ? [key] : []); + for (const id of ids) { + try { await rechnungFuerAuftrag(id); } + catch (e) { logger.error(`[skrift-sevdesk] Rechnung für Auftrag ${id} fehlgeschlagen: ${e.message}`); } + } + }); +}; diff --git a/extensions/directus-extension-skrift-sevdesk/package.json b/extensions/directus-extension-skrift-sevdesk/package.json new file mode 100644 index 0000000..c280eab --- /dev/null +++ b/extensions/directus-extension-skrift-sevdesk/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-sevdesk", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Erstellt bei Konfigurator-Bestellungen automatisch eine sevDesk-Rechnung (Entwurf) inkl. Kontakt. Positionen aus der Preisaufstellung. Nicht-blockierend, per SEVDESK_API_TOKEN aktiviert.", + "directus:extension": { + "type": "hook", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +}