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 { getFixedCost } from './settings.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. Rechnung und Auszahlung nur zu gebuchten Eintraegen (nicht zu Vormerkungen). Bei einer Rechnung * steht erst der Vermerk (Buchungs-ID in den Metadaten) und dann das Bezahlen, damit eine Wiederholung nach einem * Fehler an Appwrite die Zuordnung ohne zweites Bezahlen abschliesst. 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: '' } const PENDING_MESSAGE = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.' 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 } } /** Rechnungen und Auszahlungen nur zu gebuchten Eintraegen: eine Vormerkung kann bis zur Buchung wieder verschwinden. */ function requireBooked(tx) { if (tx.status !== 'BOOK') throw new AssignError(409, PENDING_MESSAGE) } async function resolveInvoice(tx, invoiceId) { if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Rechnung zuordnen.') requireBooked(tx) 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.') // Der Vermerk steht vor dem Bezahlen an der Rechnung: er verraet einer Wiederholung, dass die Rechnung zu dieser // Buchung gehoert (Stripe liefert paid_out_of_band nicht an der Rechnung zurueck) const linked = invoice.metadata?.bankTransactionId === tx.$id // Schon bezahlt und mit dieser Buchung gekennzeichnet (und in genau dieser Hoehe bezahlt): ein frueherer Versuch // scheiterte erst danach (Appwrite), es bleibt nur, die Buchung zu speichern const resumed = invoice.status === 'paid' && linked && invoice.amount_paid === tx.amountCents 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.`) } // Gehoert die Buchung schon zu einer anderen, bezahlten Rechnung (deren Speichern in Appwrite scheiterte), wuerde sie // sonst ein zweites Mal bezahlt verbucht. Frisch von Stripe, nicht aus dem Zwischenspeicher. const claimed = (await listInvoices({ force: true })).some((other) => other.id !== invoice.id && other.bankTransactionId === tx.$id && other.status === 'bezahlt') if (claimed) throw new AssignError(409, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.') // Erst der Vermerk (umkehrbar), dann das Bezahlen (nicht umkehrbar): scheitert der Vermerk, bleibt die Rechnung offen if (!linked) { await stripe.invoices.update(invoice.id, { metadata: { bankTransactionId: tx.$id, bankBookingDate: tx.bookingDate || '' } }) } await stripe.invoices.pay(invoice.id, { paid_out_of_band: true }) } 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.') requireBooked(tx) // 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.') // getFixedCost kennt keine unmoeglichen IDs und liefert null, wenn es die Position nicht gibt if (!(await getFixedCost(fixedCostId))) throw new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.') 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, der Betrag nicht passt oder die Buchung schon * zu einer anderen bezahlten Rechnung gehoert; 502, wenn die Rechnung in Stripe bezahlt wurde, die Buchung aber nicht * gespeichert werden konnte (erneut zuordnen schliesst ab). Stripe-Fehler fliegen unveraendert weiter. by = Mail des * Nutzers fuer die Protokollzeile bei einer Rechnung. */ export async function assignTransaction(txId, input = {}, { by = '' } = {}) { 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) let saved try { saved = await updateTransaction(id, request.note === undefined ? fields : { ...fields, note: request.note }) } catch (cause) { // Bei einer Rechnung ist sie in Stripe schon bezahlt (nicht umkehrbar): die Wiederholung schliesst die Zuordnung ab if (request.kind !== 'invoice') throw cause const err = new AssignError(502, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.') err.cause = cause throw err } // Nicht umkehrbar (Stripe hat die Rechnung bezahlt gebucht): wer, welche Buchung, welche Rechnung; ohne Betraege if (request.kind === 'invoice') { console.log(`[finanzen] Zuordnung ${id} -> Rechnung ${fields.matchedInvoiceNumber || fields.matchedInvoiceId} durch ${by || 'unbekannt'}`) } return saved }) } /** * 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) })) } }