From 16f08b5ac3a111eed061d947eb2b4b428e6a02a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:03:25 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge:=20Konstanten=20und=20Fehlerklass?= =?UTF-8?q?e=20(L0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/auftraege/fehler.js | 31 +++++++++ server/services/auftraege/konstanten.js | 27 ++++++++ test/auftraegeKonstanten.test.js | 85 +++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 server/services/auftraege/fehler.js create mode 100644 server/services/auftraege/konstanten.js create mode 100644 test/auftraegeKonstanten.test.js diff --git a/server/services/auftraege/fehler.js b/server/services/auftraege/fehler.js new file mode 100644 index 0000000..da772be --- /dev/null +++ b/server/services/auftraege/fehler.js @@ -0,0 +1,31 @@ +/** + * Fehlerklasse der Aufträge-Module und einheitliche Fehlerantwort der Routen. + * AuftragError nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text); Programmfehler + * (ungültige Kennungen oder Typen) sind TypeError und enden als 500. + */ +import { InvoiceError } from '../finance/stripeFinance.js' + +export class AuftragError extends Error { + constructor(status, message) { + if (!Number.isInteger(status) || status < 400 || status > 599) { + throw new TypeError('AuftragError: Status muss eine ganze Zahl von 400 bis 599 sein.') + } + super(message) + this.name = 'AuftragError' + this.status = status + } +} + +/** + * Antwortet auf einen Fehler: AuftragError und InvoiceError mit Status und Meldung, alles andere (auch Fehler mit + * Status von Appwrite oder Stripe) als 500 „Interner Fehler.“. Das Protokoll nennt nie die Meldung, nur die Art. + * InvoiceError wird nur in der Funktion benutzt, nie beim Laden: stripeFinance.js importiert ab L2 selbst Module + * dieses Verzeichnisses, so bleibt der Importzyklus harmlos. + */ +export function sendeFehler(res, was, err) { + if (err instanceof AuftragError || err instanceof InvoiceError) { + return res.status(err.status).json({ error: err.message }) + } + console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') + return res.status(500).json({ error: 'Interner Fehler.' }) +} diff --git a/server/services/auftraege/konstanten.js b/server/services/auftraege/konstanten.js new file mode 100644 index 0000000..dd434ea --- /dev/null +++ b/server/services/auftraege/konstanten.js @@ -0,0 +1,27 @@ +/** + * Aufträge und Rechnungen: gemeinsame Konstanten (Übersicht V2). + * Gemeinsame Datei: nur L0 ändert sie (Ausnahme: die eine Zeile AGB_FASSUNG, die L7 anpassen darf). + */ +export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'] +export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter +export const AUFTRAG_ARTEN = ['festpreis', 'aufwand'] +export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'] +export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“ +export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' } +export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'] +export const KUNDENARTEN = ['unternehmen', 'privat'] +export const STUNDENSATZ_STANDARD_CENTS = 10000 +export const VIERTELSTUNDE = 15 +export const TOKEN_GUELTIG_TAGE = 60 +export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen +export const LIMITS = { + maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, + standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, +} +// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel +export const SPERRE_NUMMER = ['auftraege-nummer'] +export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde +export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5) +export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' diff --git a/test/auftraegeKonstanten.test.js b/test/auftraegeKonstanten.test.js new file mode 100644 index 0000000..26e6051 --- /dev/null +++ b/test/auftraegeKonstanten.test.js @@ -0,0 +1,85 @@ +// test/auftraegeKonstanten.test.js +import { test, mock } from 'node:test' +import assert from 'node:assert/strict' +import * as K from '../server/services/auftraege/konstanten.js' +import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js' +import { InvoiceError } from '../server/services/finance/stripeFinance.js' + +test('Konstanten stehen wie in Übersicht V2', () => { + assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']) + assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss']) + assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand']) + assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) + assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet']) + assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }) + assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']) + assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat']) + assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000) + assert.equal(K.VIERTELSTUNDE, 15) + assert.equal(K.TOKEN_GUELTIG_TAGE, 60) + assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest + assert.deepEqual(K.LIMITS, { + maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, + standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, + }) + // withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht) + assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer']) + assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1']) + assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1']) + assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.') + for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status) + for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art) +}) + +const fakeRes = () => ({ + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code + return this + }, + json(body) { + this.body = body + return this + }, +}) + +test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => { + const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + assert.ok(err instanceof Error) + assert.equal(err.name, 'AuftragError') + assert.equal(err.status, 409) + assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + for (const status of [200, 399, 600, 404.5, '404', undefined, null]) { + assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status)) + } +}) + +test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => { + const a = fakeRes() + sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.')) + assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }]) + const b = fakeRes() + sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.')) + assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }]) +}) + +test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => { + const log = mock.method(console, 'error', () => {}) + try { + const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' }) + for (const err of [appwrite, 'sk_live_geheim', undefined, null]) { + const res = fakeRes() + sendeFehler(res, 'Projektkonto laden', err) + assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }]) + } + assert.equal(log.mock.calls.length, 4) + const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments)) + assert.ok(!zeilen.includes('geheim'), zeilen) + assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen) + } finally { + log.mock.restore() + } +})