Merge auftraege/l2-rechnungen: Rechnungen (Strang L2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e5fc5e2c74
12 files changed
+2083
-70
No files matched your search
@@ -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,382 @@
|
||||
/**
|
||||
* Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz).
|
||||
* Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der
|
||||
* Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus).
|
||||
*/
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
|
||||
import { getStripe } from '../stripeBilling.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { AuftragError } from './fehler.js'
|
||||
import {
|
||||
RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
|
||||
} from './konstanten.js'
|
||||
import {
|
||||
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText,
|
||||
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
|
||||
} from './positionen.js'
|
||||
import { auftraegeAktiv } from './schalter.js'
|
||||
import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
|
||||
import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
|
||||
import {
|
||||
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
|
||||
} from './arbeitsblaetter.js'
|
||||
import { berlinDate, formatDe } from '../finance/dates.js'
|
||||
import { euroDe } from '../finance/format.js'
|
||||
|
||||
// Appwrite-Dokument-ID
|
||||
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
|
||||
export async function ladeFestenKunden(customerId) {
|
||||
if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.')
|
||||
let kunde
|
||||
try {
|
||||
kunde = await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.')
|
||||
throw err
|
||||
}
|
||||
const stufe = String(kunde.customerStatus || '').trim().toLowerCase()
|
||||
if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.')
|
||||
return kunde
|
||||
}
|
||||
|
||||
/**
|
||||
* Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder
|
||||
* Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht).
|
||||
*/
|
||||
async function ladeProjekt(kundenId, projektId, { weich = false } = {}) {
|
||||
const fehler = (status, meldung) => {
|
||||
if (weich) return null
|
||||
throw new AuftragError(status, meldung)
|
||||
}
|
||||
if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||||
if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.')
|
||||
let projekt
|
||||
try {
|
||||
projekt = await getDocument(config.collections.websiteProjects, projektId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.')
|
||||
throw err
|
||||
}
|
||||
if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.')
|
||||
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
|
||||
}
|
||||
|
||||
/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */
|
||||
async function ladeAuftrag(kundenId, auftragId) {
|
||||
if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.')
|
||||
const auftrag = await getAuftrag(auftragId)
|
||||
if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.')
|
||||
return auftrag
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an
|
||||
* Appwrite). Fehler mit Status 404 sind still. Wirft nie.
|
||||
*/
|
||||
export async function buche(was, fn) {
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return
|
||||
console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
/** Schlussrechnung 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 liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null
|
||||
}
|
||||
|
||||
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
|
||||
async function waehleBlaetter(kunde, ids, eigeneTickets) {
|
||||
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
|
||||
throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||||
}
|
||||
const eindeutig = [...new Set(ids)]
|
||||
if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.')
|
||||
if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
|
||||
if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT)
|
||||
const blaetter = await ladeArbeitsblaetterNachIds(eindeutig)
|
||||
if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT)
|
||||
if (blaetter.some((b) => !abrechenbar(b))) {
|
||||
throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
|
||||
}
|
||||
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
|
||||
const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)]))
|
||||
if (belegt.size) {
|
||||
const gueltig = await pruefeBelegungen(belegt)
|
||||
for (const id of eindeutig) {
|
||||
const b = gueltig.get(id)
|
||||
if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`)
|
||||
}
|
||||
}
|
||||
return blaetter
|
||||
}
|
||||
|
||||
/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */
|
||||
async function standardBlaetter(kunde, projekt, eigeneTickets) {
|
||||
const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : []
|
||||
const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id))
|
||||
const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : [])
|
||||
let offene = await lade()
|
||||
if (!offene.length && zeitquelle.length) {
|
||||
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
|
||||
const belegt = new Map()
|
||||
for (const blatt of await ladeArbeitsblaetter(zeitquelle)) {
|
||||
if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id))
|
||||
}
|
||||
if (belegt.size) {
|
||||
const gueltig = await pruefeBelegungen(belegt)
|
||||
if (gueltig.size < belegt.size) offene = await lade()
|
||||
}
|
||||
}
|
||||
if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||||
if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
|
||||
return offene
|
||||
}
|
||||
|
||||
async function baueAufwand(basis, eingabe) {
|
||||
const { kunde } = basis
|
||||
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
|
||||
const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId)
|
||||
const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
|
||||
const zusatz = zusatzPositionen(eingabe.items)
|
||||
const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte)
|
||||
const eigeneTickets = new Set(
|
||||
(await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id)
|
||||
)
|
||||
const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null
|
||||
const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets)
|
||||
const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })
|
||||
return schliesseAb({
|
||||
...basis,
|
||||
projekt,
|
||||
auftrag,
|
||||
positionen: [...tage, ...zusatz],
|
||||
leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis),
|
||||
arbeitsblattIds: blaetter.map((b) => b.id),
|
||||
})
|
||||
}
|
||||
|
||||
const FINALISIERT = ['open', 'paid', 'uncollectible']
|
||||
|
||||
/**
|
||||
* Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard
|
||||
* stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen.
|
||||
*/
|
||||
async function ladeFruehere(auftragId) {
|
||||
const ergebnis = []
|
||||
for (const doc of await listProjektRechnungen({ auftragId })) {
|
||||
let roh
|
||||
try {
|
||||
roh = await getStripe().invoices.retrieve(doc.invoiceId)
|
||||
} catch (err) {
|
||||
if (!fehltBeiStripe(err)) throw err
|
||||
roh = null
|
||||
}
|
||||
if (!roh || roh.status === 'void') {
|
||||
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId))
|
||||
continue
|
||||
}
|
||||
const nummer = roh.number || ''
|
||||
if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) {
|
||||
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer }))
|
||||
}
|
||||
ergebnis.push({
|
||||
invoiceId: doc.invoiceId,
|
||||
art: doc.art,
|
||||
status: roh.status,
|
||||
rechnungsnummer: nummer,
|
||||
datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)),
|
||||
betragCents: roh.total ?? 0,
|
||||
})
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */
|
||||
async function baueAbRechnung(basis, eingabe) {
|
||||
const { kunde, art } = basis
|
||||
const { auftragId } = eingabe
|
||||
if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||
let auftrag = await ladeAuftrag(kunde.$id, auftragId)
|
||||
if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
|
||||
if (auftrag.status === 'abgerechnet') {
|
||||
await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck
|
||||
auftrag = await ladeAuftrag(kunde.$id, auftragId)
|
||||
}
|
||||
if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
|
||||
let projekt = null
|
||||
if (typeof eingabe.projektId === 'string' && eingabe.projektId) {
|
||||
projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
|
||||
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||||
} else if (auftrag.projektId) {
|
||||
projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })
|
||||
}
|
||||
|
||||
const fruehere = await ladeFruehere(auftrag.id)
|
||||
const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss'))
|
||||
if (entwurf) {
|
||||
throw new AuftragError(409, art === 'schluss'
|
||||
? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.`
|
||||
: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.')
|
||||
}
|
||||
const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status))
|
||||
let positionen
|
||||
if (art === 'anzahlung') {
|
||||
const angaben = eingabe.anzahlung ?? {}
|
||||
if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||||
positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))]
|
||||
} else {
|
||||
positionen = schlussPositionen(
|
||||
auftrag,
|
||||
finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })),
|
||||
eingabe.items
|
||||
)
|
||||
}
|
||||
return schliesseAb({ ...basis, projekt, auftrag, positionen })
|
||||
}
|
||||
Reference in new issue
Block a user