Buchungskalender: origin/main (Aufträge) zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 15:46:31 +00:00
commit fd971309a8
145 files changed
+42590 -129

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
/**
* Ablage der gesendeten AB-PDFs unter config.auftraege.uploadDir (wird bei jedem Aufruf gelesen):
* <uploadDir>/<kundenId>/<auftragId>.pdf, Verzeichnis 0700, Datei 0600. Überschreibt nie (die gesendete Fassung ist
* der Beleg). Kennungen und Pfade sind auf [A-Za-z0-9_-] begrenzt (kein Punkt außer vor „pdf“), damit kein „..“ und
* kein fremder Pfad möglich ist.
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../../config.js'
import { AuftragError } from './fehler.js'
const KENNUNG = /^[A-Za-z0-9_-]{1,64}$/
const PDF_PFAD = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_-]{1,64}\.pdf$/
const nichtGefunden = () => new AuftragError(404, 'PDF nicht gefunden.')
const gueltigeKennung = (wert) => typeof wert === 'string' && KENNUNG.test(wert)
function pfadVon(pdfDatei) {
if (typeof pdfDatei !== 'string' || !PDF_PFAD.test(pdfDatei)) throw nichtGefunden()
return path.join(config.auftraege.uploadDir, pdfDatei)
}
export async function speicherePdf(kundenId, auftragId, buffer) {
if (!gueltigeKennung(kundenId) || !gueltigeKennung(auftragId)) throw new TypeError('Ungültige Kunden- oder Auftrags-ID für die PDF-Ablage.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('Das PDF muss ein nicht leerer Buffer sein.')
const pdfDatei = `${kundenId}/${auftragId}.pdf`
const datei = path.join(config.auftraege.uploadDir, pdfDatei)
await mkdir(path.dirname(datei), { recursive: true, mode: 0o700 })
try {
await writeFile(datei, buffer, { flag: 'wx', mode: 0o600 })
} catch (err) {
if (err?.code === 'EEXIST') throw new AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')
// eine angelegte Teildatei nicht liegen lassen
await unlink(datei).catch(() => {})
throw err
}
return { pdfDatei, pdfSha256: createHash('sha256').update(buffer).digest('hex') }
}
export async function lesePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
return await readFile(datei)
} catch (err) {
if (err?.code === 'ENOENT') throw nichtGefunden()
throw err
}
}
/** Räumt ein abgelegtes PDF weg, wenn danach etwas scheitert (L3). Fehlt die Datei, ist das folgenlos. */
export async function entfernePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
await unlink(datei)
} catch (err) {
if (err?.code !== 'ENOENT') throw err
}
}
@@ -0,0 +1,108 @@
/**
* Arbeitsblätter (Collection worksheets, gehört dem Ticketsystem) für die Projektabrechnung: laden, normieren
* (Berliner Tag, auf 15 Minuten aufgerundete Minuten, kundentauglicher Text) und als abgerechnet erkennen
* (Übersicht V3/V4). „Abgerechnet“ gibt es nur über aktive Projektrechnungen: ein stornierter Eintrag gibt seine
* Blätter frei, ein Entwurf sperrt sie. Abo-Zeit und Kommentare sind nie abrechenbar.
*/
import { config } from '../../config.js'
import { listAllDocuments, Query } from '../appwriteAdmin.js'
import { customerWorkEntries } from '../customerProjectView.js'
import { berlinDate } from '../finance/dates.js'
import { worksheetMinutes } from '../worksheetMinutes.js'
import { VEREINBARTE_STATUS, VIERTELSTUNDE } from './konstanten.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
const BLOCK = 100 // Werte je equal-Abfrage (Appwrite begrenzt die Werteliste)
const MAX_TEXT = 200
const DE_DATUM = /^(\d{2})\.(\d{2})\.(\d{4})$/
/** Berliner Kalendertag eines Arbeitsblatts; nie ein Wurf. Regel wie parseSheetDate in hourLedger.js. */
function blattDatum(doc) {
const roh = String(doc.startDate || doc.endDate || '').trim()
const de = DE_DATUM.exec(roh)
if (de) {
const [tag, monat, jahr] = [Number(de[1]), Number(de[2]), Number(de[3])]
const pruefung = new Date(Date.UTC(jahr, monat - 1, tag))
if (pruefung.getUTCFullYear() === jahr && pruefung.getUTCMonth() === monat - 1 && pruefung.getUTCDate() === tag) return `${de[3]}-${de[2]}-${de[1]}`
// ein unmögliches Datum (31.02.) ist nie ein Kandidat für new Date: V8 läse „12.13.2026“ als Dezember
}
const kandidaten = [de ? '' : roh, doc.createdAt, doc.$createdAt]
for (const kandidat of kandidaten) {
if (!kandidat) continue
const datum = new Date(kandidat)
if (!Number.isNaN(datum.getTime())) return berlinDate(datum)
}
return ''
}
/** Text wie in der Kundenansicht (customerWorkEntries): eine Zeile, höchstens 200 Zeichen. */
function blattText(doc) {
const [eintrag] = customerWorkEntries([doc], { includeUpdates: true })
if (!eintrag) return ''
const roh = Array.isArray(eintrag.changes) ? eintrag.changes.join('; ') : String(eintrag.text || '')
const text = roh.replace(/\s+/g, ' ').trim()
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1).trimEnd()}…` : text
}
function docToBlatt(doc) {
return {
id: doc.$id,
ticketId: String(doc.workorderId ?? ''),
woid: String(doc.woid ?? ''),
datum: blattDatum(doc),
minuten: Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE,
text: blattText(doc),
aboZeit: doc.countsToPlan === true,
kommentar: doc.isComment === true,
}
}
const sortiert = (blaetter) => blaetter.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id))
async function ladeNach(attribut, werte) {
const liste = Array.isArray(werte) ? [...new Set(werte.filter((wert) => typeof wert === 'string' && wert !== ''))] : []
const blaetter = []
for (let i = 0; i < liste.length; i += BLOCK) {
const docs = await listAllDocuments(config.collections.worksheets, [Query.equal(attribut, liste.slice(i, i + BLOCK))])
blaetter.push(...docs.map(docToBlatt))
}
return sortiert(blaetter)
}
export const ladeArbeitsblaetter = (ticketIds) => ladeNach('workorderId', ticketIds)
/** Liefert nur gefundene Blätter; der Aufrufer vergleicht die Anzahl. */
export const ladeArbeitsblaetterNachIds = (ids) => ladeNach('$id', ids)
export const abrechenbar = (blatt) => !blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0
/** Karte Arbeitsblatt-ID → { invoiceId, rechnungsnummer } aus aktiven Projektrechnungen (Entwurf: Nummer ''). */
export async function abgerechneteArbeitsblaetter(kundenId) {
const karte = new Map()
for (const rechnung of await listProjektRechnungen({ kundenId })) {
for (const id of rechnung.arbeitsblattIds) {
if (!karte.has(id)) karte.set(id, { invoiceId: rechnung.invoiceId, rechnungsnummer: rechnung.rechnungsnummer })
}
}
return karte
}
export async function offeneArbeitsblaetter(ticketIds, kundenId) {
const abgerechnet = await abgerechneteArbeitsblaetter(kundenId)
return (await ladeArbeitsblaetter(ticketIds)).filter((blatt) => abrechenbar(blatt) && !abgerechnet.has(blatt.id))
}
/**
* Zeitquelle eines Projekts: Vereinigung der zeitquelle aller vereinbarten ABs (VEREINBARTE_STATUS), sonst das
* Ticket des Projekts. Der Aufrufer übergibt nur die ABs dieses Projekts; hier wird nicht nach Projekt gefiltert.
*/
export function zeitquelleFuerProjekt(projekt, auftraege) {
const quelle = new Set()
for (const auftrag of Array.isArray(auftraege) ? auftraege : []) {
if (!VEREINBARTE_STATUS.includes(auftrag?.status)) continue
for (const id of auftrag.zeitquelle || []) if (typeof id === 'string' && id !== '') quelle.add(id)
}
if (quelle.size) return [...quelle]
const ticketId = projekt?.ticketId
return typeof ticketId === 'string' && ticketId !== '' ? [ticketId] : []
}
+577
View File
@@ -0,0 +1,577 @@
/**
* Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren
* und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“).
* Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen
* über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter
* sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre.
*/
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { getSettings } from '../finance/settings.js'
import { withLocks } from '../keyedLock.js'
import { kundeSnapshot } from './auftragSicht.js'
import { entfernePdf, lesePdf, speicherePdf } from './ablage.js'
import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { vergibNummer } from './nummern.js'
import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js'
import { TEXTE_FASSUNG } from './texte.js'
import {
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
} from './konstanten.js'
// ---------------------------------------------------------------------------------------------------------------------
// Eingabeprüfung (rein, synchron)
// ---------------------------------------------------------------------------------------------------------------------
/** Appwrite-Dokument-ID (wie in auftragStore.js). */
export function istDokumentId(wert) {
return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert)
}
const fehler400 = (meldung) => new AuftragError(400, meldung)
const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.'
const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.'
const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.'
const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).'
const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.'
const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).'
const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.'
const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]'
function istKalendertag(wert) {
if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false
const datum = new Date(`${wert}T00:00:00Z`)
return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert
}
function pruefePosition(roh, n) {
const p = istEinfachesObjekt(roh) ? roh : {}
const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) {
throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) {
throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`)
}
return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents }
}
function pruefePlan(roh) {
if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN)
let prozent = null
let text = ''
if (roh.art === 'anzahlung') {
prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent
if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.')
}
if (roh.art === 'frei') {
text = typeof roh.text === 'string' ? roh.text.trim() : ''
if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`)
}
return { art: roh.art, prozent, text }
}
function pruefeOptionalesDatum(wert, meldung) {
if (wert === null || wert === '') return ''
if (!istKalendertag(wert)) throw fehler400(meldung)
return wert
}
/** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */
const FELDREGELN = {
art: (w) => {
if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART)
return w
},
projektId: (w) => {
if (w === null || w === '') return ''
if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.')
return w
},
titel: (w) => {
if (w === null) return ''
if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`)
return w.trim()
},
kundenart: (w) => {
if (w === null || w === '') return ''
if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.')
return w
},
positionen: (w) => {
if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN)
return w.map((p, i) => pruefePosition(p, i + 1))
},
stundensatzCents: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`)
return w
},
schaetzungMinuten: (w) => {
if (w === null || w === '' || w === 0) return null
if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) {
throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`)
}
return w
},
zahlungsplan: pruefePlan,
zahlungszielTage: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) {
throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`)
}
return w
},
auftragVom: (w) => {
if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM)
return w
},
auftragWeg: (w) => {
if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG)
return w
},
startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
zeitquelle: (w) => {
if (w === null) return []
if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.')
if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.')
const ohneDoppelte = [...new Set(w)]
if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`)
return ohneDoppelte
},
}
const FELD_REIHENFOLGE = Object.keys(FELDREGELN)
/**
* Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer,
* tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung.
*/
export function pruefeEingabe(eingabe, { teilweise = false } = {}) {
if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.')
const felder = {}
for (const name of FELD_REIHENFOLGE) {
if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name])
}
if (teilweise) return felder
// Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert
if (felder.art === undefined) throw fehler400(MELDUNG_ART)
if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN)
if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM)
if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG)
const ergebnis = {
projektId: felder.projektId ?? '',
titel: felder.titel ?? '',
kundenart: felder.kundenart ?? '',
art: felder.art,
positionen: [],
stundensatzCents: null,
schaetzungMinuten: null,
summeCents: null,
zahlungsplan: felder.zahlungsplan,
zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage,
auftragVom: felder.auftragVom,
auftragWeg: felder.auftragWeg,
startAm: felder.startAm ?? '',
fertigBis: felder.fertigBis ?? '',
zeitquelle: felder.zeitquelle ?? [],
}
if (felder.art === 'festpreis') {
if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN)
const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME)
if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.')
ergebnis.positionen = felder.positionen
ergebnis.summeCents = summe
} else {
if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null
}
return ergebnis
}
// ---------------------------------------------------------------------------------------------------------------------
// Entwurf anlegen, ändern, löschen
// ---------------------------------------------------------------------------------------------------------------------
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
export async function ladeAuftrag(id) {
const auftrag = istDokumentId(id) ? await getAuftrag(id) : null
if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
return auftrag
}
export async function ladeKunde(kundenId) {
const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.')
if (!istDokumentId(kundenId)) throw nichtGefunden()
try {
return await getDocument(config.collections.customers, kundenId)
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
/** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */
function eingabeAusAuftrag(auftrag) {
const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag
return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }
}
/** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */
function ticketsDesProjekts(projekt) {
return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : []
}
async function ladeProjekt(projektId) {
try {
return await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */
async function pruefeBezuege(kundeDoc, felder) {
let titel = felder.titel
let zeitquelle = felder.zeitquelle
let projekt = null
if (felder.projektId) {
projekt = await ladeProjekt(felder.projektId)
if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
}
if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim()
if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt)
if (zeitquelle.length > 0) {
const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)])
const gefunden = new Map(tickets.map((t) => [t.$id, t]))
if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) {
throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.')
}
}
return { ...felder, titel, zeitquelle }
}
/** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */
async function schreibeKundenart(kundeDoc, kundenart) {
if (!kundenart || hatText(kundeDoc.kundenart)) return
await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart })
}
/** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */
const speicherFelder = (f) => ({
projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen,
stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents,
zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg,
startAm: f.startAm, fertigBis: f.fertigBis,
})
export async function legeEntwurfAn(eingabe, { von } = {}) {
const felder = pruefeEingabe(eingabe)
if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.')
const kundeDoc = await ladeKunde(eingabe.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') })
}
export async function aendereEntwurf(id, eingabe) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.')
if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) {
throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
}
const teil = pruefeEingabe(eingabe, { teilweise: true })
const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil })
const kundeDoc = await ladeKunde(auftrag.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return updateAuftrag(id, speicherFelder(fertig))
})
}
export async function loescheEntwurf(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.')
await deleteAuftrag(id)
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Abhängigkeiten (PDF, Token, Mail) und Vorschau-PDF
// ---------------------------------------------------------------------------------------------------------------------
/** Ins Protokoll nur Art, Status und Code, nie die Meldung (sie kann Adressen oder Pfade enthalten). */
function logFehler(was, err) {
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
// PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen
const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' }
let depsFuerTests = null
/** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */
export function setzeDepsFuerTests(deps) {
depsFuerTests = deps
}
async function ladeDeps(deps, namen) {
const quelle = { ...depsFuerTests, ...deps }
const geladen = {}
for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name]
return geladen
}
/** Erzeugt das PDF; ein AuftragError der PDF-Funktion (z. B. 409 „Entwurf unvollständig“) bleibt erhalten. */
async function pdfErzeugen(d, auftrag, optionen) {
let pdf
try {
pdf = await d.erzeugeAuftragPdf(auftrag, optionen)
if (!Buffer.isBuffer(pdf) || pdf.length === 0) throw new Error('leeres PDF')
} catch (err) {
if (err instanceof AuftragError) throw err
logFehler('PDF-Erzeugung', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
return pdf
}
export async function vorschauPdf(id, { deps } = {}) {
const d = await ladeDeps(deps, ['erzeugeAuftragPdf'])
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Eine Vorschau gibt es nur für Entwürfe.')
const kundeDoc = await ladeKunde(auftrag.kundenId)
const { steuernummer, bankDetailsText } = await getSettings()
return pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true })
}
// ---------------------------------------------------------------------------------------------------------------------
// Senden
// ---------------------------------------------------------------------------------------------------------------------
/** Prüfreihenfolge: fester Kunde, Kundenart, E-Mail, Anschrift (jeweils mit fester Meldung). */
function pruefeKundeFuerVersand(kundeDoc) {
const status = String(kundeDoc.customerStatus || '').trim().toLowerCase()
if (status === 'lead' || status === 'lost') throw new AuftragError(400, 'Auftragsbestätigungen nur für feste Kunden.')
if (!KUNDENARTEN.includes(String(kundeDoc.kundenart || '').trim())) {
throw new AuftragError(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).')
}
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
if (!hatText(kundeDoc.street) || !hatText(kundeDoc.postalCode) || !hatText(kundeDoc.city)) {
throw new AuftragError(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.')
}
}
/** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */
async function raeumePdfAuf(auftragId, pdfDatei) {
try {
const aktuell = await getAuftrag(auftragId)
if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei)
} catch (err) {
logFehler('PDF-Rest entfernen', err)
}
}
/** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */
function mailFehlerText(err) {
const code = err?.code
return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code)
? `E-Mail konnte nicht gesendet werden (${code}).`
: 'E-Mail konnte nicht gesendet werden.'
}
/** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */
async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) {
let mailPatch
try {
await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung })
mailPatch = { mailStatus: 'gesendet', mailFehler: '' }
} catch (err) {
logFehler('Mailversand', err)
mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) }
}
const final = await updateAuftrag(gespeichert.id, mailPatch)
return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }
}
const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.'
/**
* Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung
* nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten),
* gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt.
*/
async function pruefeErsetzteAb(entwurf) {
if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
const alt = await getAuftrag(entwurf.ersetztId)
const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id))
if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
return alt
}
/**
* Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen
* AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt.
*/
async function ersetzeAlteAb(entwurf) {
await withLocks(sperreAuftrag(entwurf.ersetztId), async () => {
const alt = await pruefeErsetzteAb(entwurf)
if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id })
await umhaengen(alt.id, entwurf.id)
})
}
export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand
const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail'])
// Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten
const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => {
const entwurf = await ladeAuftrag(id)
if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.')
pruefeEingabe(eingabeAusAuftrag(entwurf))
const kundeDoc = await ladeKunde(entwurf.kundenId)
pruefeKundeFuerVersand(kundeDoc)
if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF
const kunde = kundeSnapshot(kundeDoc)
const { steuernummer, bankDetailsText } = await getSettings()
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
const gesendetAm = jetzt.toISOString()
const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }
const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } })
let ablage
try {
ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes)
} catch (err) {
// eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt
logFehler('PDF-Ablage', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
try {
const neu = d.neuerToken(jetzt)
if (entwurf.ersetztId) await ersetzeAlteAb(entwurf)
const gespeichertesDokument = await updateAuftrag(entwurf.id, {
status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG,
pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(),
})
return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token }
} catch (err) {
await raeumePdfAuf(entwurf.id, ablage.pdfDatei)
throw err
}
})
return versendeMail(d, gespeichert, { pdf, token })
}
// ---------------------------------------------------------------------------------------------------------------------
// Erinnern („Erinnerung senden“ und „Erneut senden“)
// ---------------------------------------------------------------------------------------------------------------------
/** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */
export async function ladeGespeichertesPdf(auftrag) {
if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.')
const buffer = await lesePdf(auftrag.pdfDatei)
if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) {
console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id)
throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.')
}
return buffer
}
/**
* Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten
* PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse.
*/
export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) {
const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail'])
const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.')
const kundeDoc = await ladeKunde(alt.kundenId)
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
// vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten
const pdfBytes = await ladeGespeichertesPdf(alt)
const neu = d.neuerToken(jetzt)
const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() })
return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' }
})
return versendeMail(d, gespeichert, { pdf, token, erinnerung })
}
// ---------------------------------------------------------------------------------------------------------------------
// Neue Fassung
// ---------------------------------------------------------------------------------------------------------------------
/** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */
export async function neueFassung(id, { von } = {}) {
return withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (!VEREINBARTE_STATUS.includes(alt.status)) {
throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.')
}
const vorhandene = await listAuftraege({ kundenId: alt.kundenId })
if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.')
}
return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Stornieren
// ---------------------------------------------------------------------------------------------------------------------
/** Storniert eine versendete oder bestätigte AB (ohne Mail); tokenHash, Nummer und PDF bleiben, der Link erklärt „storniert“. */
export async function storniereAuftrag(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id) // der Stand wird in der Sperre gelesen
if (auftrag.status !== 'versendet' && auftrag.status !== 'bestaetigt') {
throw new AuftragError(409, 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.')
}
return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Projekt nachträglich zuordnen
// ---------------------------------------------------------------------------------------------------------------------
/**
* Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des
* Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status
* und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt
* und die Wiederholung führt alles zu Ende.
*/
export async function ordneProjektZu(id, projektId) {
if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.')
if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.')
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) {
throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.')
}
if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.')
const projekt = await ladeProjekt(projektId)
if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false })
for (const rechnung of rechnungen) {
if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId })
}
const patch = { projektId }
if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt)
return updateAuftrag(id, patch)
})
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Sichten auf eine Auftragsbestätigung (Übersicht V3/V4): Kundensnapshot, früheste Leistung bei Privatpersonen,
* Zahlungsplan- und Aufwandstext, Ansicht fürs Team und öffentliche Ansicht für Token- und Kundenrouten.
* Alles rein (kein Appwrite, keine Konfiguration); die Wortlaute kommen aus texte.js.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { addDays, berlinDate } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { AGB_FASSUNG, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS } from './konstanten.js'
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from './texte.js'
// Kürzungen, damit der Snapshot samt JSON in die Spalte kundeJson (2000 Zeichen) passt
const SNAPSHOT_FELDER = [
['name', 'name', 120], ['firma', 'companyName', 120], ['strasse', 'street', 120], ['plz', 'postalCode', 20],
['ort', 'city', 80], ['land', 'country', 60], ['email', 'email', 200], ['kundennummer', 'code', 40],
]
const text = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max).trim() : '')
export function kundeSnapshot(customerDoc) {
const doc = customerDoc || {}
const snapshot = {}
for (const [ziel, quelle, max] of SNAPSHOT_FELDER) snapshot[ziel] = text(doc[quelle], max)
snapshot.kundenart = KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : ''
// feste Reihenfolge der Schlüssel wie KundeSnapshot in V3
const { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer } = snapshot
return { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer }
}
/** Früheste Leistung bei Privatpersonen ohne Frühbeginn: Tag nach Ablauf der Widerrufsfrist. */
export function fruehesterBeginn(auftrag) {
if (auftrag.kunde?.kundenart !== 'privat' || auftrag.fruehbeginn || !auftrag.gesendetAm) return null
const gesendet = new Date(auftrag.gesendetAm)
if (Number.isNaN(gesendet.getTime())) return null
return addDays(withdrawalDeadline(gesendet).lastDay, 1)
}
export function zahlungsplanText(auftrag) {
const ziel = Number.isSafeInteger(auftrag.zahlungszielTage) ? auftrag.zahlungszielTage : LIMITS.standardZahlungszielTage
const plan = auftrag.zahlungsplan || { art: 'nach_abnahme' }
const bausteine = AB_TEXTE.zahlungsplan
if (plan.art === 'monatlich') return bausteine.monatlich({ ziel })
if (plan.art === 'frei') return bausteine.frei({ text: plan.text || '', ziel })
if (plan.art === 'anzahlung' && Number.isSafeInteger(auftrag.summeCents) && typeof plan.prozent === 'number' && Number.isFinite(plan.prozent)) {
const anzahlung = Math.round((auftrag.summeCents * plan.prozent) / 100)
return bausteine.anzahlung({ prozent: plan.prozent, anzahlung: euroDe(anzahlung), rest: euroDe(auftrag.summeCents - anzahlung), ziel })
}
return bausteine.nach_abnahme({ ziel })
}
/** Stunden als Text mit Dezimalkomma ohne überflüssige Nullen: 600 → '10', 135 → '2,25', 90 → '1,5'. */
const stundenText = (minuten) => String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
export function aufwandText(auftrag) {
if (auftrag.art !== 'aufwand') return null
const minuten = auftrag.schaetzungMinuten
return AB_TEXTE.aufwand({
satz: euroDe(auftrag.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS),
schaetzung: Number.isSafeInteger(minuten) && minuten > 0 ? stundenText(minuten) : null,
})
}
/** Auftrag ohne tokenHash, pdfDatei und bestaetigtPdfSha256, plus die abgeleiteten Felder. */
export function auftragFuerTeam(auftrag) {
const { tokenHash, pdfDatei, bestaetigtPdfSha256, ...rest } = auftrag
return {
...rest,
pdfVorhanden: Boolean(pdfDatei),
fruehesterBeginn: fruehesterBeginn(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
aufwandText: aufwandText(auftrag),
}
}
/** Nur für Token- und Kundenrouten: keine Kundendaten, kein Token, keine Pfade. */
export function oeffentlicheAnsicht(auftrag) {
const kundenart = auftrag.kunde?.kundenart || ''
return {
nummer: auftrag.nummer,
datum: auftrag.gesendetAm ? berlinDate(new Date(auftrag.gesendetAm)) : null,
titel: auftrag.titel,
art: auftrag.art,
positionen: auftrag.positionen.map((p) => ({
beschreibung: p.beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents, summeCents: p.menge * p.einzelpreisCents,
})),
summeCents: auftrag.summeCents,
stundensatzCents: auftrag.stundensatzCents,
schaetzungMinuten: auftrag.schaetzungMinuten,
aufwandText: aufwandText(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
startAm: auftrag.startAm,
fertigBis: auftrag.fertigBis,
kundenart,
status: auftrag.status,
bestaetigtAm: auftrag.bestaetigtAm,
fruehbeginn: auftrag.fruehbeginn,
fruehesterBeginn: fruehesterBeginn(auftrag),
agbUrl: `/rechtliches/agb-${auftrag.agbFassung || AGB_FASSUNG}.pdf`,
texte: {
bestaetigung: BESTAETIGUNG_TEXT,
fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null,
kleinunternehmer: AB_TEXTE.kleinunternehmer,
},
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Speicher der Auftragsbestätigungen (Collection auftraege, Übersicht V1/V3/V4).
* Die Feldlisten stammen aus AUFTRAEGE_COLLECTIONS (Typ und Größe der Spalten); nur vier Spalten sind JSON und
* heißen im Auftrag anders: kundeJson = kunde, zeitquelleJson = zeitquelle, positionenJson = positionen,
* zahlungsplanJson = zahlungsplan. Programmfehler (falsche Typen, ungültige Filter) sind TypeError, ein zu langer
* Text ist ein AuftragError 400. Schreibende Aufrufer halten bei Statusänderungen sperreAuftrag(id) (konstanten.js).
*/
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { LIMITS, VEREINBARTE_STATUS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' }
const SPALTE_ZU_FELD = (spalte) => JSON_SPALTEN[spalte] || spalte
const SPALTEN = AUFTRAEGE_COLLECTIONS.auftraege.attributes
const TEXT_SPALTEN = SPALTEN.filter((a) => a.type === 'string' && !JSON_SPALTEN[a.key])
const ZAHL_SPALTEN = SPALTEN.filter((a) => a.type === 'integer').map((a) => a.key)
const DATUM_SPALTEN = SPALTEN.filter((a) => a.type === 'datetime').map((a) => a.key)
const SPALTEN_NACH_FELD = new Map(SPALTEN.map((a) => [SPALTE_ZU_FELD(a.key), a]))
// Appwrite-Regel für Dokument-IDs
const DOKUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const gueltigeId = (id) => typeof id === 'string' && DOKUMENT_ID.test(id)
const TOKEN_HASH = /^[0-9a-f]{64}$/
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function parseJson(text) {
if (typeof text !== 'string' || text === '') return undefined
try {
return JSON.parse(text)
} catch {
return undefined
}
}
function zeitpunkt(wert) {
if (wert === null || wert === undefined || wert === '') return null
const datum = new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum.toISOString()
}
export function docToAuftrag(doc) {
const auftrag = { id: doc.$id }
for (const { key } of TEXT_SPALTEN) auftrag[key] = typeof doc[key] === 'string' ? doc[key] : ''
for (const key of ZAHL_SPALTEN) auftrag[key] = Number.isSafeInteger(doc[key]) ? doc[key] : null
auftrag.zahlungszielTage = Number.isSafeInteger(doc.zahlungszielTage) ? doc.zahlungszielTage : LIMITS.standardZahlungszielTage
auftrag.fruehbeginn = doc.fruehbeginn === true
for (const key of DATUM_SPALTEN) auftrag[key] = zeitpunkt(doc[key])
auftrag.erstelltAm = zeitpunkt(doc.erstelltAm) ?? zeitpunkt(doc.$createdAt)
auftrag.geaendertAm = zeitpunkt(doc.geaendertAm) ?? zeitpunkt(doc.$updatedAt)
const kunde = parseJson(doc.kundeJson)
auftrag.kunde = istObjekt(kunde) ? kunde : null
const zeitquelle = parseJson(doc.zeitquelleJson)
auftrag.zeitquelle = Array.isArray(zeitquelle)
? zeitquelle.filter((id) => typeof id === 'string' && id.trim() !== '').map((id) => id.trim())
: []
const positionen = parseJson(doc.positionenJson)
auftrag.positionen = Array.isArray(positionen) ? positionen.filter(istObjekt) : []
const plan = parseJson(doc.zahlungsplanJson)
auftrag.zahlungsplan = istObjekt(plan)
? {
art: ZAHLUNGSPLAN_ARTEN.includes(plan.art) ? plan.art : 'nach_abnahme',
prozent: typeof plan.prozent === 'number' && Number.isFinite(plan.prozent) ? plan.prozent : null,
text: typeof plan.text === 'string' ? plan.text : '',
}
: { art: 'nach_abnahme', prozent: null, text: '' }
return auftrag
}
function pruefeLaenge(feld, text, spalte) {
if (text.length > spalte.size) throw new AuftragError(400, `Das Feld ${feld} ist zu lang.`)
return text
}
/**
* Wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag): id, $… und
* fremde Schlüssel bleiben weg, undefined fehlt, null löscht (Text → '', Zahl und Datum → null).
*/
export function auftragToDoc(auftrag) {
const doc = {}
for (const [feld, wert] of Object.entries(auftrag)) {
const spalte = SPALTEN_NACH_FELD.get(feld)
if (!spalte || wert === undefined) continue
const name = spalte.key
if (JSON_SPALTEN[name]) {
doc[name] = wert === null ? '' : pruefeLaenge(feld, JSON.stringify(wert), spalte)
} else if (spalte.type === 'string') {
if (wert === null) doc[name] = ''
else if (typeof wert === 'string') doc[name] = pruefeLaenge(feld, wert, spalte)
else throw new TypeError(`Auftrag.${feld} muss Text sein.`)
} else if (spalte.type === 'integer') {
if (wert !== null && !Number.isSafeInteger(wert)) throw new TypeError(`Auftrag.${feld} muss eine ganze Zahl oder null sein.`)
doc[name] = wert
} else if (spalte.type === 'boolean') {
if (typeof wert !== 'boolean') throw new TypeError(`Auftrag.${feld} muss ein Wahrheitswert sein.`)
doc[name] = wert
} else if (spalte.type === 'datetime') {
if (wert === null) doc[name] = null
else {
const iso = typeof wert === 'string' && wert !== '' ? zeitpunkt(wert) : wert instanceof Date ? zeitpunkt(wert) : null
if (!iso) throw new TypeError(`Auftrag.${feld} muss ein Zeitpunkt oder null sein.`)
doc[name] = iso
}
}
}
return doc
}
const nichtGefunden = () => new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
export async function getAuftrag(id) {
if (!gueltigeId(id)) return null
try {
return docToAuftrag(await getDocument(config.collections.auftraege, id))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
function filterAbfragen(filter, { text = [], zahl = [] }) {
const abfragen = []
for (const key of text) {
if (filter[key] === undefined) continue
if (typeof filter[key] !== 'string' || filter[key] === '') throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
for (const key of zahl) {
if (filter[key] === undefined) continue
if (!Number.isSafeInteger(filter[key])) throw new TypeError(`Filter ${key} muss eine ganze Zahl sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
return abfragen
}
/** Neueste zuerst. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listAuftraege(filter = {}) {
const abfragen = filterAbfragen(filter, { text: ['kundenId', 'projektId'], zahl: ['jahr'] })
const docs = await listAllDocuments(config.collections.auftraege, abfragen)
return docs
.map(docToAuftrag)
.sort((a, b) => (b.erstelltAm || '').localeCompare(a.erstelltAm || '') || b.id.localeCompare(a.id))
}
export async function createAuftrag(daten) {
const jetzt = new Date().toISOString()
const doc = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })
return docToAuftrag(await createDocument(config.collections.auftraege, doc))
}
export async function updateAuftrag(id, patch) {
if (!gueltigeId(id)) throw nichtGefunden()
const doc = auftragToDoc({ ...patch, geaendertAm: new Date().toISOString() })
try {
return docToAuftrag(await updateDocument(config.collections.auftraege, id, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
export async function deleteAuftrag(id) {
if (!gueltigeId(id)) return
try {
await deleteDocument(config.collections.auftraege, id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
async function findeEine(feld, wert) {
const docs = await listDocuments(config.collections.auftraege, [Query.equal(feld, wert), Query.limit(1)])
return docs.length ? docToAuftrag(docs[0]) : null
}
/** Nur ein 64-stelliger Kleinbuchstaben-Hex-Hash führt zu einer Abfrage (ein leerer Hash träfe jeden Entwurf). */
export async function findAuftragByTokenHash(hash) {
if (typeof hash !== 'string' || !TOKEN_HASH.test(hash)) return null
return findeEine('tokenHash', hash)
}
export async function findAuftragByNummer(nummer) {
if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null
return findeEine('nummer', nummer)
}
const sendezeit = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
/** Jüngste zuerst nach Sendezeitpunkt (ersatzweise Anlagezeit), bei Gleichstand nach Kennung. */
export const juengsteZuerst = (a, b) => sendezeit(b) - sendezeit(a) || String(b.id).localeCompare(String(a.id))
/**
* Die Aufwands-AB, mit der ein Projekt abgerechnet und angezeigt wird: unter den gültigen (versendet, bestätigt) die jüngste
* nach gesendetAm, sonst null. Projektkonto und Rechnungsbau benutzen beide diese eine Auswahl.
*/
export function juengsteAufwandAb(liste) {
return liste.filter((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)).sort(juengsteZuerst)[0] ?? null
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`,
* Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen
* nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { withLocks } from '../keyedLock.js'
import { getAuftrag, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { sperreAuftrag } from './konstanten.js'
import { sendeBestaetigtMails } from './mails.js'
import { FRUEHBEGINN_FASSUNG } from './texte.js'
export const GESPERRT_MELDUNG = {
ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.',
storniert: 'Diese Auftragsbestätigung wurde storniert.',
widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.',
}
export const UNGUELTIGER_LINK = 'Dieser Link ist ungültig oder abgelaufen.'
const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen']
/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */
export function bestaetigungsLage(auftrag) {
if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt'
return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen'
}
function pruefeZeitpunkt(jetzt) {
if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein')
}
export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) {
// synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst
if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein')
pruefeZeitpunkt(jetzt)
const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const lage = bestaetigungsLage(frisch)
if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR)
if (ueber === 'link') {
// Die Route hat Token und Ablauf auf einem Stand ausserhalb der Sperre geprueft; eine Erinnerung (Token-Tausch) kann
// dazwischen gelaufen sein: dann gilt der alte Link nicht mehr, wie bei jedem ungueltigen Link (404)
const bis = frisch.tokenGueltigBis ? Date.parse(frisch.tokenGueltigBis) : Number.NaN
if (!frisch.tokenHash || frisch.tokenHash !== auftrag.tokenHash || !Number.isFinite(bis) || jetzt.getTime() > bis) {
throw new AuftragError(404, UNGUELTIGER_LINK)
}
}
if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false }
const privat = frisch.kunde?.kundenart === 'privat'
const patch = {
bestaetigtAm: jetzt.toISOString(),
bestaetigtUeber: ueber,
bestaetigtPdfSha256: frisch.pdfSha256,
fruehbeginn: privat && fruehbeginn === true,
fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '',
}
if (frisch.status === 'versendet') patch.status = 'bestaetigt'
return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true }
})
if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell)
return aktuell
}
/**
* Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre.
* Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre).
* Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'.
*/
export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) {
pruefeZeitpunkt(jetzt)
return withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null
const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde })
if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen')
if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht')
if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar')
if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist')
return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }))
})
}
+31
View File
@@ -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.' })
}
+27
View File
@@ -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.'
+167
View File
@@ -0,0 +1,167 @@
/**
* Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne
* Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der
* Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge.
* Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF).
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../../config.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { lesePdf } from './ablage.js'
import { fruehesterBeginn } from './auftragSicht.js'
import { AGB_FASSUNG } from './konstanten.js'
import { FRUEHBEGINN_TEXT } from './texte.js'
import { hashToken } from './token.js'
// server/services/auftraege/ -> drei Ebenen nach oben = Repo
const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url))
let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS
const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/
/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */
export function setzeAgbVerzeichnisFuerTests(verzeichnis) {
agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS
}
export function agbPdfPfad(fassung = AGB_FASSUNG) {
if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung')
return path.join(agbVerzeichnis, `agb-${fassung}.pdf`)
}
// Betreff-Zusaetze der K31 (die Vorlage selbst bleibt fuer alle Faelle gleich)
const ERINNERUNG_PRAEFIX = 'Erinnerung: '
const KOPIE_PRAEFIX = 'Kopie: '
const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function zeitpunkt(iso) {
const datum = new Date(iso)
if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig')
return datum
}
const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso)))
const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}`
const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents))
const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()
export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) {
const kunde = auftrag.kunde ?? {}
return {
template: 'K31',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
datum: tagText(auftrag.gesendetAm),
betrag: preisText(auftrag),
// die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird
link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`,
gueltig_bis: tagText(auftrag.tokenGueltigBis),
},
blocks: {
erinnerung: Boolean(erinnerung),
privat: kunde.kundenart === 'privat' && !teamkopie,
festpreis: auftrag.art === 'festpreis',
aufwand: auftrag.art === 'aufwand',
teamkopie: Boolean(teamkopie),
kundenmail: !teamkopie,
},
}
}
function frueherBeginn(auftrag) {
const privat = auftrag.kunde?.kundenart === 'privat'
const tag = fruehesterBeginn(auftrag)
return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' }
}
export function k32Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'K32',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
fruehester_beginn: f.beginnText,
fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '',
},
blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne },
}
}
const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' }
export function t22Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'T22',
values: {
kunde: kunde.firma || kunde.name || kunde.email || '',
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
ueber: UEBER[auftrag.bestaetigtUeber] ?? '',
woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`,
fruehester_beginn: f.beginnText,
},
blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne },
}
}
/**
* K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler
* der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler.
*/
export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) {
const adresse = empfaenger(auftrag)
if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung')
if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')
const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei)
const fassung = auftrag.agbFassung || AGB_FASSUNG
let agbPdf
try {
agbPdf = await readFile(agbPdfPfad(fassung))
} catch (err) {
throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.name}`)
}
await sendCustomerMail(adresse, {
...k31Mail(auftrag, { token, erinnerung }),
subjectPrefix: erinnerung ? ERINNERUNG_PRAEFIX : '',
attachments: [
{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' },
{ filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' },
],
bccTeam: false,
})
// Der Grund steht nicht im Protokoll: Meldungen des Mailservers nennen oft die Empfaengeradresse
const kopie = await sendAdminNotice({ ...k31Mail(auftrag, { erinnerung, teamkopie: true }), subjectPrefix: `${KOPIE_PRAEFIX}${erinnerung ? ERINNERUNG_PRAEFIX : ''}` })
if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet`)
}
/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */
export async function sendeBestaetigtMails(auftrag) {
const adresse = empfaenger(auftrag)
if (!adresse) {
console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`)
} else {
try {
await sendCustomerMail(adresse, k32Mail(auftrag))
} catch (err) {
console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
try {
const t22 = await sendAdminNotice(t22Mail(auftrag))
if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet`)
} catch (err) {
console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Nummernvergabe der Auftragsbestätigungen: AB-JJJJ-NNNN, lückenlos je Berliner Kalenderjahr (Übersicht V4, L3).
* vergibNummer schreibt nichts und sperrt nicht selbst: der Aufrufer hält SPERRE_NUMMER (konstanten.js), und die Nummer
* ist erst mit dem Speichern der AB vergeben. Eine Nummer, die schon in Gebrauch ist, wird übersprungen.
*/
import { berlinDate } from '../finance/dates.js'
import { findAuftragByNummer, listAuftraege } from './auftragStore.js'
export function formatNummer(jahr, laufnummer) {
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 9999 || !Number.isInteger(laufnummer) || laufnummer < 1 || laufnummer > 9999) {
throw new RangeError('Ungültige Auftragsnummer.')
}
return `AB-${jahr}-${String(laufnummer).padStart(4, '0')}`
}
export async function vergibNummer(jetzt = new Date()) {
const jahr = Number(berlinDate(jetzt).slice(0, 4))
const vorhandene = await listAuftraege({ jahr })
let laufnummer = vorhandene.reduce((groesste, a) => Math.max(groesste, Number.isSafeInteger(a.laufnummer) ? a.laufnummer : 0), 0) + 1
while (await findAuftragByNummer(formatNummer(jahr, laufnummer))) laufnummer += 1
return { nummer: formatNummer(jahr, laufnummer), jahr, laufnummer }
}
+475
View File
@@ -0,0 +1,475 @@
import { readFileSync } from 'node:fs'
import * as fontkitModul from 'fontkit'
import PDFDocument from 'pdfkit'
import { euroDe } from '../finance/format.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { AuftragError } from './fehler.js'
import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from './texte.js'
import { aufwandText, zahlungsplanText } from './auftragSicht.js'
/**
* PDF der Auftragsbestätigung (A4, pdfkit): Schriften und Zeichenabdeckung, Daten (alle Texte fertig formatiert),
* Seitenaufteilung (rein) und Zeichnen. Aufbau der Datei: 1 Schriften, 2 Daten, 3 Layout, 4 Zeichnen, 5 erzeugeAuftragPdf.
*/
// ===== 1. Schriften, Logo, Zeichenabdeckung =====
const fontkit = fontkitModul.default ?? fontkitModul
const SCHRIFT_VERZEICHNIS = new URL('../../assets/fonts/', import.meta.url)
const LOGO_DATEI = new URL('../../../public/webklar-logo.png', import.meta.url)
const SCHRIFT_DATEIEN = Object.freeze({ regular: 'Figtree-Regular.ttf', semibold: 'Figtree-SemiBold.ttf' })
let geladen = null
/** { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont } – bei jedem Aufruf dasselbe Objekt. */
export function ladeSchriften() {
if (!geladen) {
const regular = readFileSync(new URL(SCHRIFT_DATEIEN.regular, SCHRIFT_VERZEICHNIS))
const semibold = readFileSync(new URL(SCHRIFT_DATEIEN.semibold, SCHRIFT_VERZEICHNIS))
geladen = Object.freeze({
regular,
semibold,
logo: readFileSync(LOGO_DATEI),
regularFont: fontkit.create(regular),
semiboldFont: fontkit.create(semibold),
})
}
return geladen
}
/** Hat der Codepunkt ein Glyph in Regular UND SemiBold? */
export function hatGlyph(codePoint) {
const { regularFont, semiboldFont } = ladeSchriften()
return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint)
}
const ERSATZ = new Map([
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
])
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */
export function bereinigeText(text, hatGlyphFn = hatGlyph) {
const roh = String(text ?? '')
.normalize('NFC')
.replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n')
.replace(/\t/g, ' ')
.replace(ENTFERNEN, '')
let aus = ''
for (const zeichen of roh) {
const cp = zeichen.codePointAt(0)
if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen
else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp)
else if (/\p{Zs}/u.test(zeichen)) aus += ' '
else if (/\p{M}/u.test(zeichen)) continue
else {
const basis = zeichen.normalize('NFD').codePointAt(0)
aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?'
}
}
return aus
}
/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */
export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) {
if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn)
if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn))
if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)]))
return wert
}
// ===== 2. Daten =====
export const PDF_BESCHRIFTUNG = Object.freeze({
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
})
const fehlt = (was) => new AuftragError(409, `Entwurf unvollständig: ${was}`)
const zeilen = (text) => String(text ?? '').split('\n').map((z) => z.trim()).filter(Boolean)
const tag = (iso) => formatDe(berlinDate(new Date(iso)))
function pruefe(auftrag, vorschau) {
if (!auftrag?.kunde || typeof auftrag.kunde !== 'object') throw fehlt('Kundendaten fehlen.')
if (!vorschau && !auftrag.nummer) throw fehlt('Nummer fehlt.')
if (!String(auftrag.titel ?? '').trim()) throw fehlt('Titel fehlt.')
if (!/^\d{4}-\d{2}-\d{2}$/.test(auftrag.auftragVom ?? '')) throw fehlt('Datum der Absprache fehlt.')
if (!Object.hasOwn(AUFTRAG_WEGE, auftrag.auftragWeg)) throw fehlt('Weg der Absprache fehlt.')
if (!AUFTRAG_ARTEN.includes(auftrag.art)) throw fehlt('Art des Auftrags fehlt.')
if (!ZAHLUNGSPLAN_ARTEN.includes(auftrag.zahlungsplan?.art)) throw fehlt('Zahlungsplan fehlt.')
if (auftrag.art === 'festpreis') {
if (!Array.isArray(auftrag.positionen) || auftrag.positionen.length === 0) throw fehlt('Positionen fehlen.')
const summe = auftrag.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe !== auftrag.summeCents) throw new AuftragError(409, 'Die Summe der Positionen stimmt nicht mit der Auftragssumme überein.')
} else if (!Number.isSafeInteger(auftrag.stundensatzCents) || auftrag.stundensatzCents <= 0) {
throw fehlt('Stundensatz fehlt.')
}
}
function empfaengerZeilen(kunde) {
const land = String(kunde.land ?? '').trim()
const ausland = land && !/^(deutschland|germany|de)$/i.test(land)
return [kunde.firma, kunde.name, kunde.strasse, [kunde.plz, kunde.ort].filter(Boolean).join(' '), ausland ? land : '']
.map((z) => String(z ?? '').trim())
.filter((z, i, alle) => z && alle.indexOf(z) === i)
}
/** Alle Texte und Zahlen des AB-PDFs, fertig formatiert (reine Funktion). */
export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) {
pruefe(auftrag, vorschau)
const b = PDF_BESCHRIFTUNG
const steuernummer = String(settings.steuernummer ?? '').trim()
const festpreis = auftrag.art === 'festpreis'
const privat = auftrag.kunde.kundenart === 'privat'
const termine = AB_TEXTE.termine({ start: auftrag.startAm ? formatDe(auftrag.startAm) : null, fertig: auftrag.fertigBis ? formatDe(auftrag.fertigBis) : null, privat })
const abschnitte = [
{ id: 'zahlung', ueberschrift: b.zahlung, text: zahlungsplanText(auftrag) },
...(termine ? [{ id: 'termine', ueberschrift: b.termine, text: termine }] : []),
{ id: 'mitwirkung', ueberschrift: b.mitwirkung, text: AB_TEXTE.mitwirkung },
]
return {
vorschau,
beschriftung: b,
titel: AB_TEXTE.titel,
nummer: auftrag.nummer || 'Entwurf',
datum: tag(auftrag.gesendetAm || jetzt),
kundennummer: String(auftrag.kunde.kundennummer ?? ''),
projekt: auftrag.titel.trim(),
absender: {
zeilen: [ABSENDER.name, ABSENDER.strasse, ABSENDER.plzOrt, `${b.telefon} ${ABSENDER.telefon}`, ABSENDER.email, ABSENDER.web, ...(steuernummer ? [`${b.steuernummer} ${steuernummer}`] : [])],
rueckzeile: `${ABSENDER.name} · ${ABSENDER.strasse} · ${ABSENDER.plzOrt}`,
},
empfaenger: empfaengerZeilen(auftrag.kunde),
einleitung: AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] }),
leistung: festpreis
? {
art: 'festpreis',
ueberschrift: b.leistung,
positionen: auftrag.positionen.map((p, i) => ({
nr: String(i + 1), beschreibung: p.beschreibung.trim(), menge: String(p.menge),
einzelpreis: euroDe(p.einzelpreisCents), summe: euroDe(p.menge * p.einzelpreisCents),
})),
gesamt: { label: b.gesamt, betrag: euroDe(auftrag.summeCents) },
aufwandText: null,
}
: { art: 'aufwand', ueberschrift: b.leistung, positionen: [], gesamt: null, aufwandText: aufwandText(auftrag) },
kleinunternehmer: AB_TEXTE.kleinunternehmer,
abschnitte,
agb: AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }),
ersetzt: ersetzt ? AB_TEXTE.ersetzt({ nummer: ersetzt.nummer, datum: formatDe(ersetzt.datum) }) : null,
gruss: AB_TEXTE.gruss,
widerruf: privat ? { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } : null,
fuss: {
bank: zeilen(settings.bankDetailsText),
kontakt: [`${ABSENDER.name} · vertreten durch ${ABSENDER.vertretenDurch}`, `${b.telefon} ${ABSENDER.telefon} · ${ABSENDER.email} · ${ABSENDER.web}`],
},
}
}
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung =====
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
const ZELLEN_RAND = 6
const STIL = Object.freeze({
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
})
const MASSE = Object.freeze({
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
fussHoehe: 82, folgeKopf: 40,
})
const SICHERHEIT = 1
export const BEREICHE = Object.freeze({
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
})
const atom = (id, typ, hoehe, extra = {}) => ({ id, typ, hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
/** Alle Bausteine der AB in Zeichenreihenfolge. messe(text, stil, breite) liefert die Texthöhe in pt. */
export function baueAtome(daten, messe) {
const b = daten.beschriftung
const B = INHALT_BREITE
const atome = []
const absatz = (id, text, extra = {}, stil = STIL.text) => atome.push(atom(id, 'absatz', messe(text, stil, B), { text, stil: stil === STIL.klein ? 'klein' : 'text', abstandDavor: 6, ...extra }))
const ueberschrift = (id, text) => atome.push(atom(id, 'ueberschrift', messe(text, STIL.ueberschrift, B) + 5, { text, abstandDavor: 14, haengtAnNaechstem: true }))
// Briefkopf (nur Seite 1): Logo, Absender, Anschrift, Angaben, Titel
const infoZeilen = [[b.nummer, daten.nummer], [b.datum, daten.datum], ...(daten.kundennummer ? [[b.kundennummer, daten.kundennummer]] : [])]
const adresseHoehe = messe(daten.empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite)
const blockEnde = MASSE.adresseY + Math.max(adresseHoehe, infoZeilen.length * MASSE.infoZeile)
const titelY = blockEnde + 22
const projektY = titelY + STIL.titel.groesse * 1.2 + 6
const projektHoehe = messe(`${b.projekt}: ${daten.projekt}`, STIL.projekt, B)
atome.push(atom('briefkopf', 'briefkopf', projektY + projektHoehe + 4, { haengtAnNaechstem: true, masse: { titelY, projektY, projektHoehe, infoZeilen } }))
absatz('einleitung', daten.einleitung, { abstandDavor: 8 })
const l = daten.leistung
ueberschrift('leistung', l.ueberschrift)
if (l.art === 'festpreis') {
const kopf = atom('tabellenkopf', 'tabellenkopf', MASSE.kopfHoehe, { haengtAnNaechstem: true, spalten: b.spalten })
atome.push(kopf)
l.positionen.forEach((p, i) => {
const text = messe(p.beschreibung, STIL.text, SPALTEN[1] - 2 * ZELLEN_RAND)
const eineZeile = messe('X', STIL.text, SPALTEN[1])
atome.push(atom(`zeile-${i + 1}`, 'zeile', Math.max(text, eineZeile) + 2 * ZELLEN_RAND, { zelle: p, kopf, haengtAnNaechstem: i === l.positionen.length - 1 }))
})
atome.push(atom('summe', 'summe', MASSE.summeHoehe, { label: l.gesamt.label, betrag: l.gesamt.betrag, haengtAnNaechstem: true }))
} else {
atome.push(atom('aufwand', 'absatz', messe(l.aufwandText, STIL.text, B - 2 * MASSE.kastenRand) + 2 * MASSE.kastenRand, { text: l.aufwandText, stil: 'text', kasten: true, haengtAnNaechstem: true }))
}
absatz('kleinunternehmer', daten.kleinunternehmer, { abstandDavor: 8 }, STIL.klein)
for (const a of daten.abschnitte) {
ueberschrift(`${a.id}-kopf`, a.ueberschrift)
absatz(a.id, a.text, { abstandDavor: 0 })
}
absatz('agb', daten.agb, { abstandDavor: 12 })
if (daten.ersetzt) absatz('ersetzt', daten.ersetzt, { abstandDavor: 8 })
absatz('gruss', daten.gruss, { abstandDavor: 14 })
if (daten.widerruf) {
const { belehrung, formular } = daten.widerruf
atome.push(atom('widerruf-titel', 'ueberschrift', messe(belehrung.titel, STIL.titel, B) + 8, { text: belehrung.titel, stil: 'titel', umbruchDavor: true, haengtAnNaechstem: true }))
belehrung.abschnitte.forEach((a, i) => {
ueberschrift(`widerruf-${i + 1}-kopf`, a.ueberschrift)
a.absaetze.forEach((t, k) => absatz(`widerruf-${i + 1}-${k + 1}`, t, { abstandDavor: k === 0 ? 0 : 5 }))
})
atome.push(atom('formular-titel', 'ueberschrift', messe(formular.titel, STIL.ueberschrift, B) + 5, { text: formular.titel, abstandDavor: 20, haengtAnNaechstem: true }))
absatz('formular-einleitung', formular.einleitung, { abstandDavor: 0, haengtAnNaechstem: true }, STIL.klein)
formular.zeilen.forEach((t, i) => absatz(`formular-${i + 1}`, t, { abstandDavor: 8, haengtAnNaechstem: i < formular.zeilen.length - 1 }))
}
return atome
}
/** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */
export function paginiere(atome, bereiche = BEREICHE) {
const seiten = []
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
let seite
let y
const neueSeite = () => {
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
seiten.push(seite)
y = bereich.oben
}
const frisch = () => seite.posten.every((p) => p.wiederholt)
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
neueSeite()
for (let i = 0; i < atome.length; ) {
let j = i
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
const kette = atome.slice(i, j + 1)
i = j + 1
if (kette[0].umbruchDavor && !frisch()) neueSeite()
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
for (const a of kette) {
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
y += a.kopf.hoehe
}
y += abstand(a)
seite.posten.push({ atom: a, y, wiederholt: false })
y += a.hoehe
}
}
return seiten
}
// ===== 4. Zeichnen mit pdfkit =====
/** Zeichnen mit pdfkit. Alle Texte stehen fertig in `daten`; Höhen und Seiten kommen aus dem Abschnitt Layout. */
const SCHRIFT_NAMEN = { regular: 'Figtree', semibold: 'Figtree-SemiBold' }
const RECHTS = SEITE.breite - SEITE.rechts
const SPALTEN_X = SPALTEN.reduce((liste, breite) => [...liste, liste[liste.length - 1] + breite], [SEITE.links]).slice(0, SPALTEN.length)
function setzeStil(doc, stil) {
return doc.font(SCHRIFT_NAMEN[stil.schrift]).fontSize(stil.groesse).fillColor(stil.farbe)
}
function schreibe(doc, text, x, y, stil, optionen = {}) {
setzeStil(doc, stil).text(text, x, y, { lineGap: stil.lineGap, ...optionen })
}
const linie = (doc, x1, y, x2, farbe = FARBEN.linie, breite = 0.5) => {
doc.save().lineWidth(breite).strokeColor(farbe).moveTo(x1, y).lineTo(x2, y).stroke().restore()
}
function zeichneBriefkopf(doc, daten, atom, y0, assets) {
const b = daten.beschriftung
const m = atom.masse
doc.image(assets.logo, SEITE.links, y0, { width: 34 })
schreibe(doc, 'WEBklar', SEITE.links + 43, y0 + 6, { schrift: 'semibold', groesse: 20, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.4, lineBreak: false })
const [name, ...rest] = daten.absender.zeilen
const blockX = RECHTS - 190
schreibe(doc, name, blockX, y0, { ...STIL.klein, schrift: 'semibold', farbe: FARBEN.tinte }, { width: 190, align: 'right' })
schreibe(doc, rest.join('\n'), blockX, y0 + 13, STIL.klein, { width: 190, align: 'right' })
schreibe(doc, daten.absender.rueckzeile, SEITE.links, y0 + MASSE.rueckY, { ...STIL.klein, groesse: 7 }, { width: MASSE.adresseBreite, lineBreak: false, underline: true })
schreibe(doc, daten.empfaenger.join('\n'), SEITE.links, y0 + MASSE.adresseY, STIL.adresse, { width: MASSE.adresseBreite })
m.infoZeilen.forEach(([label, wert], i) => {
const zeileY = y0 + MASSE.adresseY + i * MASSE.infoZeile
schreibe(doc, label, MASSE.infoX, zeileY + 1, STIL.klein, { width: 90, lineBreak: false })
schreibe(doc, wert, MASSE.infoX + 92, zeileY, { ...STIL.text, schrift: 'semibold' }, { width: RECHTS - MASSE.infoX - 92, align: 'right' })
})
schreibe(doc, daten.titel, SEITE.links, y0 + m.titelY, STIL.titel, { width: INHALT_BREITE })
schreibe(doc, `${b.projekt}: ${daten.projekt}`, SEITE.links, y0 + m.projektY, STIL.projekt, { width: INHALT_BREITE })
}
function zeichneAtom(doc, daten, atom, y, assets) {
switch (atom.typ) {
case 'briefkopf':
return zeichneBriefkopf(doc, daten, atom, y, assets)
case 'ueberschrift':
return schreibe(doc, atom.text, SEITE.links, y, atom.stil === 'titel' ? STIL.titel : STIL.ueberschrift, { width: INHALT_BREITE })
case 'absatz': {
const stil = STIL[atom.stil]
if (atom.kasten) {
doc.save().roundedRect(SEITE.links, y, INHALT_BREITE, atom.hoehe, 4).lineWidth(0.5).fillAndStroke(FARBEN.flaeche, FARBEN.linie).restore()
return schreibe(doc, atom.text, SEITE.links + MASSE.kastenRand, y + MASSE.kastenRand, stil, { width: INHALT_BREITE - 2 * MASSE.kastenRand })
}
return schreibe(doc, atom.text, SEITE.links, y, stil, { width: INHALT_BREITE })
}
case 'tabellenkopf': {
doc.save().rect(SEITE.links, y, INHALT_BREITE, atom.hoehe).fill(FARBEN.flaeche).restore()
atom.spalten.forEach((text, i) => {
const rechts = i >= 2
schreibe(doc, text, SPALTEN_X[i] + (i === 0 ? 0 : ZELLEN_RAND), y + 7, STIL.tabellenkopf, { width: SPALTEN[i] - (i === 0 ? ZELLEN_RAND : 2 * ZELLEN_RAND), align: rechts ? 'right' : 'left', lineBreak: false })
})
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS, FARBEN.linieDunkel, 0.75)
}
case 'zeile': {
const z = atom.zelle
const oben = y + ZELLEN_RAND
schreibe(doc, z.nr, SPALTEN_X[0], oben, STIL.text, { width: SPALTEN[0] - ZELLEN_RAND })
schreibe(doc, z.beschreibung, SPALTEN_X[1] + ZELLEN_RAND, oben, STIL.text, { width: SPALTEN[1] - 2 * ZELLEN_RAND })
schreibe(doc, z.menge, SPALTEN_X[2], oben, STIL.text, { width: SPALTEN[2] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.einzelpreis, SPALTEN_X[3], oben, STIL.text, { width: SPALTEN[3] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.summe, SPALTEN_X[4], oben, STIL.text, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right' })
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS)
}
case 'summe': {
linie(doc, SEITE.links, y, RECHTS, FARBEN.tinte, 0.75)
const labelBreite = SPALTEN_X[4] - SEITE.links - ZELLEN_RAND
schreibe(doc, atom.label, SEITE.links, y + 9, STIL.summe, { width: labelBreite, align: 'right', lineBreak: false })
return schreibe(doc, atom.betrag, SPALTEN_X[4], y + 9, STIL.summe, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right', lineBreak: false })
}
default:
throw new Error(`Unbekannter Baustein: ${atom.typ}`)
}
}
function zeichneFolgeKopf(doc, daten, assets) {
const y = SEITE.oben
doc.image(assets.logo, SEITE.links, y, { width: 16 })
schreibe(doc, 'WEBklar', SEITE.links + 22, y + 2, { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.2, lineBreak: false })
schreibe(doc, `${daten.titel} ${daten.nummer}`, RECHTS - 250, y + 4, STIL.klein, { width: 250, align: 'right', lineBreak: false })
linie(doc, SEITE.links, y + 24, RECHTS)
}
function zeichneFuss(doc, daten, nummer, gesamt) {
const b = daten.beschriftung
const y = SEITE.hoehe - MASSE.fussHoehe
linie(doc, SEITE.links, y, RECHTS)
const spalte = (x, breite, label, zeilen) => {
schreibe(doc, label, x, y + 8, STIL.fussLabel, { width: breite, lineBreak: false })
schreibe(doc, zeilen.join('\n'), x, y + 19, STIL.fuss, { width: breite, height: 46, ellipsis: true })
}
if (daten.fuss.bank.length) spalte(SEITE.links, 175, b.bank, daten.fuss.bank)
spalte(SEITE.links + 190, 225, b.kontakt, daten.fuss.kontakt)
schreibe(doc, `${b.seite} ${nummer} ${b.von} ${gesamt}`, RECHTS - 70, y + 8, STIL.fussLabel, { width: 70, align: 'right', lineBreak: false })
}
function zeichneWasserzeichen(doc, daten) {
const stil = { schrift: 'semibold', groesse: 110, lineGap: 0, farbe: FARBEN.entwurf }
setzeStil(doc, stil)
const breite = doc.widthOfString(daten.beschriftung.entwurf)
const mitteX = SEITE.breite / 2
const mitteY = SEITE.hoehe / 2
doc.save()
doc.fillOpacity(0.07)
doc.rotate(-35, { origin: [mitteX, mitteY] })
doc.text(daten.beschriftung.entwurf, mitteX - breite / 2, mitteY - (stil.groesse * 1.2) / 2, { lineBreak: false })
doc.restore()
schreibe(doc, daten.beschriftung.entwurfHinweis, SEITE.links, 22, { schrift: 'semibold', groesse: 8, lineGap: 0, farbe: FARBEN.entwurf }, { width: INHALT_BREITE, align: 'center', lineBreak: false })
}
/** Zeichnet alle Seiten; die erste Seite legt pdfkit selbst an. */
function zeichneAuftrag(doc, daten, seiten, assets) {
seiten.forEach((seite, index) => {
if (index > 0) doc.addPage()
if (daten.vorschau) zeichneWasserzeichen(doc, daten)
if (index > 0) zeichneFolgeKopf(doc, daten, assets)
for (const posten of seite.posten) zeichneAtom(doc, daten, posten.atom, posten.y, assets)
zeichneFuss(doc, daten, seite.nummer, seiten.length)
})
}
// ===== 5. Ablauf =====
async function ladeErsetzte(id) {
const { getAuftrag } = await import('./auftragStore.js')
const alt = await getAuftrag(id)
if (!alt?.nummer || !alt.gesendetAm) throw new AuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.')
return { nummer: alt.nummer, datum: berlinDate(new Date(alt.gesendetAm)) }
}
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {}) {
const alt = ersetzt !== undefined ? ersetzt : auftrag.ersetztId ? await ladeErsetzte(auftrag.ersetztId) : null
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt, ersetzt: alt }))
const assets = ladeSchriften()
const doc = new PDFDocument({
size: 'A4', margins: { top: 0, bottom: 0, left: 0, right: 0 }, lang: 'de-DE', displayTitle: true,
info: {
Title: `${daten.titel} ${daten.nummer}`, Author: 'WEBklar GbR', Subject: daten.projekt, Producer: 'WEBklar Kundenportal',
CreationDate: new Date(auftrag.gesendetAm || jetzt),
},
})
doc.registerFont('Figtree', assets.regular)
doc.registerFont('Figtree-SemiBold', assets.semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(teile)))
doc.on('error', reject)
})
fertig.catch(() => {}) // scheitert das Zeichnen zuerst, soll das verworfene Versprechen nichts auslösen
try {
const messe = (text, stil, breite) => {
setzeStil(doc, stil)
return doc.heightOfString(text, { width: breite, lineGap: stil.lineGap })
}
const seiten = paginiere(baueAtome(daten, messe), BEREICHE)
zeichneAuftrag(doc, daten, seiten, assets)
doc.end()
} catch (fehler) {
doc.destroy()
throw fehler
}
return fertig
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche,
* Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent.
* Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen.
*/
import { AuftragError } from './fehler.js'
import { LIMITS } from './konstanten.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
const TAG_MS = 86_400_000
const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max
/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */
export function kuerzen(text, max) {
if (text.length <= max) return text
let teil = text.slice(0, max - 1)
const letzter = teil.charCodeAt(teil.length - 1)
if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1)
return `${teil.trimEnd()}…`
}
/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */
export function stundenText(minuten) {
return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
}
/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */
export function gueltigerTag(ymd) {
const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (!treffer) return false
const [j, m, t] = treffer.slice(1).map(Number)
const pruef = new Date(Date.UTC(j, m - 1, t))
return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t
}
/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */
export function kalenderwoche(ymd) {
if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
const tag = new Date(`${ymd}T00:00:00Z`)
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS)
const jahr = donnerstag.getUTCFullYear()
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1
return { jahr, woche }
}
/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */
export function leistungszeitraumText(von, bis) {
if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.')
if (von === bis) return formatDe(von)
if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}`
return `${formatDe(von)}–${formatDe(bis)}`
}
/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */
function pruefeEingabePositionen(items, { leerErlaubt, praefix }) {
const leer = items === undefined || items === null
if (leer && leerErlaubt) return []
if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`)
}
const positionen = items.map((item, index) => {
const label = `Position ${index + 1}`
const beschreibung = typeof item?.description === 'string' ? item.description.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) {
throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) {
throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return {
schluessel: `${praefix}-${index + 1}`,
beschreibung,
menge: item.quantity,
einzelpreisCents: item.unitAmountCents,
betragCents: item.quantity * item.unitAmountCents,
}
})
if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) {
throw new AuftragError(400, 'Rechnungssumme zu hoch.')
}
return positionen
}
/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-<n>. */
export function festePositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' })
}
/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-<n>. */
export function zusatzPositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' })
}
/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */
export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) {
if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`)
}
positionen.forEach((p, index) => {
const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0
if (laenge < 1 || laenge > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
})
const summe = positionen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
return summe
}
const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '')
/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */
function aufwandZeile(einheit, satzCents, zeilentexte) {
const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : ''
const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}`
const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}`
const betragCents = Math.round((einheit.minuten * satzCents) / 60)
return {
schluessel: einheit.schluessel,
beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`,
menge: 1,
einzelpreisCents: betragCents,
betragCents,
}
}
/**
* Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu
* KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich.
*/
export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) {
if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null }
if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.')
for (const blatt of blaetter) {
if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) {
throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
}
const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id)))
const tage = []
for (const blatt of sortiert) {
let tag = tage.at(-1)
if (!tag || tag.datum !== blatt.datum) {
tag = { datum: blatt.datum, minuten: 0, texte: [] }
tage.push(tag)
}
tag.minuten += blatt.minuten
const text = vereinheitlicht(blatt.text)
if (text && !tag.texte.includes(text)) tag.texte.push(text)
}
const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte })
const wochenEinheit = (woche) => {
const texte = []
for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text)
const ww = String(woche.woche).padStart(2, '0')
return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte }
}
const einheiten = []
if (tage.length <= maxZeilen) {
einheiten.push(...tage.map(tagesEinheit))
} else {
const wochen = []
for (const tag of tage) {
const kw = kalenderwoche(tag.datum)
let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche)
if (!woche) {
woche = { ...kw, tage: [] }
wochen.push(woche)
}
woche.tage.push(tag)
}
let anzahl = tage.length
for (const woche of wochen) {
if (anzahl > maxZeilen && woche.tage.length > 1) {
anzahl -= woche.tage.length - 1
einheiten.push(wochenEinheit(woche))
} else einheiten.push(...woche.tage.map(tagesEinheit))
}
if (anzahl > maxZeilen) {
throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`)
}
}
return {
positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)),
minuten: tage.reduce((s, t) => s + t.minuten, 0),
von: tage[0].datum,
bis: tage.at(-1).datum,
}
}
/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */
export function auftragDatum(auftrag) {
if (auftrag.gesendetAm) {
const zeitpunkt = new Date(auftrag.gesendetAm)
if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt)
}
return auftrag.auftragVom || ''
}
const angegeben = (wert) => wert !== undefined && wert !== null
/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */
export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) {
const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {}
const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : ''
if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
if (angegeben(prozentEingabe) && angegeben(betragEingabe)) {
throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
}
let prozent = null
let betrag
if (angegeben(betragEingabe)) {
if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) {
throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
}
betrag = betragEingabe
} else {
prozent = angegeben(prozentEingabe) ? prozentEingabe
: auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent
: LIMITS.standardAnzahlungProzent
if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) {
throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
}
betrag = Math.round((auftrag.summeCents * prozent) / 100)
if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.')
}
const rest = auftrag.summeCents - bereitsBerechnetCents
if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
if (betrag > rest) {
throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
}
const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %`
const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}`
const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : ''
const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor
return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag }
}
// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen
export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }
/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */
export function schlussPositionen(auftrag, fruehere, zusatz = []) {
const positionenAb = auftrag.positionen || []
if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) {
throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`)
}
if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) {
throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`)
}
const abzuege = (fruehere || []).filter((f) => f.betragCents > 0)
if (abzuege.length > SCHLUSS_GRENZEN.abzuege) {
throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`)
}
const zeilen = positionenAb.map((p, i) => ({
schluessel: `ab-${i + 1}`,
beschreibung: p.beschreibung,
menge: p.menge,
einzelpreisCents: p.einzelpreisCents,
betragCents: p.menge * p.einzelpreisCents,
}))
zeilen.push(...zusatzPositionen(zusatz))
const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId))
for (const f of sortiert) {
zeilen.push({
schluessel: `abzug-${f.invoiceId}`,
beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`,
menge: 1,
einzelpreisCents: -f.betragCents,
betragCents: -f.betragCents,
})
}
const summe = zeilen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
return zeilen
}
@@ -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_<n>
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
}
+323
View File
@@ -0,0 +1,323 @@
import { config } from '../../config.js'
import { Query, getDocument, listDocuments } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { berlinDate } from '../finance/dates.js'
import { listInvoices, mapInvoice } from '../finance/stripeFinance.js'
import { kindLabel } from '../projectKinds.js'
import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
import { AuftragError } from './fehler.js'
import { auftraegeAktiv } from './schalter.js'
import { auftragFuerTeam } from './auftragSicht.js'
import { listAuftraege, juengsteAufwandAb } from './auftragStore.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js'
/**
* Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen
* dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben.
*/
// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie
const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'])
const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0)
const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60)
// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung
// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander
const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0)
const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten)
/**
* Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit
* der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der
* Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe);
* stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null).
*/
export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) {
const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status))
const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis')
const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id))
const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null
const aufwandAb = juengsteAufwandAb(gueltig)
const aufwand = aufwandAb
? {
stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS,
schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null,
}
: null
const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS
let berechnetCents = 0
let bezahltCents = 0
let aufFestpreisCents = 0
if (stripeOk) {
for (const { invoice, auftragId } of rechnungen) {
if (!FINALISIERT.has(invoice.status)) continue
berechnetCents += invoice.totalCents
bezahltCents += invoice.paidCents
// nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene
if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents
}
}
const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents)
let minuten = 0
let aboMinuten = 0
let offeneMinuten = 0
const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete)
const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag ''
for (const blatt of blaetter) {
if (abrechenbar(blatt)) {
minuten += blatt.minuten
addiere(minutenJeTag, blatt.datum, blatt.minuten)
if (!abgerechnet.has(blatt.id)) {
offeneMinuten += blatt.minuten
addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten)
}
} else if (blatt.aboZeit && !blatt.kommentar) {
aboMinuten += blatt.minuten
}
}
return {
vereinbart: { festpreisCents, aufwand },
berechnetCents,
bezahltCents,
offenCents,
zeit: {
minuten,
aboMinuten,
offeneMinuten,
satzCents,
wertCents: tageswert(minutenJeTag, satzCents),
offenerWertCents: tageswert(offeneMinutenJeTag, satzCents),
},
}
}
/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */
export function rechneOhneProjekt(rechnungen) {
const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status))
return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length }
}
// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js
// haelt seine Optionen privat, daher hier eine eigene Konstante
const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 }
const EINZELN_PARALLEL = 10
// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_<n>) passen auch
const STRIPE_RECHNUNG = /^in_\w{1,33}$/
/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */
async function leseEinzeln(invoiceId) {
try {
const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN)
return mapInvoice(raw, { today: berlinDate() })
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
throw err
}
}
/**
* Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden-
* Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig.
* Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status.
*/
export async function ladeStripeRechnungen(projektRechnungen) {
try {
const alle = await listInvoices()
const nachId = new Map(alle.map((invoice) => [invoice.id, invoice]))
const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id))
for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) {
const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln))
for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice)
}
return { ok: true, alle, nachId }
} catch (err) {
console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return { ok: false, alle: [], nachId: new Map() }
}
}
/** Dokument lesen; unbekannt -> AuftragError 404 "<was> nicht gefunden." */
async function ladeDokument(collection, id, was) {
try {
return await getDocument(collection, id)
} catch (err) {
if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`)
throw err
}
}
const kundeAnsicht = (doc) => ({
id: doc.$id,
name: String(doc.companyName || '').trim() || String(doc.name || '').trim(),
kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '',
})
/** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */
async function ladeTickets(ids) {
const nachId = new Map()
for (let i = 0; i < ids.length; i += 100) {
const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)])
for (const ticket of seite) nachId.set(ticket.$id, ticket)
}
return nachId
}
/**
* Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je
* Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt.
*/
async function ladeZeitDaten(kundenId, ticketIds) {
if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() }
const [tickets, blaetter, abgerechnet] = await Promise.all([
ladeTickets(ticketIds),
ladeArbeitsblaetter(ticketIds),
abgerechneteArbeitsblaetter(kundenId),
])
const nachTicket = new Map()
for (const blatt of blaetter) {
if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, [])
nachTicket.get(blatt.ticketId).push(blatt)
}
return { tickets, nachTicket, abgerechnet }
}
/** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */
const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))]
/**
* Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von
* ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail.
*/
function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) {
const rechnungen = prs
.map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) }))
.filter((eintrag) => eintrag.invoice)
const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? [])
return {
konto: {
projektId: projekt.$id,
name: projekt.projectName || '',
projektTyp: kindLabel(projekt.kind),
tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })),
zeitquelle,
...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }),
},
rechnungen,
blaetter,
}
}
/**
* Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle
* Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus
* VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB).
*/
export async function projektkontenFuerKunde(kundenId) {
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [projekte, auftraege, prs] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
aktiv ? listAuftraege({ kundenId }) : [],
listProjektRechnungen({ kundenId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const mitGeld = new Set([
...prs.map((eintrag) => eintrag.projektId),
...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId),
])
const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id)))
const eintraege = sichtbar.map((projekt) => {
const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id)
return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) }
})
const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))])
const konten = eintraege.map(
({ projekt, abs, zeitquelle }) =>
baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto
)
// Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen)
const kandidaten = new Map()
for (const invoice of stripeDaten.alle) {
if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice)
}
for (const eintrag of prs) {
const invoice = stripeDaten.nachId.get(eintrag.invoiceId)
if (invoice) kandidaten.set(invoice.id, invoice)
}
const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id))
const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId))
const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id)))
return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok }
}
const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id)
/**
* Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht
* abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false.
*/
export async function projektkontoDetail(projektId) {
const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt')
if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.')
const kundenId = projekt.customerId
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [abs, prs] = await Promise.all([
aktiv ? listAuftraege({ kundenId, projektId }) : [],
listProjektRechnungen({ kundenId, projektId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const zeitquelle = zeitquelleVon(projekt, abs)
const zeit = await ladeZeitDaten(kundenId, zeitquelle)
const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit })
return {
konto,
auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)),
rechnungen: rechnungen
.map(({ art, auftragId, invoice }) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
createdDate: invoice.createdDate,
totalCents: invoice.totalCents,
paidCents: invoice.paidCents,
art,
auftragId,
}))
.sort(neuesteZuerst),
offeneArbeitsblaetter: blaetter
.filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id))
.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)),
stripeOk: stripeDaten.ok,
}
}
/**
* Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer
* oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden.
*/
export async function abgerechnetFuerTicket(ticketId) {
let ticket
try {
ticket = await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err?.status === 404) return {}
throw err
}
if (!ticket.customerId) return {}
const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId)
if (abgerechnet.size === 0) return {}
const ergebnis = {}
for (const blatt of await ladeArbeitsblaetter([ticketId])) {
const eintrag = abgerechnet.get(blatt.id)
if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf'
}
return ergebnis
}
+418
View File
@@ -0,0 +1,418 @@
/**
* 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, juengsteAufwandAb } from './auftragStore.js'
import { getProjektRechnung, 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 finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */
export async function setzeAbAbgerechnet(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' })
})
}
/** 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<blattId, { invoiceId, rechnungsnummer }>. 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 juengsteAufwandAb(liste)
}
/** 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 })
}
const AB_UNGUELTIG = 'Die Auftragsbestätigung ist nicht mehr gültig (storniert, widerrufen, ersetzt oder schon abgerechnet). Der Entwurf wird nicht gesendet.'
/**
* Letzte Pruefung vor dem Senden eines Anzahlungs- oder Schluss-Entwurfs (Aufrufer haelt sperreRechnung des Kunden):
* die AB muss noch versendet oder bestaetigt sein, bei einer Schlussrechnung darf keine andere Schlussrechnung
* festgeschrieben sein, und bei einer Anzahlung darf die Summe aller festgeschriebenen Rechnungen zur AB plus diese die
* AB-Summe nicht uebersteigen (zwei Entwuerfe zaehlen beim Anlegen nicht mit). Sonst AuftragError 409; nichts wird
* geschrieben ausser der Selbstheilung von ladeFruehere. Die AB kommt aus dem Projektdatensatz (nach umhaengen die neue),
* ersatzweise aus den Stripe-Metadaten; ohne AB gibt es nichts zu pruefen.
*/
export async function pruefeVorSenden({ invoiceId, art, auftragIdMetadaten = '', totalCents }) {
if (!AB_RECHNUNGS_ARTEN.includes(art)) return
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return
const ab = await getAuftrag(auftragId)
if (!ab || !['versendet', 'bestaetigt'].includes(ab.status)) throw new AuftragError(409, AB_UNGUELTIG)
const andere = (await ladeFruehere(ab.id)).filter((f) => f.invoiceId !== invoiceId && FINALISIERT.includes(f.status))
if (art === 'schluss' && andere.some((f) => f.art === 'schluss')) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon eine Schlussrechnung. Der Entwurf wird nicht gesendet.')
}
// Eine Schlussrechnung darf mit Nachtraegen ueber der AB-Summe liegen: die Obergrenze gilt nur fuer die Anzahlung
if (art === 'anzahlung') {
const berechnet = andere.reduce((summe, f) => summe + f.betragCents, 0)
if (berechnet + totalCents > ab.summeCents) {
throw new AuftragError(409, `Die Summe der Rechnungen würde die Auftragssumme von ${euroDe(ab.summeCents)} übersteigen (bereits berechnet ${euroDe(berechnet)}). Der Entwurf wird nicht gesendet.`)
}
}
}
/** Gibt es zur AB (aus dem Projektdatensatz der Rechnung, ersatzweise aus den Stripe-Metadaten) einen Entwurf der Schlussrechnung? */
export async function hatSchlussEntwurf({ invoiceId, auftragIdMetadaten = '' }) {
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return false
return (await ladeFruehere(auftragId)).some((f) => f.art === 'schluss' && f.status === 'draft')
}
+21
View File
@@ -0,0 +1,21 @@
import { config } from '../../config.js'
/**
* Pilot-Schalter der Aufträge (AUFTRAEGE_ENABLED = off (Vorgabe) | pilot | on). Betrifft nur die AB-Funktionen und
* die Rechnungsarten anzahlung/schluss; Projektkonto und die Arten frei, festbetrag, aufwand laufen immer.
* Liest config.auftraege bei jedem Aufruf.
*/
/**
* Darf dieser Kunde Auftragsbestätigungen nutzen? `kunde`: Kundendokument oder { email }. on = alle; pilot = Liste
* enthält '*' oder die (klein geschriebene, getrimmte) E-Mail-Adresse steht in AUFTRAEGE_PILOT_EMAILS (eine leere
* Liste lässt niemanden durch); alles andere, auch ein unbekannter Modus, gilt als off.
*/
export function auftraegeAktiv(kunde) {
const { mode, pilotEmails } = config.auftraege
if (mode === 'on') return true
if (mode !== 'pilot') return false
if (pilotEmails.includes('*')) return true
const adresse = String(kunde?.email || '').trim().toLowerCase()
return adresse !== '' && pilotEmails.includes(adresse)
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Aufträge und Rechnungen: Appwrite-Schema der Collections auftraege und projektRechnungen sowie der Zusatzfelder
* (Übersicht V1). Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor
* useTestEnv(), ein früh geladenes config.js würde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs
* stehen in config.collections.<Name>; scripts/auftraege-setup.mjs legt alles an.
*
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
* Index: { key, type: 'key', attributes: [...] }
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurück, daher immer mit new Date() lesen.
*/
const str = (key, size) => ({ key, type: 'string', size, required: false })
const int = (key) => ({ key, type: 'integer', required: false })
const bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
export const AUFTRAEGE_COLLECTIONS = {
auftraege: {
attributes: [
str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20),
str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200),
str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000),
int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'),
str('zahlungsplanJson', 1000), int('zahlungszielTage'),
str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10),
str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64),
dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500),
str('tokenHash', 64), dt('tokenGueltigBis'),
dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64),
bool('fruehbeginn'), str('fruehbeginnFassung', 20),
str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'),
str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'),
],
// idx_nummer bewusst kein Unique-Index: Entwürfe haben nummer ''. Die Eindeutigkeit stellt vergibNummer unter
// SPERRE_NUMMER her (L3).
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'),
index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')],
},
// Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_<n>)
projektRechnungen: {
attributes: [
str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20),
int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'),
str('erstelltVon', 255), dt('erstelltAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')],
},
}
// Zusätzliche Felder bestehender Collections (Schlüssel = Name in config.collections)
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
// Datumsfelder je Collection, für startAppwriteMock({ datetimes }) (Appwrite liefert sie mit "+00:00")
export const AUFTRAEGE_DATETIMES = Object.fromEntries(
Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]),
)
+176
View File
@@ -0,0 +1,176 @@
/**
* Wortlaute der Auftragsbestätigung (AB), der Bestätigung durch den Kunden und der Texte für Privatpersonen (Frühbeginn,
* Widerrufsbelehrung, Muster-Widerrufsformular). Reine Konstanten und Funktionen ohne Zugriff auf Appwrite, Stripe oder die
* Konfiguration. Endfassung der Rechtsrunde vom 06.10.2026 (Strang L7); die Zusammenstellung zur Freigabe liegt in
* /root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md.
*
* Alle Kundentexte stehen in der Sie-Form und ohne Gedankenstriche im Fließtext. Widerrufsbelehrung und Muster-Widerrufsformular sind
* die Muster der Anlagen 1 und 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB für einen Dienstleistungsvertrag (Quellen:
* docs/rechtliches/quellen/anlage-1.txt und anlage-2.txt) und gleich der Belehrung für die Bestellung einer Funktion
* (widerrufsbelehrungFunktion in services/termine/rechtstexte.js); test/auftraegeRechtstexte.test.js prüft beides.
* Die Frühbeginn-Erklärung folgt den Erklärungen der Funktionen: § 356 Abs. 5 Nr. 2 BGB (Erlöschen), § 357a Abs. 2 BGB (Wertersatz).
*
* Ändert sich ein Wortlaut nach der Auslieferung, bekommt TEXTE_FASSUNG ein neues Datum. Die Fassung steht im Nachweis jeder
* Auftragsbestätigung (texteFassung); die Dateien früherer Fassungen unter public/rechtliches/ bleiben unverändert.
*/
import { AUFTRAG_WEGE, LIMITS } from './konstanten.js'
export const TEXTE_FASSUNG = '2026-10-06'
export const ABSENDER = { name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler',
telefon: '0176 23726355', email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein' }
// ---------------------------------------------------------------------------------------------------------- Hilfen
/** Pflichtwert: fehlt er, entsteht ein Fehler statt eines Textes mit „undefined“ (eine Auftragsbestätigung darf nie unvollständig entstehen). */
function pflicht(wert, name) {
if (wert === undefined || wert === null || String(wert).trim() === '') throw new TypeError(`Text der Auftragsbestätigung: ${name} fehlt.`)
return String(wert).trim()
}
/** Klartext der Absprache: der Schlüssel aus AUFTRAG_WEGE („telefon“) wird zu „telefonisch“, ein fertiger Klartext bleibt, ohne Angabe entfällt er. */
function wegText(weg) {
const w = typeof weg === 'string' ? weg.trim() : ''
if (w === '') return ''
return Object.hasOwn(AUFTRAG_WEGE, w) ? AUFTRAG_WEGE[w] : w
}
/** Zahlungsziel als Satzteil: 0 = sofort, sonst „innerhalb von n Tagen nach Rechnungsdatum“; fehlend oder ungültig = Vorgabe (14 Tage). */
function zielText(ziel) {
const n = typeof ziel === 'number' ? ziel : Number.parseInt(String(ziel), 10)
const tage = Number.isInteger(n) && n >= 0 ? n : LIMITS.standardZahlungszielTage
if (tage === 0) return 'sofort nach Rechnungsstellung'
return `innerhalb von ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} nach Rechnungsdatum`
}
/** Zahl mit Dezimalkomma (33.5 wird „33,5“). */
const zahl = (wert) => String(wert).replace('.', ',')
const PRIVAT_BEGINN = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.'
// ---------------------------------------------------------------------------------------------------------- Auftragsbestätigung
export const AB_TEXTE = {
titel: 'Auftragsbestätigung',
einleitung: ({ datum, weg } = {}) => {
const art = wegText(weg)
return `Vielen Dank für Ihren Auftrag vom ${pflicht(datum, 'datum')}${art ? ` (${art})` : ''}. Wir bestätigen ihn wie folgt:`
},
aufwand: ({ satz, schaetzung } = {}) => {
const saetze = [
`Wir rechnen nach Aufwand ab: ${pflicht(satz, 'satz')} pro Stunde, erfasst je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet.`,
'Wirkt eine weitere Person unterstützend mit, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).',
]
if (schaetzung !== undefined && schaetzung !== null && String(schaetzung).trim() !== '') {
const stunden = String(schaetzung).trim()
saetze.push(`Der geschätzte Umfang beträgt etwa ${stunden} ${stunden === '1' ? 'Stunde' : 'Stunden'}; die Schätzung ist unverbindlich.`)
saetze.push('Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.')
}
return saetze.join(' ')
},
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
zahlungsplan: {
nach_abnahme: ({ ziel } = {}) => `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${zielText(ziel)}.`,
anzahlung: ({ prozent, anzahlung, rest, ziel } = {}) =>
`Anzahlung ${zahl(pflicht(prozent, 'prozent'))} % (${pflicht(anzahlung, 'anzahlung')}) nach Auftragsbestätigung, `
+ `Rest (${pflicht(rest, 'rest')}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`,
monatlich: ({ ziel } = {}) => `Wir stellen den angefallenen Aufwand monatlich in Rechnung; zahlbar ${zielText(ziel)}.`,
frei: ({ text, ziel } = {}) => {
const t = pflicht(text, 'text')
return `${t}${/[.!?]$/.test(t) ? '' : '.'} Zahlbar jeweils ${zielText(ziel)}.`
},
},
termine: ({ start, fertig, privat = false } = {}) => {
const teile = []
if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`)
else if (start) teile.push(`Beginn: ${start}.`)
else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`)
if (privat) teile.push(PRIVAT_BEGINN)
return teile.length > 0 ? teile.join(' ') : null
},
mitwirkung:
'Mitwirkung und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen. '
+ 'Bitte stellen Sie uns die benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.',
agb: ({ fassung } = {}) =>
`Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${pflicht(fassung, 'fassung')}, die dieser Bestätigung beiliegen.`,
ersetzt: ({ nummer, datum } = {}) => `Diese Auftragsbestätigung ersetzt ${pflicht(nummer, 'nummer')} vom ${pflicht(datum, 'datum')}.`,
gruss: 'Mit freundlichen Grüßen',
}
// ---------------------------------------------------------------------------------------------------------- Bestätigung und Frühbeginn
/** Häkchen auf der Bestätigungsseite und im Kundenportal (Wortlaut der Spezifikation; Alternative mit Bezug auf die AGB: Entscheidung E2 im Freigabe-Dokument). */
export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
/** Fassung der Frühbeginn-Erklärung; steht im Nachweis der Bestätigung (fruehbeginnFassung). */
export const FRUEHBEGINN_FASSUNG = '2026-10-06'
/** Zweites, freiwilliges Häkchen für Privatpersonen: Verlangen (§ 357a Abs. 2 Nr. 1 BGB), Zustimmung und Kenntnis (§ 356 Abs. 5 Nr. 2 BGB). */
export const FRUEHBEGINN_TEXT =
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Ausführung dieses Projektauftrags beginnt. '
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
// ---------------------------------------------------------------------------------------------------------- Widerrufsbelehrung und Formular
const KONTAKT = `${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, Telefon: ${ABSENDER.telefon}, E-Mail: ${ABSENDER.email}`
/** Muster der Anlage 1 für einen Dienstleistungsvertrag; Lücken: Frist „des Vertragsabschlusses.“, Kontaktangaben, Widerrufsfunktion, Wertersatz. */
export const WIDERRUFSBELEHRUNG_PROJEKT = {
titel: 'Widerrufsbelehrung',
abschnitte: [
{
ueberschrift: 'Widerrufsrecht',
absaetze: [
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.',
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${KONTAKT}) mittels einer eindeutigen Erklärung `
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. '
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.',
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
],
},
{
ueberschrift: 'Folgen des Widerrufs',
absaetze: [
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
+ '(mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, '
+ 'günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, '
+ 'an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. '
+ 'Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, '
+ 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, '
+ 'der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, '
+ 'bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.',
],
},
],
}
/** Muster der Anlage 2; „–“ ist das Aufzählungszeichen des Musters, die letzte Zeile gehört zum Muster. */
export const WIDERRUFSFORMULAR = {
titel: 'Muster-Widerrufsformular',
einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
zeilen: [
`– An ${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, E-Mail: ${ABSENDER.email}:`,
'– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
'– Bestellt am (*)/erhalten am (*)',
'– Name des/der Verbraucher(s)',
'– Anschrift des/der Verbraucher(s)',
'– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)',
'– Datum',
'(*) Unzutreffendes streichen.',
],
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen),
* gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand.
* Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js).
*/
import { createHash, randomBytes } from 'node:crypto'
import { TOKEN_GUELTIG_TAGE } from './konstanten.js'
const TAG_MS = 86_400_000
const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
export function neuerToken(jetzt = new Date()) {
if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein')
const token = randomBytes(32).toString('base64url')
return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) }
}
/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */
export function hashToken(token) {
return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null
}
+3 -1
View File
@@ -59,7 +59,8 @@ const visibleToCustomer = (raw) => ISSUED.has(raw.status) && (raw.total ?? 0) >
/**
* Stripe-Rechnung in der Form des Kunden-Tabs (wie sanitizeInvoice fuer InvoiceNinja, Betraege in Euro). Offen sind
* "open" und "uncollectible" (Stripe nimmt die Zahlung weiter an); ueberfaellig, wenn die Faelligkeit vor dem
* Berliner Tag today (YYYY-MM-DD) liegt.
* Berliner Tag today (YYYY-MM-DD) liegt. `projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem
* WOMS), sonst leer.
*/
export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
const outstanding = raw.status === 'open' || raw.status === 'uncollectible'
@@ -83,6 +84,7 @@ export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
overdue,
portalUrl: raw.hosted_invoice_url || '',
hasPdf: Boolean(raw.invoice_pdf),
projekt: raw.metadata?.projektName || '',
}
}
+13 -1
View File
@@ -21,6 +21,7 @@ const SETTINGS_ID = 'main'
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen, auch eine UUID); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const MAX_BANK_DETAILS = 500
const MAX_STEUERNUMMER = 40
const MAX_NAME = 120
const MAX_MATCH = 120
const MAX_BUFFER_CENTS = 100_000_000
@@ -35,6 +36,7 @@ export const DEFAULT_SETTINGS = Object.freeze({
previousYearRevenueCents: null,
estPrepaymentCents: 0,
bankDetailsText: '',
steuernummer: '',
})
const invalid = (field, message) => new SettingsError(400, `${field}: ${message}`)
@@ -74,6 +76,15 @@ const SETTINGS_RULES = {
wholeNumber('estPrepaymentCents', v, 0, Number.MAX_SAFE_INTEGER, 'Die ESt-Vorauszahlung muss eine ganze Zahl ab 0 Cent sein.'),
bankDetailsText: (v) =>
text('bankDetailsText', v, { min: 0, max: MAX_BANK_DETAILS }, `Der Bankverbindungstext muss ein Text mit höchstens ${MAX_BANK_DETAILS} Zeichen sein.`),
// Steuernummer oder USt-IdNr. für die Auftragsbestätigung: Buchstaben A-Z, Ziffern, Leerzeichen, / - und . (kein
// Tabulator, kein Zeilenumbruch, keine Umlaute); '' löscht den Wert. Die Meldung ohne Feldnamen (sie erscheint im Formular).
steuernummer: (v) => {
const trimmed = typeof v === 'string' ? v.trim() : null
if (trimmed === null || trimmed.length > MAX_STEUERNUMMER || !/^[A-Za-z0-9/ .-]*$/.test(trimmed)) {
throw new SettingsError(400, 'Steuernummer ungültig.')
}
return trimmed
},
}
const FIXED_COST_RULES = {
@@ -154,7 +165,8 @@ async function saveSettings(data) {
/**
* Einstellungen aendern; patch nennt nur die zu aendernden Felder: minBufferCents 0 bis 100.000.000,
* taxReservePercent 0 bis 60, previousYearRevenueCents >= 0 oder null (null loescht den Wert), estPrepaymentCents >= 0,
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert). Ein unbekanntes oder ungueltiges Feld:
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert), steuernummer hoechstens 40 Zeichen aus
* Buchstaben, Ziffern, Leerzeichen, / - und . (getrimmt gespeichert, '' loescht). Ein unbekanntes oder ungueltiges Feld:
* SettingsError 400 mit dem Feldnamen, es wird nichts gespeichert. Legt das Dokument an, falls es fehlt; ein leerer
* patch aendert und legt nichts an. Liefert die Einstellungen danach.
*/
+128 -78
View File
@@ -1,8 +1,11 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
import { withLocks } from '../keyedLock.js'
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet, pruefeVorSenden, hatSchlussEntwurf } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung, getProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js'
/**
@@ -87,6 +90,11 @@ export function mapInvoice(raw, { today }) {
pdfUrl: raw.invoice_pdf || null,
outOfBand: Boolean(metadata.bankTransactionId),
bankTransactionId: metadata.bankTransactionId || '',
projektId: metadata.projektId || '',
projektName: metadata.projektName || '',
art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei',
auftragId: metadata.auftragId || '',
auftragNummer: metadata.auftragNummer || '',
}
}
@@ -126,11 +134,6 @@ export async function listPayouts({ force = false } = {}) {
// Projektrechnungen schreiben
// ---------------------------------------------------------------------------
const MAX_ITEMS = 20
const MAX_DESCRIPTION = 200
const MAX_QUANTITY = 999
const MAX_UNIT_CENTS = 10_000_000
const MAX_TOTAL_CENTS = 99_999_999
const MAX_DAYS_UNTIL_DUE = 60
const DEFAULT_DAYS_UNTIL_DUE = 14
const MAX_MEMO = 1000
@@ -194,53 +197,18 @@ function assertAddressForSend(customer, totalCents) {
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
}
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
export function invoiceFooter(bankDetailsText = '') {
/**
* Rechnungsfuss: Kleinunternehmer-Hinweis, Steuernummer (falls gesetzt, § 34a UStDV), Bankdaten (falls gesetzt), Hinweis
* auf den Verwendungszweck.
*/
export function invoiceFooter(bankDetailsText = '', steuernummer = '') {
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
const nummer = typeof steuernummer === 'string' ? steuernummer.trim() : ''
return [config.stripe.invoiceFooter, nummer ? `Steuernummer: ${nummer}` : '', bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
}
const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max
/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */
function validateItems(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`)
}
const lines = items.map((item, index) => {
const label = `Position ${index + 1}`
const description = typeof item?.description === 'string' ? item.description.trim() : ''
if (description.length < 1 || description.length > MAX_DESCRIPTION) {
throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`)
}
if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) {
throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`)
}
if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) {
throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents }
})
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.')
return lines
}
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
async function loadBillableCustomer(customerId) {
if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.')
let customer
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.')
throw err
}
const stage = String(customer.customerStatus || '').trim().toLowerCase()
if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.')
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
@@ -257,12 +225,39 @@ async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche)
const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')
async function bucheNummer(invoiceId, number, art) {
if (!PROJEKT_ARTEN.includes(art) || !number) return
await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))
}
/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */
async function bucheFinalisiert(invoiceId, number, art, auftragId) {
if (!PROJEKT_ARTEN.includes(art)) return
await bucheNummer(invoiceId, number, art)
if (art !== 'schluss') return
// Massgebend ist die AB im Projektdatensatz (nach umhaengen die neue Fassung), nicht die in den Stripe-Metadaten
await buche('AB abrechnen', async () => {
const abId = (await getProjektRechnung(invoiceId))?.auftragId || auftragId
if (abId) await setzeAbAbgerechnet(abId)
})
}
/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */
async function bucheStorniert(invoiceId, art) {
if (!PROJEKT_ARTEN.includes(art)) return
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
async function finalizeAndSend(stripe, invoiceId) {
async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) {
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
const number = finalized.number || ''
await bucheFinalisiert(invoiceId, number, art, auftragId)
try {
await stripe.invoices.sendInvoice(invoiceId)
} catch (cause) {
@@ -338,25 +333,40 @@ function validateIdempotencyKey(key) {
*/
export async function createProjectInvoice({
customerId,
art = 'frei',
projektId,
auftragId,
items,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue,
memo = '',
senden = false,
idempotencyKey,
createdBy,
}) {
const lines = validateItems(items)
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
// Ohne Angabe: bei Anzahlung und Schluss das Zahlungsziel der AB (siehe createInvoice), sonst 14 Tage
if (daysUntilDue !== undefined && !isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
}
const note = typeof memo === 'string' ? memo.trim() : ''
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
// Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400)
const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds
const fingerprint = createHash('sha256')
.update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true]))
.digest('hex')
claimCreate(fingerprint)
try {
const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
@@ -366,13 +376,26 @@ export async function createProjectInvoice({
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
/**
* Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch
* Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre.
*/
export async function rechnungsVorschau(eingabe) {
const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe)
return { positionen, summeCents, leistungszeitraum, customFields }
}
async function createInvoice({ eingabe, daysUntilDue: angegeben, note, senden, key }) {
const entwurf = await baueRechnung(eingabe)
// Zahlungsziel: die Angabe des Aufrufers, sonst bei Anzahlung und Schluss das der AB, sonst 14 Tage
const abZiel = AB_RECHNUNGS_ARTEN.includes(entwurf.art) ? entwurf.auftrag?.zahlungszielTage : undefined
const daysUntilDue = angegeben ?? (isWholeNumberBetween(abZiel, 0, MAX_DAYS_UNTIL_DUE) ? abZiel : DEFAULT_DAYS_UNTIL_DUE)
const customer = entwurf.kunde
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
if (senden) assertAddressForSend(customer, entwurf.summeCents)
const { bankDetailsText } = await getSettings()
const { bankDetailsText, steuernummer } = await getSettings()
const stripe = getStripe()
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
// Vor dem ersten Rechnungsaufruf: scheitert das, gibt es keine halbfertige Rechnung
@@ -387,24 +410,25 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
auto_advance: false,
pending_invoice_items_behavior: 'exclude',
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
footer: invoiceFooter(bankDetailsText, steuernummer),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
metadata: entwurf.metadata,
custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined,
rendering: { pdf: { page_size: 'a4' } },
},
key ? { idempotencyKey: key } : undefined
)
invoiceId = draft.id
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
for (const line of lines) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
invoice: invoiceId,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitAmountCents),
currency: 'eur',
})
for (const position of entwurf.positionen) {
const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung }
// Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity
if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents })
else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) })
posten.currency = 'eur'
await stripe.invoiceItems.create(posten)
}
if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung)
} catch (err) {
// Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar
if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {})
@@ -413,7 +437,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
}
try {
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : ''
try {
return await readInvoice(invoiceId)
} catch (cause) {
@@ -438,6 +462,17 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
*/
export async function sendInvoiceNow(invoiceId) {
const raw = await retrieveInvoice(invoiceId)
const vorab = mapInvoice(raw, { today: berlinDate() })
// Anzahlungs- und Schluss-Entwuerfe: unter der Sperre des Kunden (wie das Anlegen) senden und vorher die AB erneut pruefen
if (vorab.source === 'projekt' && vorab.status === 'entwurf' && AB_RECHNUNGS_ARTEN.includes(vorab.art) && vorab.webklarCustomerId) {
return withLocks(sperreRechnung(vorab.webklarCustomerId), () => sendeRechnung(invoiceId, null, true))
}
return sendeRechnung(invoiceId, raw, false)
}
// raw = die schon gelesene Rechnung; unter der Sperre wird frisch gelesen (ein anderer Aufruf kann sie inzwischen gesendet haben)
async function sendeRechnung(invoiceId, raw, abPruefen) {
raw ??= await retrieveInvoice(invoiceId)
const invoice = mapInvoice(raw, { today: berlinDate() })
// Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
@@ -445,10 +480,11 @@ export async function sendInvoiceNow(invoiceId) {
if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') {
throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.')
}
if (isDraft && abPruefen) await pruefeVorSenden({ invoiceId: invoice.id, art: invoice.art, auftragIdMetadaten: invoice.auftragId, totalCents: invoice.totalCents })
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
let customer = null
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId)
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
@@ -459,8 +495,12 @@ export async function sendInvoiceNow(invoiceId) {
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
await finalizeAndSend(getStripe(), invoice.id)
} else await getStripe().invoices.sendInvoice(invoice.id)
await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId })
} else {
// Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen
await bucheNummer(invoice.id, invoice.number, invoice.art)
await getStripe().invoices.sendInvoice(invoice.id)
}
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
@@ -472,12 +512,21 @@ export async function voidInvoice(invoiceId) {
const invoice = await readInvoice(invoiceId)
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
try {
await getStripe().invoices.voidInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
const stornieren = async () => {
// Der Schluss-Entwurf enthaelt die Minuszeile dieser Anzahlung: wuerde sie storniert, ginge er zu niedrig hinaus
if (invoice.art === 'anzahlung' && (await hatSchlussEntwurf({ invoiceId: invoice.id, auftragIdMetadaten: invoice.auftragId }))) {
throw httpError(409, 'Erst den Entwurf der Schlussrechnung löschen.')
}
try {
await getStripe().invoices.voidInvoice(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
}
}
// Eine Anzahlung unter der Sperre des Kunden: das Anlegen und Senden einer Schlussrechnung laeuft nie zwischen Pruefung und Storno
return invoice.art === 'anzahlung' && invoice.webklarCustomerId ? withLocks(sperreRechnung(invoice.webklarCustomerId), stornieren) : stornieren()
}
/** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */
@@ -487,6 +536,7 @@ export async function deleteDraft(invoiceId) {
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.')
try {
await getStripe().invoices.del(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
} finally {
invalidateFinanceCache()
}
+3 -2
View File
@@ -35,10 +35,11 @@ function getTransporter() {
}
/** Vorlage rendern (server/mail-templates); Unstimmigkeiten landen im Log, die Mail geht trotzdem raus. */
function render({ template, values = {}, blocks = {} }) {
function render({ template, values = {}, blocks = {}, subjectPrefix = '' }) {
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`)
return mail
// optional: Text vor dem Betreff der Vorlage (z. B. "Kopie: " bei einer Teamkopie)
return subjectPrefix ? { ...mail, subject: `${subjectPrefix}${mail.subject}` } : mail
}
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,