/** * 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 }