Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent f14a6b7b57
commit 2bf67cb48d
3 files changed
+767 -3

No files matched your search

+44 -3
View File
@@ -4,9 +4,10 @@ import { withLocks } from '../../services/keyedLock.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js'
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
/**
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung und Buchungen. Die Handler sind klein und rufen
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung. Die Handler sind klein und rufen
* die Dienste in server/services/finance auf; weitere Finanz-Routen kommen in dieselbe Datei.
*/
const router = Router()
@@ -113,12 +114,52 @@ router.post('/bank/rueckkehr', async (req, res) => {
router.get('/buchungen', async (_req, res) => {
try {
const buchungen = (await listTransactions()).map(publicTransaction)
return res.json({ buchungen })
// Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt
return res.json(await withSuggestions((await listTransactions()).map(publicTransaction)))
} catch (err) {
logFailure('Buchungen laden', err)
return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' })
}
})
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
/**
* Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt
* ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite
* und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite.
*/
function sendAssignError(res, what, err) {
if (err instanceof AssignError) return res.status(err.status).json({ error: err.message })
logFailure(what, err)
if (isStripeError(err)) {
const status = err.statusCode
if (status >= 400 && status < 500 && status !== 429) return res.status(409).json({ error: STRIPE_REJECTED_MESSAGE })
return res.status(502).json({ error: STRIPE_UNAVAILABLE_MESSAGE })
}
return res.status(500).json({ error: 'Interner Fehler.' })
}
router.post('/buchungen/:id/zuordnen', async (req, res) => {
const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {}
try {
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note })
return res.json({ buchung: publicTransaction(buchung) })
} catch (err) {
return sendAssignError(res, 'Buchung zuordnen', err)
}
})
router.delete('/buchungen/:id/zuordnung', async (req, res) => {
try {
return res.json({ buchung: publicTransaction(await unassignTransaction(req.params.id)) })
} catch (err) {
return sendAssignError(res, 'Zuordnung lösen', err)
}
})
export default router
+222
View File
@@ -0,0 +1,222 @@
import { config } from '../../config.js'
import { getDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { getStripe } from '../stripeBilling.js'
import { getTransaction, listTransactions, updateTransaction } from './bankStore.js'
import { euroDe } from './format.js'
import { suggestMatches } from './matching.js'
import { invalidateFinanceCache, listInvoices, listPayouts } from './stripeFinance.js'
/**
* Bankbuchungen zuordnen: eine Gutschrift zu einer offenen Stripe-Rechnung (die Rechnung wird in Stripe als
* bezahlt markiert, "paid_out_of_band", und das ist nicht umkehrbar) oder zu einer Stripe-Auszahlung, eine
* Belastung zu einer Fixkosten-Position, oder eine Buchung nur mit Kategorie (Rechnung ausserhalb von Stripe,
* privat, sonstiges). Zuordnungen laufen je Buchung nacheinander (keyedLock), damit zwei Klicks nie zweimal bei
* Stripe bezahlen. Die Meldungen sind fuer die Oberflaeche bestimmt (AssignError mit status).
*/
/** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */
export class AssignError extends Error {
constructor(status, message) {
super(message)
this.name = 'AssignError'
this.status = status
}
}
// Kategorien, die ohne Rechnung/Auszahlung/Fixkosten gesetzt werden duerfen; rechnung, stripe-auszahlung und
// fixkosten entstehen nur ueber ihr Ziel
const PLAIN_CATEGORIES = new Set(['rechnung-extern', 'privat', 'sonstiges'])
const MAX_NOTE = 500
const MAX_ID = 64
const STRIPE_INVOICE_ID = /^in_\w+$/
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const UNASSIGNED = { category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '' }
export const STRIPE_SUGGESTIONS_ERROR = 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.'
const isIncoming = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents > 0
const isOutgoing = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents < 0
const present = (value) => value !== undefined && value !== null && value !== ''
/** Eingabe pruefen (ohne I/O): genau ein Ziel, bekannte Kategorie, Notiz. Liefert { kind, value, note }. */
function parseInput(input) {
const body = input && typeof input === 'object' ? input : {}
const targets = [
['invoice', body.invoiceId],
['payout', body.payoutId],
['fixedCost', body.fixedCostId],
['category', body.category],
].filter(([, value]) => present(value))
if (targets.length !== 1) {
throw new AssignError(400, 'Bitte genau ein Ziel angeben: Rechnung, Auszahlung, Fixkosten oder Kategorie.')
}
const [kind, value] = targets[0]
if (typeof value !== 'string' || value.length > MAX_ID) throw new AssignError(400, 'Das Ziel der Zuordnung ist ungültig.')
if (kind === 'category' && !PLAIN_CATEGORIES.has(value)) {
throw new AssignError(400, 'Unbekannte Kategorie (erlaubt: Rechnung außerhalb von Stripe, privat, sonstiges).')
}
let note
if (present(body.note) || body.note === '') {
if (typeof body.note !== 'string') throw new AssignError(400, 'Die Notiz muss ein Text sein.')
note = body.note.trim()
if (note.length > MAX_NOTE) throw new AssignError(400, `Die Notiz darf höchstens ${MAX_NOTE} Zeichen lang sein.`)
}
return { kind, value, note }
}
function documentId(id) {
if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) throw new AssignError(404, 'Buchung nicht gefunden.')
return id
}
async function loadTransaction(id) {
const tx = await getTransaction(id)
if (!tx) throw new AssignError(404, 'Buchung nicht gefunden.')
return tx
}
// Gesperrt wird die Buchung; bei einer Rechnung oder Auszahlung auch diese, damit dieselbe Rechnung von zwei
// Buchungen gleichzeitig nur einmal bezahlt und dieselbe Auszahlung nur einmal vergeben wird
function lockKeys(txId, { kind, value }) {
const keys = [`finanzen-buchung:${txId}`]
if (kind === 'invoice') keys.push(`finanzen-rechnung:${value}`)
if (kind === 'payout') keys.push(`finanzen-auszahlung:${value}`)
return keys
}
/** Stripe-Aufruf; "gibt es nicht" (404, resource_missing) wird zu einem AssignError, alles andere fliegt weiter. */
async function stripeOrNotFound(call, message) {
try {
return await call()
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') throw new AssignError(404, message)
throw err
}
}
async function resolveInvoice(tx, invoiceId) {
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Rechnung zuordnen.')
if (!STRIPE_INVOICE_ID.test(invoiceId)) throw new AssignError(404, 'Rechnung nicht gefunden.')
const stripe = getStripe()
try {
const invoice = await stripeOrNotFound(() => stripe.invoices.retrieve(invoiceId), 'Rechnung nicht gefunden.')
// Eine fruehere Zuordnung kann nach dem Bezahlen an Appwrite gescheitert sein: dann ist die Rechnung schon als
// bezahlt mit genau dieser Buchung gekennzeichnet und es bleibt nur, die Buchung zu speichern
const resumed =
invoice.status === 'paid' && invoice.paid_out_of_band === true && invoice.metadata?.bankTransactionId === tx.$id
if (!resumed) {
if (invoice.status !== 'open') throw new AssignError(409, 'Diese Rechnung ist nicht mehr offen.')
if (invoice.amount_remaining !== tx.amountCents) {
throw new AssignError(409, `Betrag weicht vom offenen Betrag ab (${euroDe(invoice.amount_remaining)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
}
await stripe.invoices.pay(invoice.id, { paid_out_of_band: true })
// Die Rechnung ist jetzt bezahlt (nicht umkehrbar): scheitert nur der Vermerk, bleibt die Zuordnung gueltig
try {
await stripe.invoices.update(invoice.id, { metadata: { bankTransactionId: tx.$id, bankBookingDate: tx.bookingDate || '' } })
} catch (err) {
console.error('[finanzen] Vermerk an der Stripe-Rechnung fehlgeschlagen:', err?.name || 'Error', err?.statusCode ?? '', err?.code ?? '')
}
}
return { category: 'rechnung', matchedInvoiceId: invoice.id, matchedInvoiceNumber: invoice.number || '' }
} finally {
invalidateFinanceCache()
}
}
async function resolvePayout(tx, payoutId) {
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Auszahlung zuordnen.')
// Frisch von Stripe (nicht aus dem 60-s-Zwischenspeicher): die Zuordnung darf nicht auf einem alten Stand beruhen
const payout = (await listPayouts({ force: true })).find((item) => item.id === payoutId)
if (!payout) throw new AssignError(404, 'Auszahlung nicht gefunden.')
if (payout.status === 'failed' || payout.status === 'canceled') {
throw new AssignError(409, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
}
if (payout.amountCents !== tx.amountCents) {
throw new AssignError(409, `Betrag weicht von der Auszahlung ab (${euroDe(payout.amountCents)}).`)
}
const taken = (await listTransactions()).some((other) => other.$id !== tx.$id && other.matchedPayoutId === payoutId)
if (taken) throw new AssignError(409, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
return { category: 'stripe-auszahlung', matchedPayoutId: payout.id }
}
async function resolveFixedCost(tx, fixedCostId) {
if (!isOutgoing(tx)) throw new AssignError(400, 'Fixkosten lassen sich nur Ausgängen zuordnen.')
const missing = new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.')
if (!DOCUMENT_ID.test(fixedCostId)) throw missing
try {
await getDocument(config.collections.financeFixedCosts, fixedCostId)
} catch (err) {
if (err?.status === 404 || err?.status === 400) throw missing
throw err
}
return { category: 'fixkosten', fixedCostId }
}
function resolveCategory(tx, category) {
if (category === 'rechnung-extern' && !isIncoming(tx)) {
throw new AssignError(400, 'Eine Rechnung außerhalb von Stripe passt nur zu einem Eingang.')
}
return { category }
}
/**
* Buchung zuordnen. Genau eines von invoiceId, payoutId, fixedCostId oder category (rechnung-extern, privat,
* sonstiges); note ist optional (hoechstens 500 Zeichen). Liefert die gespeicherte Buchung. Fehler (AssignError):
* 400 bei falscher Eingabe oder falscher Richtung, 404 bei unbekannter Buchung, Rechnung oder Auszahlung, 409 wenn
* die Buchung schon zugeordnet ist, die Rechnung nicht (mehr) offen ist oder der Betrag nicht passt. Stripe-Fehler
* fliegen unveraendert weiter.
*/
export async function assignTransaction(txId, input = {}) {
const request = parseInput(input)
const id = documentId(txId)
return withLocks(lockKeys(id, request), async () => {
const tx = await loadTransaction(id)
// Vor jedem Stripe-Aufruf: eine zugeordnete Buchung wird nie ein zweites Mal bearbeitet
if (tx.category) throw new AssignError(409, 'Bereits zugeordnet.')
let fields
if (request.kind === 'invoice') fields = await resolveInvoice(tx, request.value)
else if (request.kind === 'payout') fields = await resolvePayout(tx, request.value)
else if (request.kind === 'fixedCost') fields = await resolveFixedCost(tx, request.value)
else fields = resolveCategory(tx, request.value)
return updateTransaction(id, request.note === undefined ? fields : { ...fields, note: request.note })
})
}
/**
* Zuordnung loesen: Kategorie und Verweise werden leer (die Notiz bleibt). Eine Rechnung laesst sich nicht
* loesen (409): Stripe hat sie als bezahlt gebucht. Eine nicht zugeordnete Buchung bleibt, wie sie ist.
*/
export async function unassignTransaction(txId) {
const id = documentId(txId)
return withLocks([`finanzen-buchung:${id}`], async () => {
const tx = await loadTransaction(id)
if (tx.category === 'rechnung') {
throw new AssignError(409, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
}
if (!tx.category) return tx
return updateTransaction(id, UNASSIGNED)
})
}
/**
* Vorschlaege an die Buchungen haengen: { buchungen: [{ ...buchung, vorschlaege }], stripeFehler? }. Nur
* Eingaenge ohne Kategorie bekommen Vorschlaege; gibt es keinen, wird Stripe gar nicht gefragt. Scheitert Stripe,
* bleiben alle Vorschlaege leer und stripeFehler nennt den Grund (die Liste selbst scheitert deshalb nie).
*/
export async function withSuggestions(transactions) {
const open = transactions.some((tx) => tx.amountCents > 0 && !tx.category)
const plain = () => transactions.map((tx) => ({ ...tx, vorschlaege: [] }))
if (!open) return { buchungen: plain() }
let stripeData
try {
const [invoices, payouts] = await Promise.all([listInvoices(), listPayouts()])
stripeData = { invoices, payouts }
} catch (err) {
console.error('[finanzen] Zuordnungsvorschläge laden fehlgeschlagen:', err?.name || 'Error', err?.statusCode ?? '', err?.code ?? '')
return { buchungen: plain(), stripeFehler: STRIPE_SUGGESTIONS_ERROR }
}
return { buchungen: transactions.map((tx) => ({ ...tx, vorschlaege: suggestMatches(tx, stripeData) })) }
}