diff --git a/server/services/auftraege/projektRechnungenStore.js b/server/services/auftraege/projektRechnungenStore.js new file mode 100644 index 0000000..2c72369 --- /dev/null +++ b/server/services/auftraege/projektRechnungenStore.js @@ -0,0 +1,149 @@ +/** + * Speicher der Projektrechnungen (Collection projektRechnungen, Übersicht V1/V3/V4). Je Stripe-Rechnung, die zu einem + * Projekt, einer AB oder einer Aufwandsabrechnung gehört, ein Dokument; Dokument-ID = Stripe-Rechnungs-ID. Aktiv + * heißt: die Rechnung zählt (ein Storno oder gelöschter Entwurf setzt aktiv auf false und gibt die Arbeitsblätter + * frei). Programmfehler (ungültige Kennungen, Typen, Filter) sind TypeError, AuftragError nur für 404/409/400. + */ +import { config } from '../../config.js' +import { createDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { AuftragError } from './fehler.js' +import { AUFTRAEGE_COLLECTIONS } from './schema.js' + +// Wie STRIPE_INVOICE_ID im Bestand (/^in_\w+$/), höchstens 36 Zeichen (Grenze der Appwrite-Dokument-ID); der +// Stripe-Nachbau vergibt in_test_ +const RECHNUNGS_ID = /^in_\w{1,33}$/ +const ARTEN = ['festbetrag', 'aufwand', 'anzahlung', 'schluss'] // frei hat nie ein Dokument +const JSON_GROESSE = AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.find((a) => a.key === 'arbeitsblattIdsJson').size + +const istText = (wert) => typeof wert === 'string' +const istNichtLeer = (wert) => istText(wert) && wert !== '' + +function docToRechnung(doc) { + let ids = [] + try { + const roh = JSON.parse(doc.arbeitsblattIdsJson) + if (Array.isArray(roh)) ids = roh.filter(istNichtLeer) + } catch { + // kaputtes oder leeres JSON: keine Arbeitsblätter + } + const text = (wert) => (istText(wert) ? wert : '') + const zeit = new Date(doc.erstelltAm || doc.$createdAt) + return { + invoiceId: doc.$id, + kundenId: text(doc.kundenId), + projektId: text(doc.projektId), + auftragId: text(doc.auftragId), + art: text(doc.art), + betragCents: Number.isSafeInteger(doc.betragCents) ? doc.betragCents : 0, + arbeitsblattIds: ids, + rechnungsnummer: text(doc.rechnungsnummer), + aktiv: doc.aktiv === true, + erstelltVon: text(doc.erstelltVon), + erstelltAm: Number.isNaN(zeit.getTime()) ? null : zeit.toISOString(), + } +} + +/** Prüft die genannten Felder (jede Verletzung ist ein TypeError) und liefert das Dokument dazu. */ +function feldDoc(daten, { vollstaendig }) { + const doc = {} + const hat = (key) => Object.hasOwn(daten, key) && daten[key] !== undefined + const text = (key, { pflicht = false } = {}) => { + if (!hat(key)) return + if (pflicht ? !istNichtLeer(daten[key]) : !istText(daten[key])) throw new TypeError(`Projektrechnung.${key} ist ungültig.`) + doc[key] = daten[key] + } + text('kundenId', { pflicht: true }) + text('projektId') + text('auftragId') + text('rechnungsnummer') + text('erstelltVon') + if (hat('art')) { + if (!ARTEN.includes(daten.art)) throw new TypeError('Projektrechnung.art ist ungültig.') + doc.art = daten.art + } + if (hat('betragCents')) { + if (!Number.isSafeInteger(daten.betragCents) || daten.betragCents < 0) throw new TypeError('Projektrechnung.betragCents ist ungültig.') + doc.betragCents = daten.betragCents + } + if (hat('arbeitsblattIds')) { + if (!Array.isArray(daten.arbeitsblattIds) || !daten.arbeitsblattIds.every(istNichtLeer)) throw new TypeError('Projektrechnung.arbeitsblattIds ist ungültig.') + const json = JSON.stringify(daten.arbeitsblattIds) + if (json.length > JSON_GROESSE) throw new AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.') + doc.arbeitsblattIdsJson = json + } + if (hat('aktiv')) { + if (typeof daten.aktiv !== 'boolean') throw new TypeError('Projektrechnung.aktiv ist ungültig.') + doc.aktiv = daten.aktiv + } + if (vollstaendig && (!Object.hasOwn(doc, 'kundenId') || !Object.hasOwn(doc, 'art') || !Object.hasOwn(doc, 'betragCents'))) { + throw new TypeError('Projektrechnung braucht kundenId, art und betragCents.') + } + return doc +} + +export async function getProjektRechnung(invoiceId) { + if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) return null + try { + return docToRechnung(await getDocument(config.collections.projektRechnungen, invoiceId)) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +export async function createProjektRechnung(invoiceId, daten) { + if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw new TypeError('Ungültige Rechnungs-ID.') + const doc = { + projektId: '', auftragId: '', rechnungsnummer: '', erstelltVon: '', arbeitsblattIds: [], aktiv: true, + ...Object.fromEntries(Object.entries(daten).filter(([, wert]) => wert !== undefined)), + } + const felder = { ...feldDoc(doc, { vollstaendig: true }), invoiceId, erstelltAm: new Date().toISOString() } + try { + return docToRechnung(await createDocument(config.collections.projektRechnungen, felder, invoiceId)) + } catch (err) { + if (err?.status === 409) throw new AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.') + throw err + } +} + +export async function updateProjektRechnung(invoiceId, patch) { + const nichtGefunden = () => new AuftragError(404, 'Projektrechnung nicht gefunden.') + if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw nichtGefunden() + const doc = feldDoc(patch, { vollstaendig: false }) + try { + return docToRechnung(await updateDocument(config.collections.projektRechnungen, invoiceId, doc)) + } catch (err) { + if (err?.status === 404) throw nichtGefunden() + throw err + } +} + +/** Nach erstelltAm aufsteigend. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */ +export async function listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) { + const abfragen = [] + for (const [key, wert] of Object.entries({ kundenId, projektId, auftragId })) { + if (wert === undefined) continue + if (!istNichtLeer(wert)) throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`) + abfragen.push(Query.equal(key, wert)) + } + if (typeof nurAktiv !== 'boolean') throw new TypeError('Filter nurAktiv muss ein Wahrheitswert sein.') + if (nurAktiv) abfragen.push(Query.equal('aktiv', true)) + const docs = await listAllDocuments(config.collections.projektRechnungen, abfragen) + return docs + .map(docToRechnung) + .sort((a, b) => (a.erstelltAm || '').localeCompare(b.erstelltAm || '') || a.invoiceId.localeCompare(b.invoiceId)) +} + +/** + * Hängt alle Dokumente einer AB (auch stornierte) an die neue AB. Bei einem Fehler bleibt ein Teilstand, ein + * erneuter Aufruf setzt fort. Liefert die Anzahl umgehängter Dokumente. + */ +export async function umhaengen(auftragIdAlt, auftragIdNeu) { + if (!istNichtLeer(auftragIdAlt) || !istNichtLeer(auftragIdNeu)) throw new TypeError('umhaengen: beide AB-IDs müssen nicht leerer Text sein.') + if (auftragIdAlt === auftragIdNeu) return 0 + const alte = await listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false }) + for (const rechnung of alte) { + await updateDocument(config.collections.projektRechnungen, rechnung.invoiceId, { auftragId: auftragIdNeu }) + } + return alte.length +} diff --git a/test/auftraegeProjektRechnungenStore.test.js b/test/auftraegeProjektRechnungenStore.test.js new file mode 100644 index 0000000..9aa903a --- /dev/null +++ b/test/auftraegeProjektRechnungenStore.test.js @@ -0,0 +1,115 @@ +// test/auftraegeProjektRechnungenStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Projektrechnungen (Dokument-ID = Stripe-Rechnungs-ID). Nachbau von Appwrite mit Schema und Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen } = + await import('../server/services/auftraege/projektRechnungenStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.projektRechnungen +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const DATEN = { kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, arbeitsblattIds: ['w1', 'w2'], erstelltVon: 'kenso@webklar.com' } +const seed = (id, felder = {}) => + aw.seed(COL, { + $id: id, invoiceId: id, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 100, + arbeitsblattIdsJson: '[]', rechnungsnummer: '', aktiv: true, erstelltAm: '2026-10-01T10:00:00.000Z', ...felder, + }) + +test('createProjektRechnung: Dokument-ID ist die Rechnungs-ID, Standardwerte, Rückgabe in der Form aus V3', async () => { + const rechnung = await createProjektRechnung('in_1SD6vPRxAbCdEf', DATEN) + assert.deepEqual( + { ...rechnung, erstelltAm: undefined }, + { + invoiceId: 'in_1SD6vPRxAbCdEf', kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, + arbeitsblattIds: ['w1', 'w2'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: undefined, + }, + ) + assert.match(rechnung.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + const doc = aw.docs(COL).get('in_1SD6vPRxAbCdEf') + assert.equal(doc.invoiceId, 'in_1SD6vPRxAbCdEf') + assert.equal(doc.arbeitsblattIdsJson, '["w1","w2"]') + assert.equal(doc.aktiv, true) + assert.deepEqual(await getProjektRechnung('in_1SD6vPRxAbCdEf'), rechnung) + const minimal = await createProjektRechnung('in_test_1', { kundenId: 'c1', art: 'festbetrag', betragCents: 5000 }) + assert.deepEqual([minimal.projektId, minimal.auftragId, minimal.arbeitsblattIds, minimal.rechnungsnummer, minimal.aktiv], ['', '', [], '', true]) +}) + +test('Rechnungs-IDs wie Stripe sie vergibt (auch in_test_1 des Nachbaus); alles andere ist ein Programmfehler und legt nichts an', async () => { + for (const id of ['in_test_1', 'in_1SD6vPRxAbCdEf', `in_${'a'.repeat(33)}`]) assert.equal((await createProjektRechnung(id, DATEN)).invoiceId, id) + await Promise.all([...aw.docs(COL).keys()].map((id) => deleteDocument(COL, id))) + for (const id of ['', 'x', 'in_', 'in_a/b', 'in_../x', 'in_a-b', 'in_a.b', 'IN_abc', `in_${'a'.repeat(34)}`, undefined, 7]) { + await assert.rejects(() => createProjektRechnung(id, DATEN), TypeError, String(id)) + assert.equal(await getProjektRechnung(id), null, String(id)) + } + const falsch = [{ kundenId: '' }, { art: 'frei' }, { art: 'xyz' }, { betragCents: 1.5 }, { betragCents: -1 }, { arbeitsblattIds: 'w1' }, { arbeitsblattIds: [''] }, { aktiv: 'ja' }] + for (const abweichung of falsch) { + await assert.rejects(() => createProjektRechnung('in_abc', { ...DATEN, ...abweichung }), TypeError, JSON.stringify(abweichung)) + } + assert.equal(aw.docs(COL).size, 0) +}) + +test('createProjektRechnung: eine doppelte Rechnungs-ID ist 409', async () => { + await createProjektRechnung('in_abc', DATEN) + await assert.rejects(() => createProjektRechnung('in_abc', DATEN), (err) => err instanceof AuftragError && err.status === 409) +}) + +test('updateProjektRechnung ändert genannte Felder; eine unbekannte Rechnung ist 404', async () => { + await createProjektRechnung('in_abc', DATEN) + const gesendet = await updateProjektRechnung('in_abc', { rechnungsnummer: 'WEBKLAR-0009' }) + assert.deepEqual([gesendet.rechnungsnummer, gesendet.aktiv, gesendet.betragCents], ['WEBKLAR-0009', true, 22500]) + const storniert = await updateProjektRechnung('in_abc', { aktiv: false, arbeitsblattIds: ['w1'] }) + assert.deepEqual([storniert.aktiv, storniert.arbeitsblattIds, storniert.rechnungsnummer], [false, ['w1'], 'WEBKLAR-0009']) + assert.equal(aw.docs(COL).get('in_abc').aktiv, false) + for (const id of ['in_gibtsnicht', '../x', '']) { + await assert.rejects(() => updateProjektRechnung(id, { aktiv: false }), (err) => err instanceof AuftragError && err.status === 404, id) + } +}) + +test('listProjektRechnungen: nur aktive, Filter, nach erstelltAm aufsteigend, mehr als 25 Treffer', async () => { + seed('in_a', { erstelltAm: '2026-10-03T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_b', { erstelltAm: '2026-10-01T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_c', { aktiv: false, erstelltAm: '2026-10-02T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_d', { kundenId: 'c2', projektId: 'p2', erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listProjektRechnungen(filter)).map((r) => r.invoiceId) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ kundenId: 'c1', nurAktiv: false }), ['in_b', 'in_c', 'in_a']) + assert.deepEqual(await ids({ auftragId: 'ab1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ projektId: 'p2' }), ['in_d']) + assert.deepEqual(await ids(), ['in_b', 'in_a', 'in_d']) + for (let i = 0; i < 30; i += 1) seed(`in_m${i}`, { kundenId: 'c9' }) + assert.equal((await listProjektRechnungen({ kundenId: 'c9' })).length, 30) +}) + +test('listProjektRechnungen: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('in_a') + for (const filter of [{ kundenId: '' }, { projektId: null }, { auftragId: 7 }, { nurAktiv: 'ja' }]) { + await assert.rejects(() => listProjektRechnungen(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('umhaengen hängt alle Dokumente einer AB (auch stornierte) an die neue AB, ist wiederholbar und lässt andere unberührt', async () => { + seed('in_a', { auftragId: 'ab1' }) + seed('in_b', { auftragId: 'ab1', aktiv: false }) + seed('in_c', { auftragId: 'ab2' }) + assert.equal(await umhaengen('ab1', 'ab3'), 2) + assert.deepEqual([...aw.docs(COL).values()].map((d) => [d.invoiceId, d.auftragId]).sort(), [['in_a', 'ab3'], ['in_b', 'ab3'], ['in_c', 'ab2']]) + assert.equal(await umhaengen('ab1', 'ab3'), 0) + assert.equal(await umhaengen('ab3', 'ab3'), 0) + for (const [alt, neu] of [['', 'ab3'], ['ab1', ''], [undefined, 'ab3'], ['ab1', null]]) { + await assert.rejects(() => umhaengen(alt, neu), TypeError, JSON.stringify([alt, neu])) + } +})