Files
skrift-directus/extensions/directus-extension-skrift-sevdesk/dist/index.js
s4luorth 0f73a653b3 sevDesk: automatische Rechnung (Entwurf) bei Konfigurator-Bestellung
- 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 <noreply@anthropic.com>
2026-09-18 07:44:28 +02:00

214 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`); }
}
});
};