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) // erst der Vermerk, dann das Bezahlen (der Vermerk ist umkehrbar, das Bezahlen nicht) assert.ok(st.calls.indexOf(updates[0]) < st.calls.indexOf(payCalls()[0])) const invoice = st.invoices.get('in_ewv') assert.equal(invoice.status, 'paid') 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('Vermerk scheitert (4xx): 409, kein pay, Rechnung bleibt offen, Buchung unveraendert', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) st.failNext({ method: 'POST', path: '/v1/invoices/in_ewv', status: 400, message: 'Geheimer Vermerk-Text' }) 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(payCalls().length, 0) assert.equal(st.invoices.get('in_ewv').status, 'open') assert.equal(stored('b1').category, '') }) test('Vermerk scheitert (Verbindungsfehler): 502, kein pay', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) mock.method(getStripe().invoices, 'update', 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.equal(payCalls().length, 0) assert.equal(st.invoices.get('in_ewv').status, 'open') }) test('pay scheitert nach dem Vermerk: Wiederholung bezahlt genau einmal und vermerkt nicht erneut', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'pay abgelehnt' }) const failed = await zuordnen('b1', { invoiceId: 'in_ewv' }) assert.equal(failed.status, 409) assert.equal(st.invoices.get('in_ewv').status, 'open') assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b1') assert.equal(stored('b1').category, '') const retry = await zuordnen('b1', { invoiceId: 'in_ewv' }) assert.equal(retry.status, 200) assert.equal(stored('b1').category, 'rechnung') assert.equal(st.invoices.get('in_ewv').status, 'paid') // pay: ein gescheiterter und ein erfolgreicher Aufruf; der Vermerk wurde nur einmal gesetzt assert.equal(payCalls().length, 2) assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1) }) test('offene Rechnung mit dem Vermerk einer anderen Buchung: Vermerk wird ueberschrieben', async () => { seedTx('b2') st.invoices.set('in_ewv', rawInvoice({ metadata: { bankTransactionId: 'b1-alt', bankBookingDate: '2026-09-01' } })) assert.equal((await zuordnen('b2', { invoiceId: 'in_ewv' })).status, 200) assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b2') assert.equal(st.invoices.get('in_ewv').metadata.bankBookingDate, BOOKING) }) test('bezahlte Rechnung mit dem Vermerk einer anderen Buchung -> 409 nicht mehr offen', async () => { seedTx('b2') st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } })) const res = await zuordnen('b2', { invoiceId: 'in_ewv' }) assert.equal(res.status, 409) assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.') assert.equal(stored('b2').category, '') }) test('Appwrite scheitert nach pay: 502 mit klarer Meldung, die Wiederholung schliesst ab ohne zweites pay (Rechnung ohne paid_out_of_band wie bei Stripe) (M1)', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) const error = mock.method(console, 'error', () => {}) aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' }) const failed = await zuordnen('b1', { invoiceId: 'in_ewv' }) assert.equal(failed.status, 502) assert.equal(failed.body.error, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.') assert.deepEqual(Object.keys(failed.body), ['error']) assert.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt')) // die Ursache steht im Protokoll (Art und Status), nie ihre Meldung assert.equal(error.mock.callCount(), 1) assert.equal(error.mock.calls[0].arguments[0], '[finanzen] Buchung zuordnen fehlgeschlagen:') assert.ok(!JSON.stringify(error.mock.calls).includes('Appwrite kaputt')) mock.restoreAll() assert.equal(stored('b1').category, '') assert.equal(st.invoices.get('in_ewv').status, 'paid') // wie die echte API: an der bezahlten Rechnung steht nichts von paid_out_of_band assert.equal(st.invoices.get('in_ewv').paid_out_of_band, undefined) const retry = await zuordnen('b1', { invoiceId: 'in_ewv' }) assert.equal(retry.status, 200) assert.equal(stored('b1').category, 'rechnung') assert.equal(stored('b1').matchedInvoiceId, 'in_ewv') assert.equal(stored('b1').matchedInvoiceNumber, 'WK-0005') assert.equal(payCalls().length, 1) assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1) }) test('Appwrite scheitert beim Zuordnen ohne Rechnung (Kategorie): weiter ein blanker 500 (M1)', async () => { seedTx('b1') mock.method(console, 'error', () => {}) aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' }) const failed = await zuordnen('b1', { category: 'privat' }) assert.equal(failed.status, 500) assert.equal(failed.body.error, 'Interner Fehler.') }) test('bezahlt und gekennzeichnet, aber in anderer Hoehe bezahlt als die Buchung -> keine Wiederaufnahme, 409 nicht mehr offen (M2)', async () => { seedTx('b1', { amountCents: 10000 }) st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } })) 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(stored('b1').category, '') assert.equal(payCalls().length, 0) }) test('Buchung gehoert schon zu einer anderen bezahlten Rechnung -> 409, kein Vermerk, kein pay (M2)', async () => { seedTx('b1') // in_alt wurde fuer b1 bezahlt, aber das Speichern in Appwrite scheiterte: b1 steht noch ohne Kategorie da st.invoices.set('in_alt', rawInvoice({ id: 'in_alt', number: 'WK-0004', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } })) st.invoices.set('in_ewv', rawInvoice()) const res = await zuordnen('b1', { invoiceId: 'in_ewv' }) assert.equal(res.status, 409) assert.equal(res.body.error, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.') assert.equal(payCalls().length, 0) assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 0, 'kein Vermerk') assert.equal(st.invoices.get('in_ewv').status, 'open') assert.equal(stored('b1').category, '') // die Pruefung liest frisch von Stripe assert.ok(callsTo('GET', '/v1/invoices').length >= 1) // dieselbe Rechnung noch einmal zuordnen (Wiederaufnahme) bleibt moeglich const resume = await zuordnen('b1', { invoiceId: 'in_alt' }) assert.equal(resume.status, 200, JSON.stringify(resume.body)) assert.equal(stored('b1').matchedInvoiceId, 'in_alt') assert.equal(payCalls().length, 0) }) test('Buchung ist nur an einer offenen oder fremden Rechnung vermerkt -> kein Hindernis (M2)', async () => { seedTx('b1') seedTx('b2') // offene Rechnung mit Vermerk von b1 (pay scheiterte frueher) und eine bezahlte mit dem Vermerk von b2 st.invoices.set('in_offen', rawInvoice({ id: 'in_offen', number: 'WK-0003', amount_remaining: 5000, total: 5000, metadata: { bankTransactionId: 'b1' } })) st.invoices.set('in_b2', rawInvoice({ id: 'in_b2', number: 'WK-0002', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b2' } })) st.invoices.set('in_ewv', rawInvoice()) assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200) assert.equal(payCalls().length, 1) }) test('Zuordnung zu einer Rechnung schreibt eine Protokollzeile mit Buchung, Rechnungsnummer und Nutzer, ohne Betrag (M3)', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) const log = mock.method(console, 'log', () => {}) assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200) assert.deepEqual(log.mock.calls.map((c) => c.arguments), [['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com']]) assert.ok(!log.mock.calls[0].arguments[0].includes('168')) }) test('Wiederaufnahme schreibt die Protokollzeile einmal beim Abschluss, nicht beim Scheitern (M3)', async () => { seedTx('b1') st.invoices.set('in_ewv', rawInvoice()) mock.method(console, 'error', () => {}) const log = mock.method(console, 'log', () => {}) aw.failNext({ collection: TX(), method: 'PATCH', status: 500 }) assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 502) assert.equal(log.mock.callCount(), 0) assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200) assert.deepEqual(log.mock.calls.map((c) => c.arguments[0]), ['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com']) }) test('Auszahlung, Fixkosten und Kategorie schreiben keine Zuordnungs-Protokollzeile (umkehrbar) (M3)', async () => { seedTx('b1') const log = mock.method(console, 'log', () => {}) assert.equal((await zuordnen('b1', { category: 'privat' })).status, 200) assert.equal(log.mock.callCount(), 0) }) // ---------- Vorgemerkte Buchungen ---------- test('vorgemerkte Buchung: Rechnung und Auszahlung -> 409 vor jedem Stripe-Aufruf', async () => { seedTx('p1', { status: 'PDNG' }) seedTx('p2', { status: 'PDNG', amountCents: 45000 }) st.invoices.set('in_ewv', rawInvoice()) st.payouts.set('po_1', rawPayout()) const message = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.' const invoice = await zuordnen('p1', { invoiceId: 'in_ewv' }) assert.equal(invoice.status, 409) assert.equal(invoice.body.error, message) const payout = await zuordnen('p2', { payoutId: 'po_1' }) assert.equal(payout.status, 409) assert.equal(payout.body.error, message) assert.equal(st.calls.length, 0) assert.equal(stored('p1').category, '') assert.equal(stored('p2').category, '') }) test('vorgemerkte Buchung: Kategorien und Fixkosten bleiben moeglich', async () => { seedTx('p1', { status: 'PDNG', amountCents: 700 }) seedTx('p2', { status: 'PDNG', amountCents: -449 }) seedTx('p3', { status: 'PDNG', amountCents: -300 }) aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true }) assert.equal((await zuordnen('p1', { category: 'rechnung-extern' })).status, 200) assert.equal((await zuordnen('p2', { fixedCostId: 'fk1' })).status, 200) assert.equal((await zuordnen('p3', { category: 'privat' })).status, 200) assert.equal(st.calls.length, 0) }) // ---------- 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, []) }) }