/** * 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, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag, } from './konstanten.js' import { festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN, } from './positionen.js' import { auftraegeAktiv } from './schalter.js' import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js' import { ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, } from './arbeitsblaetter.js' import { berlinDate, formatDe } from '../finance/dates.js' import { euroDe } from '../finance/format.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). Mit weich = true liefert jeder * Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht). */ async function ladeProjekt(kundenId, projektId, { weich = false } = {}) { const fehler = (status, meldung) => { if (weich) return null throw new AuftragError(status, meldung) } if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.') if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.') let projekt try { projekt = await getDocument(config.collections.websiteProjects, projektId) } catch (err) { if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.') throw err } if (projekt.customerId !== kundenId) return fehler(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) if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG) 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) return baueAbRechnung(basis, eingabe) } 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), }) } const FINALISIERT = ['open', 'paid', 'uncollectible'] /** * Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard * stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen. */ async function ladeFruehere(auftragId) { const ergebnis = [] for (const doc of await listProjektRechnungen({ auftragId })) { let roh try { roh = await getStripe().invoices.retrieve(doc.invoiceId) } catch (err) { if (!fehltBeiStripe(err)) throw err roh = null } if (!roh || roh.status === 'void') { await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId)) continue } const nummer = roh.number || '' if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) { await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer })) } ergebnis.push({ invoiceId: doc.invoiceId, art: doc.art, status: roh.status, rechnungsnummer: nummer, datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)), betragCents: roh.total ?? 0, }) } return ergebnis } /** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */ async function baueAbRechnung(basis, eingabe) { const { kunde, art } = basis const { auftragId } = eingabe if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.') let auftrag = await ladeAuftrag(kunde.$id, auftragId) if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.') if (auftrag.status === 'abgerechnet') { await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck auftrag = await ladeAuftrag(kunde.$id, auftragId) } if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') let projekt = null if (typeof eingabe.projektId === 'string' && eingabe.projektId) { projekt = await ladeProjekt(kunde.$id, eingabe.projektId) if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.') } else if (auftrag.projektId) { projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true }) } const fruehere = await ladeFruehere(auftrag.id) const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss')) if (entwurf) { throw new AuftragError(409, art === 'schluss' ? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.` : 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.') } const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status)) let positionen if (art === 'anzahlung') { const angaben = eingabe.anzahlung ?? {} if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.') positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))] } else { positionen = schlussPositionen( auftrag, finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })), eingabe.items ) } return schliesseAb({ ...basis, projekt, auftrag, positionen }) }