From 2bf67cb48d6876ef8b10fb72bb87a19513ec463f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:15:49 +0000 Subject: [PATCH] Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 47 ++- server/services/finance/assign.js | 222 +++++++++++++ test/financeAssign.test.js | 501 ++++++++++++++++++++++++++++++ 3 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 server/services/finance/assign.js create mode 100644 test/financeAssign.test.js diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index bdfb081..b01df43 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -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 diff --git a/server/services/finance/assign.js b/server/services/finance/assign.js new file mode 100644 index 0000000..5544fce --- /dev/null +++ b/server/services/finance/assign.js @@ -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) })) } +} diff --git a/test/financeAssign.test.js b/test/financeAssign.test.js new file mode 100644 index 0000000..f990691 --- /dev/null +++ b/test/financeAssign.test.js @@ -0,0 +1,501 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import Stripe from 'stripe' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeRoutesBank.test.js) +const aw = await startAppwriteMock({ + schema: { bankTransactions: schemaKeys('bankTransactions'), financeFixedCosts: schemaKeys('financeFixedCosts') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') +const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') +const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') +const { getStripe } = await import('../server/services/stripeBilling.js') +const { euroDe } = await import('../server/services/finance/format.js') + +const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) +after(async () => { + mock.restoreAll() + await Promise.all([app.close(), aw.close(), st.close()]) +}) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const base = '/api/admin/finanzen' +const TX = () => config.collections.bankTransactions +const stored = (id) => aw.docs(TX()).get(id) +const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) +const payCalls = () => callsTo('POST', /\/pay$/) + +const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000) +const BOOKING = '2026-09-29' + +const tx = (id, over = {}) => ({ + $id: id, + accountUid: 'konto-1', + status: 'BOOK', + bookingDate: BOOKING, + valueDate: BOOKING, + amountCents: 16879, + currency: 'EUR', + counterpartyName: 'EWV SOFTWARE GMBH', + counterpartyIban: '', + remittance: 'Rechnung WK-0005 vom 28.07.2026', + mcc: '', + entryReference: id, + category: '', + matchedInvoiceId: '', + matchedInvoiceNumber: '', + matchedPayoutId: '', + fixedCostId: '', + note: '', + syncedAt: '2026-10-01T09:00:00.000Z', + ...over, +}) + +const rawInvoice = (over = {}) => ({ + id: 'in_ewv', + object: 'invoice', + number: 'WK-0005', + status: 'open', + customer: 'cus_ewv', + customer_name: 'EWV Software GmbH', + customer_email: 'ewv@example.invalid', + created: unix('2026-08-01'), + due_date: unix('2026-10-20'), + total: 16879, + amount_due: 16879, + amount_paid: 0, + amount_remaining: 16879, + status_transitions: { paid_at: null }, + metadata: {}, + lines: { object: 'list', data: [], has_more: false }, + ...over, +}) + +const rawPayout = (over = {}) => ({ + id: 'po_1', + object: 'payout', + amount: 45000, + arrival_date: unix('2026-09-28'), + status: 'paid', + currency: 'eur', + ...over, +}) + +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') + +// aw.docs liefert eine Kopie: Dokumente loescht der Test ueber den Server wie die Anwendung +async function reset() { + mock.restoreAll() + invalidateFinanceCache() + st.calls.length = 0 + st.invoices.clear() + st.customers.clear() + st.payouts.clear() + for (const collection of [TX(), config.collections.financeFixedCosts]) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } +} +beforeEach(reset) + +const seedTx = (id, over) => aw.seed(TX(), tx(id, over)) +const zuordnen = (id, body) => post(`${base}/buchungen/${id}/zuordnen`, body, admin) +const loesen = async (id) => { + const res = await fetch(app.url + `${base}/buchungen/${id}/zuordnung`, { method: 'DELETE', headers: admin.headers }) + const text = await res.text() + return { status: res.status, body: text ? JSON.parse(text) : null } +} + +// ---------- Zugriff ---------- + +test('zuordnen und loesen: ohne JWT 401', async () => { + seedTx('b1') + assert.equal((await post(`${base}/buchungen/b1/zuordnen`, { category: 'privat' })).status, 401) + const res = await fetch(app.url + `${base}/buchungen/b1/zuordnung`, { method: 'DELETE' }) + assert.equal(res.status, 401) + assert.equal(stored('b1').category, '') +}) + +// ---------- Rechnung ---------- + +test('Rechnung: Stripe pay (paid_out_of_band) + Metadaten, Buchung wird rechnung', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + + const res = await zuordnen('b1', { invoiceId: 'in_ewv', note: ' Überweisung EWV ' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body), ['buchung']) + assert.equal(res.body.buchung.$id, 'b1') + assert.equal(res.body.buchung.category, 'rechnung') + assert.equal(res.body.buchung.matchedInvoiceId, 'in_ewv') + assert.equal(res.body.buchung.matchedInvoiceNumber, 'WK-0005') + assert.equal(res.body.buchung.note, 'Überweisung EWV') + assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id']) + + assert.equal(payCalls().length, 1) + assert.equal(payCalls()[0].path, '/v1/invoices/in_ewv/pay') + assert.equal(payCalls()[0].body.paid_out_of_band, 'true') + const updates = callsTo('POST', '/v1/invoices/in_ewv') + assert.equal(updates.length, 1) + assert.equal(updates[0].body['metadata[bankTransactionId]'], 'b1') + assert.equal(updates[0].body['metadata[bankBookingDate]'], BOOKING) + + const invoice = st.invoices.get('in_ewv') + assert.equal(invoice.status, 'paid') + assert.equal(invoice.paid_out_of_band, true) + assert.equal(invoice.metadata.bankTransactionId, 'b1') + + const doc = stored('b1') + assert.equal(doc.category, 'rechnung') + assert.equal(doc.matchedInvoiceId, 'in_ewv') + assert.equal(doc.matchedInvoiceNumber, 'WK-0005') +}) + +test('Rechnung: Zwischenspeicher wird geleert (die Liste zeigt die Rechnung nicht mehr als offen)', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + const { listInvoices } = await import('../server/services/finance/stripeFinance.js') + assert.equal((await listInvoices())[0].status, 'offen') + assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200) + const [invoice] = await listInvoices() + assert.equal(invoice.status, 'bezahlt') + assert.equal(invoice.outOfBand, true) + assert.equal(invoice.paidDate, BOOKING) +}) + +test('Rechnung: zwei parallele Zuordnungen derselben Buchung -> eine 200, eine 409, genau ein pay', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + const results = await Promise.allSettled([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b1', { invoiceId: 'in_ewv' })]) + const statuses = results.map((r) => r.value.status).sort() + assert.deepEqual(statuses, [200, 409]) + const loser = results.map((r) => r.value).find((r) => r.status === 409) + assert.equal(loser.body.error, 'Bereits zugeordnet.') + assert.equal(payCalls().length, 1) +}) + +test('Rechnung: dieselbe Rechnung fuer zwei Buchungen gleichzeitig -> eine 200, eine 409, genau ein pay', async () => { + seedTx('b1') + seedTx('b2') + st.invoices.set('in_ewv', rawInvoice()) + const results = await Promise.all([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b2', { invoiceId: 'in_ewv' })]) + assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]) + assert.equal(payCalls().length, 1) + const assigned = ['b1', 'b2'].filter((id) => stored(id).category === 'rechnung') + assert.equal(assigned.length, 1) +}) + +test('Rechnung: Betrag weicht ab -> 409 mit Betrag, kein pay, Buchung unveraendert', async () => { + seedTx('b1', { amountCents: 10000 }) + st.invoices.set('in_ewv', rawInvoice()) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 409) + assert.equal(res.body.error, `Betrag weicht vom offenen Betrag ab (${euroDe(16879)}). Teilzahlungen bitte direkt in Stripe erfassen.`) + assert.equal(payCalls().length, 0) + assert.equal(stored('b1').category, '') + assert.equal(st.invoices.get('in_ewv').status, 'open') +}) + +test('Rechnung: nicht offen (bezahlt) -> 409, kein pay', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879 })) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.') + assert.equal(payCalls().length, 0) + assert.equal(stored('b1').category, '') +}) + +test('Rechnung: unbekannte oder ungueltige Rechnungs-ID -> 404', async () => { + seedTx('b1') + assert.equal((await zuordnen('b1', { invoiceId: 'in_gibtsnicht' })).status, 404) + assert.equal((await zuordnen('b1', { invoiceId: '../x' })).status, 404) + assert.equal(payCalls().length, 0) +}) + +test('Rechnung: nur Eingaenge, eine Ausgangsbuchung -> 400', async () => { + seedTx('b1', { amountCents: -16879 }) + st.invoices.set('in_ewv', rawInvoice()) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 400) + assert.equal(payCalls().length, 0) +}) + +test('bereits zugeordnet -> 409 "Bereits zugeordnet." ohne jeden Stripe-Aufruf', async () => { + seedTx('b1', { category: 'privat' }) + st.invoices.set('in_ewv', rawInvoice()) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Bereits zugeordnet.') + assert.equal(st.calls.length, 0) +}) + +test('Stripe lehnt pay ab (4xx) -> 409 ohne Stripe-Text, Buchung bleibt offen', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'Geheimer Stripe-Text sk_live_abc' }) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.') + assert.ok(!JSON.stringify(res.body).includes('Geheimer')) + assert.equal(stored('b1').category, '') +}) + +test('Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + mock.method(getStripe().invoices, 'retrieve', async () => { + throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' }) + }) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 502) + assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.') + assert.ok(!JSON.stringify(res.body).includes('10.0.0.1')) +}) + +test('Metadaten-Update scheitert nach pay: Buchung wird trotzdem zugeordnet (Rechnung ist schon bezahlt)', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + st.failNext({ method: 'POST', path: '/v1/invoices/in_ewv', status: 400, message: 'Metadaten abgelehnt' }) + const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(res.status, 200) + assert.equal(stored('b1').category, 'rechnung') + assert.equal(st.invoices.get('in_ewv').status, 'paid') +}) + +test('Appwrite scheitert nach pay: 500, die zweite Zuordnung setzt dort fort ohne zweites pay', async () => { + seedTx('b1') + st.invoices.set('in_ewv', rawInvoice()) + aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' }) + const failed = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(failed.status, 500) + assert.equal(failed.body.error, 'Interner Fehler.') + assert.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt')) + assert.equal(stored('b1').category, '') + assert.equal(st.invoices.get('in_ewv').status, 'paid') + + const retry = await zuordnen('b1', { invoiceId: 'in_ewv' }) + assert.equal(retry.status, 200) + assert.equal(stored('b1').category, 'rechnung') + assert.equal(stored('b1').matchedInvoiceNumber, 'WK-0005') + assert.equal(payCalls().length, 1) +}) + +// ---------- Auszahlung ---------- + +test('Auszahlung: gleicher Betrag -> stripe-auszahlung mit matchedPayoutId', async () => { + seedTx('b1', { amountCents: 45000, counterpartyName: 'STRIPE PAYMENTS EUROPE', remittance: '' }) + st.payouts.set('po_1', rawPayout()) + const res = await zuordnen('b1', { payoutId: 'po_1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.buchung.category, 'stripe-auszahlung') + assert.equal(res.body.buchung.matchedPayoutId, 'po_1') + assert.equal(stored('b1').matchedPayoutId, 'po_1') + assert.equal(payCalls().length, 0) +}) + +test('Auszahlung: Betrag weicht ab -> 409; unbekannt -> 404; fehlgeschlagen/storniert -> 409', async () => { + seedTx('b1', { amountCents: 44000 }) + st.payouts.set('po_1', rawPayout()) + st.payouts.set('po_f', rawPayout({ id: 'po_f', status: 'failed', amount: 44000 })) + st.payouts.set('po_c', rawPayout({ id: 'po_c', status: 'canceled', amount: 44000 })) + + const diff = await zuordnen('b1', { payoutId: 'po_1' }) + assert.equal(diff.status, 409) + assert.match(diff.body.error, /Betrag weicht von der Auszahlung ab/) + assert.equal((await zuordnen('b1', { payoutId: 'po_gibtsnicht' })).status, 404) + for (const id of ['po_f', 'po_c']) { + const res = await zuordnen('b1', { payoutId: id }) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Diese Auszahlung ist fehlgeschlagen oder storniert.') + } + assert.equal(stored('b1').category, '') +}) + +test('Auszahlung: dieselbe Auszahlung nicht zweimal vergeben', async () => { + seedTx('b1', { amountCents: 45000 }) + seedTx('b2', { amountCents: 45000 }) + st.payouts.set('po_1', rawPayout()) + assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 200) + const second = await zuordnen('b2', { payoutId: 'po_1' }) + assert.equal(second.status, 409) + assert.equal(second.body.error, 'Diese Auszahlung ist schon einer Buchung zugeordnet.') + assert.equal(stored('b2').category, '') +}) + +test('Auszahlung: Ausgangsbuchung -> 400', async () => { + seedTx('b1', { amountCents: -45000 }) + st.payouts.set('po_1', rawPayout()) + assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 400) +}) + +// ---------- Fixkosten ---------- + +test('Fixkosten: Ausgang mit vorhandener Position -> fixkosten mit fixedCostId', async () => { + seedTx('b1', { amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' }) + aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true }) + const res = await zuordnen('b1', { fixedCostId: 'fk1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.buchung.category, 'fixkosten') + assert.equal(res.body.buchung.fixedCostId, 'fk1') + assert.equal(stored('b1').fixedCostId, 'fk1') +}) + +test('Fixkosten: unbekannte Position -> 400, Eingangsbuchung -> 400', async () => { + seedTx('b1', { amountCents: -449 }) + seedTx('b2', { amountCents: 449 }) + aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true }) + const missing = await zuordnen('b1', { fixedCostId: 'fk-gibtsnicht' }) + assert.equal(missing.status, 400) + assert.equal(missing.body.error, 'Diese Fixkosten-Position gibt es nicht.') + assert.equal((await zuordnen('b2', { fixedCostId: 'fk1' })).status, 400) + assert.equal(stored('b1').category, '') + assert.equal(stored('b2').category, '') +}) + +// ---------- Kategorien ---------- + +test('Kategorien: privat und sonstiges fuer Ein- und Ausgang, rechnung-extern nur fuer Eingang', async () => { + seedTx('in1', { amountCents: 5000 }) + seedTx('out1', { amountCents: -5000 }) + seedTx('out2', { amountCents: -700 }) + seedTx('in2', { amountCents: 700 }) + + const extern = await zuordnen('in1', { category: 'rechnung-extern', note: 'Rechnung 17 außerhalb von Stripe' }) + assert.equal(extern.status, 200) + assert.equal(extern.body.buchung.category, 'rechnung-extern') + assert.equal(stored('in1').note, 'Rechnung 17 außerhalb von Stripe') + + const externOut = await zuordnen('out1', { category: 'rechnung-extern' }) + assert.equal(externOut.status, 400) + assert.equal(stored('out1').category, '') + + assert.equal((await zuordnen('out1', { category: 'privat' })).status, 200) + assert.equal(stored('out1').category, 'privat') + assert.equal((await zuordnen('in2', { category: 'sonstiges' })).status, 200) + assert.equal(stored('in2').category, 'sonstiges') + assert.equal((await zuordnen('out2', { category: 'sonstiges' })).status, 200) + assert.equal(st.calls.length, 0) +}) + +test('Eingaben: genau ein Ziel, bekannte Kategorie, Notiz hoechstens 500 Zeichen, bekannte Buchung', async () => { + seedTx('b1') + const cases = [ + [{}, 400], + [{ invoiceId: 'in_ewv', payoutId: 'po_1' }, 400], + [{ category: 'privat', fixedCostId: 'fk1' }, 400], + // rechnung, stripe-auszahlung und fixkosten entstehen nur ueber ihr Ziel, nicht als nackte Kategorie + [{ category: 'rechnung' }, 400], + [{ category: 'stripe-auszahlung' }, 400], + [{ category: 'fixkosten' }, 400], + [{ category: 'irgendwas' }, 400], + [{ category: 'privat', note: 'x'.repeat(501) }, 400], + [{ category: 'privat', note: 42 }, 400], + [{ invoiceId: 42 }, 400], + ] + for (const [body, status] of cases) { + const res = await zuordnen('b1', body) + assert.equal(res.status, status, JSON.stringify(body)) + assert.ok(res.body.error) + } + assert.equal(stored('b1').category, '') + assert.equal(st.calls.length, 0) + + assert.equal((await zuordnen('b1', { category: 'privat', note: 'x'.repeat(500) })).status, 200) + assert.equal((await zuordnen('gibtsnicht', { category: 'privat' })).status, 404) + assert.equal((await zuordnen('../b1', { category: 'privat' })).status, 404) +}) + +// ---------- Zuordnung loesen ---------- + +test('loesen: rechnung -> 409, Stripe und Buchung bleiben unveraendert', async () => { + seedTx('b1', { category: 'rechnung', matchedInvoiceId: 'in_ewv', matchedInvoiceNumber: 'WK-0005' }) + const res = await loesen('b1') + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.') + assert.equal(stored('b1').category, 'rechnung') + assert.equal(stored('b1').matchedInvoiceId, 'in_ewv') + assert.equal(st.calls.length, 0) +}) + +test('loesen: privat, stripe-auszahlung und fixkosten -> alle Zuordnungsfelder leer, Notiz bleibt', async () => { + seedTx('b1', { category: 'privat', note: 'Mittagessen' }) + seedTx('b2', { category: 'stripe-auszahlung', matchedPayoutId: 'po_1' }) + seedTx('b3', { amountCents: -449, category: 'fixkosten', fixedCostId: 'fk1' }) + + const res = await loesen('b1') + assert.equal(res.status, 200) + assert.equal(res.body.buchung.category, '') + assert.equal(res.body.buchung.note, 'Mittagessen') + assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id']) + + assert.equal((await loesen('b2')).status, 200) + assert.equal((await loesen('b3')).status, 200) + for (const id of ['b1', 'b2', 'b3']) { + const doc = stored(id) + for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId']) { + assert.equal(doc[key], '', `${id}.${key}`) + } + } + // danach laesst sich die Buchung neu zuordnen + assert.equal((await zuordnen('b1', { category: 'sonstiges' })).status, 200) +}) + +test('loesen: unbekannte Buchung -> 404; nicht zugeordnete Buchung bleibt unveraendert (200)', async () => { + seedTx('b1') + assert.equal((await loesen('gibtsnicht')).status, 404) + const res = await loesen('b1') + assert.equal(res.status, 200) + assert.equal(res.body.buchung.category, '') +}) + +// ---------- GET /buchungen ---------- + +test('GET /buchungen: Eingang bekommt einen Vorschlag, Ausgang und zugeordnete nicht', async () => { + seedTx('ewv', { bookingDate: '2026-09-29' }) + seedTx('aus', { bookingDate: '2026-09-28', amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' }) + seedTx('fertig', { bookingDate: '2026-09-27', category: 'privat' }) + st.invoices.set('in_ewv', rawInvoice()) + + const body = await get(`${base}/buchungen`, admin) + assert.equal(body.stripeFehler, undefined) + const byId = Object.fromEntries(body.buchungen.map((b) => [b.$id, b])) + assert.equal(byId.ewv.vorschlaege.length, 1) + assert.equal(byId.ewv.vorschlaege[0].kind, 'invoice') + assert.equal(byId.ewv.vorschlaege[0].id, 'in_ewv') + assert.equal(byId.ewv.vorschlaege[0].number, 'WK-0005') + assert.equal(byId.ewv.vorschlaege[0].confidence, 'hoch') + assert.deepEqual(byId.aus.vorschlaege, []) + assert.deepEqual(byId.fertig.vorschlaege, []) +}) + +test('GET /buchungen: Stripe-Aufrufe nur, wenn es einen offenen Eingang gibt', async () => { + seedTx('aus', { amountCents: -449 }) + seedTx('fertig', { category: 'privat' }) + const body = await get(`${base}/buchungen`, admin) + assert.equal(body.buchungen.length, 2) + assert.equal(body.stripeFehler, undefined) + assert.equal(st.calls.length, 0) +}) + +for (const path of ['/v1/invoices', '/v1/payouts']) { + test(`GET /buchungen: Stripe-Fehler bei ${path} -> 200 mit stripeFehler und leeren Vorschlaegen`, async () => { + seedTx('ewv') + seedTx('aus', { amountCents: -449 }) + st.invoices.set('in_ewv', rawInvoice()) + st.failNext({ method: 'GET', path, status: 400, message: 'Geheimer Stripe-Text' }) + const res = await rawGet(`${base}/buchungen`, admin) + assert.equal(res.status, 200) + assert.equal(res.body.stripeFehler, 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.') + assert.ok(!JSON.stringify(res.body).includes('Geheimer')) + assert.equal(res.body.buchungen.length, 2) + for (const buchung of res.body.buchungen) assert.deepEqual(buchung.vorschlaege, []) + }) +}