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, []) }) }