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
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { AuftragError } from '../../services/auftraege/fehler.js'
|
||||
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
||||
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
|
||||
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
|
||||
@@ -21,13 +22,14 @@ import {
|
||||
createProjectInvoice,
|
||||
deleteDraft,
|
||||
listInvoices,
|
||||
rechnungsVorschau,
|
||||
sendInvoiceNow,
|
||||
voidInvoice,
|
||||
} from '../../services/finance/stripeFinance.js'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
|
||||
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* und rufen die Dienste in server/services/finance auf.
|
||||
*/
|
||||
const router = Router()
|
||||
@@ -148,7 +150,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
|
||||
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
|
||||
*/
|
||||
function sendFinanceError(res, what, err) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
|
||||
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
|
||||
if (err.status === 502 && err.cause) logFailure(what, err.cause)
|
||||
const body = { error: err.message }
|
||||
@@ -208,11 +210,22 @@ router.get('/rechnungen', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/rechnungen', async (req, res) => {
|
||||
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
|
||||
const {
|
||||
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
|
||||
daysUntilDue, memo, senden, idempotencyKey,
|
||||
} = req.body || {}
|
||||
try {
|
||||
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
|
||||
const rechnung = await createProjectInvoice({
|
||||
customerId,
|
||||
art,
|
||||
projektId,
|
||||
auftragId,
|
||||
items,
|
||||
arbeitsblattIds,
|
||||
anzahlung,
|
||||
zeilentexte,
|
||||
leistungszeitraum,
|
||||
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
|
||||
memo,
|
||||
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
||||
@@ -227,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
|
||||
router.post('/rechnungen/vorschau', async (req, res) => {
|
||||
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
|
||||
try {
|
||||
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnungsvorschau', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/senden', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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, sperreRechnung } from '../auftraege/konstanten.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js'
|
||||
import { createProjektRechnung, 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
|
||||
@@ -202,45 +205,6 @@ export function invoiceFooter(bankDetailsText = '') {
|
||||
|
||||
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 +221,34 @@ 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' && auftragId) await buche('AB abrechnen', () => setzeAbAbgerechnet(auftragId))
|
||||
}
|
||||
|
||||
/** 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,14 +324,20 @@ function validateIdempotencyKey(key) {
|
||||
*/
|
||||
export async function createProjectInvoice({
|
||||
customerId,
|
||||
art = 'frei',
|
||||
projektId,
|
||||
auftragId,
|
||||
items,
|
||||
arbeitsblattIds,
|
||||
anzahlung,
|
||||
zeilentexte,
|
||||
leistungszeitraum,
|
||||
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
|
||||
memo = '',
|
||||
senden = false,
|
||||
idempotencyKey,
|
||||
createdBy,
|
||||
}) {
|
||||
const lines = validateItems(items)
|
||||
if (!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.`)
|
||||
}
|
||||
@@ -353,10 +345,18 @@ export async function createProjectInvoice({
|
||||
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,11 +366,21 @@ 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, note, senden, key }) {
|
||||
const entwurf = await baueRechnung(eingabe)
|
||||
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 stripe = getStripe()
|
||||
@@ -389,22 +399,23 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
|
||||
currency: 'eur',
|
||||
footer: invoiceFooter(bankDetailsText),
|
||||
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 +424,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) {
|
||||
@@ -448,7 +459,7 @@ export async function sendInvoiceNow(invoiceId) {
|
||||
// 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 +470,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()
|
||||
@@ -474,6 +489,7 @@ export async function voidInvoice(invoiceId) {
|
||||
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
|
||||
try {
|
||||
await getStripe().invoices.voidInvoice(invoice.id)
|
||||
await bucheStorniert(invoice.id, invoice.art)
|
||||
return await readInvoice(invoice.id)
|
||||
} finally {
|
||||
invalidateFinanceCache()
|
||||
@@ -487,6 +503,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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user