/** * Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz). * Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der * Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus). */ import { config } from '../../config.js' import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js' import { getStripe } from '../stripeBilling.js' import { withLocks } from '../keyedLock.js' import { AuftragError } from './fehler.js' import { RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag, } from './konstanten.js' import { festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, SCHLUSS_GRENZEN, } from './positionen.js' import { auftraegeAktiv } from './schalter.js' import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' import { updateProjektRechnung } from './projektRechnungenStore.js' import { ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, } from './arbeitsblaetter.js' import { formatDe } from '../finance/dates.js' // Appwrite-Dokument-ID const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ /** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ export async function ladeFestenKunden(customerId) { if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.') let kunde try { kunde = await getDocument(config.collections.customers, customerId) } catch (err) { if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.') throw err } const stufe = String(kunde.customerStatus || '').trim().toLowerCase() if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.') return kunde } /** Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). */ async function ladeProjekt(kundenId, projektId) { if (typeof projektId !== 'string' || !projektId) throw new AuftragError(400, 'Für diese Rechnungsart ist ein Projekt nötig.') if (!KENNUNG.test(projektId)) throw new AuftragError(404, 'Projekt nicht gefunden.') let projekt try { projekt = await getDocument(config.collections.websiteProjects, projektId) } catch (err) { if (err.status === 404) throw new AuftragError(404, 'Projekt nicht gefunden.') throw err } if (projekt.customerId !== kundenId) throw new AuftragError(404, 'Projekt nicht gefunden.') return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' } } /** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */ async function ladeAuftrag(kundenId, auftragId) { if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.') const auftrag = await getAuftrag(auftragId) if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.') return auftrag } /** * Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an * Appwrite). Fehler mit Status 404 sind still. Wirft nie. */ export async function buche(was, fn) { try { await fn() } catch (err) { if (err?.status === 404) return console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') } } /** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */ export async function setzeAbZurueck(auftragId) { await withLocks(sperreAuftrag(auftragId), async () => { const ab = await getAuftrag(auftragId) if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' }) }) } /** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */ export async function gibProjektRechnungFrei(invoiceId) { const doc = await updateProjektRechnung(invoiceId, { aktiv: false }) if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId) return doc } const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing' /** * Selbstheilung: belegt ist Map. Je Rechnung bei Stripe nachsehen: storniert oder * geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird * im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen. */ async function pruefeBelegungen(belegt) { const urteile = new Map() for (const { invoiceId } of belegt.values()) { if (urteile.has(invoiceId)) continue let roh try { roh = await getStripe().invoices.retrieve(invoiceId) } catch (err) { if (!fehltBeiStripe(err)) throw err roh = null } if (!roh || roh.status === 'void') { await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId)) urteile.set(invoiceId, null) continue } const nummer = roh.status === 'draft' ? '' : roh.number || '' urteile.set(invoiceId, nummer) } const gueltig = new Map() for (const [blattId, belegung] of belegt) { const nummer = urteile.get(belegung.invoiceId) if (nummer === null) continue if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) { gueltig.set(`nachgetragen:${belegung.invoiceId}`, true) await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer })) } gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer }) } for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel) return gueltig } function pruefeLeistungszeitraum(wert) { if (wert === undefined || wert === null) return '' if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) { throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`) } return wert.trim() } /** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */ function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) { const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen }) const projektId = projekt?.id ?? auftrag?.projektId ?? '' const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140) const customFields = [] if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName }) if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` }) if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum }) const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art } if (projektId) metadata.projektId = projektId if (projektName) metadata.projektName = projektName if (auftrag) { metadata.auftragId = auftrag.id if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer } const projektRechnung = art === 'frei' ? null : { kundenId: kunde.$id, projektId, auftragId: auftrag?.id ?? '', art, betragCents: summeCents, arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [], rechnungsnummer: '', aktiv: true, erstelltVon: String(createdBy || '').slice(0, 255), erstelltAm: new Date().toISOString(), } return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung } } /** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */ export async function baueRechnung(eingabe) { const art = eingabe.art ?? 'frei' if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.') const kunde = await ladeFestenKunden(eingabe.customerId) const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum) const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy } if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) }) if (art === 'festbetrag') { const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) }) } if (art === 'aufwand') return baueAufwand(basis, eingabe) throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.') } const MAX_BLAETTER = 400 const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.' function pruefeZeilentexte(wert) { if (wert === undefined || wert === null) return {} if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) { throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.') } return wert } /** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */ async function ladeAufwandAuftrag(kunde, projekt, auftragId) { if (auftragId !== undefined && auftragId !== null && auftragId !== '') { if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG) const auftrag = await ladeAuftrag(kunde.$id, auftragId) if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.') if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.') if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.') return auftrag } if (!auftraegeAktiv(kunde)) return null const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) return liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null } /** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */ async function waehleBlaetter(kunde, ids, eigeneTickets) { if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) { throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.') } const eindeutig = [...new Set(ids)] if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.') if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`) if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT) const blaetter = await ladeArbeitsblaetterNachIds(eindeutig) if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT) if (blaetter.some((b) => !abrechenbar(b))) { throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).') } const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id) const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)])) if (belegt.size) { const gueltig = await pruefeBelegungen(belegt) for (const id of eindeutig) { const b = gueltig.get(id) if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`) } } return blaetter } /** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */ async function standardBlaetter(kunde, projekt, eigeneTickets) { const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : [] const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id)) const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : []) let offene = await lade() if (!offene.length && zeitquelle.length) { const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id) const belegt = new Map() for (const blatt of await ladeArbeitsblaetter(zeitquelle)) { if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id)) } if (belegt.size) { const gueltig = await pruefeBelegungen(belegt) if (gueltig.size < belegt.size) offene = await lade() } } if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.') if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`) return offene } async function baueAufwand(basis, eingabe) { const { kunde } = basis const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId) const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS const zusatz = zusatzPositionen(eingabe.items) const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte) const eigeneTickets = new Set( (await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id) ) const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets) const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length }) return schliesseAb({ ...basis, projekt, auftrag, positionen: [...tage, ...zusatz], leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis), arbeitsblattIds: blaetter.map((b) => b.id), }) }