Files
skrift-directus/extensions/directus-extension-skrift-sevdesk/dist/index.js
s4luorth f4ce602b7a Motiv-Entwurf eingepasst (meet, in Trim-Fläche) + sevDesk-Diagnose-Logs
- Motiv-A6-Layout: Motiv vollständig in 148×105 eingepasst (meet, mittig) auf
  weißem A6-Grund statt randabfallend beschnitten (slice).
- skrift-sevdesk: Lade-Log (Token gesetzt/fehlt) + Logs je Auftrag (source-Gate,
  bereits erstellt) zur Diagnose, warum ggf. nichts erzeugt wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:48:09 +02:00

222 lines
10 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) { logger.info(`[skrift-sevdesk] ${order.order_number}: bereits erstellt (#${order.sevdesk_invoice_id}).`); return; }
if (!SOURCES.includes(String(order.source || ''))) {
logger.info(`[skrift-sevdesk] ${order.order_number}: source="${order.source}" nicht in [${SOURCES.join(',')}] → übersprungen.`);
return;
}
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.`);
}
}
// Beim Laden einmal protokollieren, damit sichtbar ist, ob die Extension aktiv
// ist und ob der Token gesetzt wurde.
logger.info(`[skrift-sevdesk] geladen – Token ${TOKEN ? 'gesetzt' : 'FEHLT'}, sources=[${SOURCES.join(',')}], base=${BASE}`);
register.action('orders.items.create', async ({ key, keys }) => {
const ids = keys || (key != null ? [key] : []);
if (!TOKEN) { logger.info(`[skrift-sevdesk] Auftrag ${ids.join(',')} – kein SEVDESK_API_TOKEN gesetzt, übersprungen.`); return; }
logger.info(`[skrift-sevdesk] orders.create → prüfe Auftrag/Aufträge ${ids.join(',')}`);
for (const id of ids) {
try { await rechnungFuerAuftrag(id); }
catch (e) { logger.error(`[skrift-sevdesk] Rechnung für Auftrag ${id} fehlgeschlagen: ${e.message}`); }
}
});
};