Buchungskalender: origin/main (Aufträge) zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd971309a8
145 files changed
+42590
-129
No files matched your search
@@ -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] : []
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }))
|
||||
})
|
||||
}
|
||||
@@ -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.' })
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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'}`)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)]),
|
||||
)
|
||||
@@ -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.',
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user