From 6af983429348fb8429d4e1284b555bf070ed6526 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:48:16 +0000 Subject: [PATCH] Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen Co-Authored-By: Claude Sonnet 5.5 --- server/index.js | 2 + server/routes/admin/finanzen.js | 124 +++++++++ test/financeRoutesBank.test.js | 435 ++++++++++++++++++++++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 server/routes/admin/finanzen.js create mode 100644 test/financeRoutesBank.test.js diff --git a/server/index.js b/server/index.js index 39104f9..5874453 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,7 @@ import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import adminSwitchLinkRoutes from './routes/admin/switchLink.js' +import adminFinanzenRoutes from './routes/admin/finanzen.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -86,6 +87,7 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/preview-open', adminPreviewOpenRoutes) app.use('/api/admin/switch-link', adminSwitchLinkRoutes) app.use('/api/admin/analyse', adminAnalyseRoutes) +app.use('/api/admin/finanzen', adminFinanzenRoutes) app.use('/webhook', giteaWebhookRoutes) // Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token) app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes) diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js new file mode 100644 index 0000000..bdfb081 --- /dev/null +++ b/server/routes/admin/finanzen.js @@ -0,0 +1,124 @@ +import { Router } from 'express' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +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' + +/** + * Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung und Buchungen. Die Handler sind klein und rufen + * die Dienste in server/services/finance auf; weitere Finanz-Routen kommen in dieselbe Datei. + */ +const router = Router() + +router.use(requireAppwriteStaff) + +// Der Knopf "Aktualisieren" darf die Bank hoechstens alle 10 Minuten abfragen +const MANUAL_SYNC_COOLDOWN_MS = 10 * 60_000 +const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync'] + +// Eine Meldung fuer jeden Fehler auf dem Weg zur Bank (Freigabe starten, Rueckkehr): der Status der Bank +// (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server +const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.' + +/** Was die Seite vom Bankzugang sieht: nie Sitzung, IBAN, state oder Konto-Kennungen. */ +function publicConnection(connection) { + const pick = (key) => connection[key] ?? null + return { + status: pick('status'), + ibanMasked: pick('ibanMasked'), + validUntil: pick('validUntil'), + lastSyncAt: pick('lastSyncAt'), + lastSyncOk: pick('lastSyncOk'), + lastError: pick('lastError'), + balanceBookedCents: pick('balanceBookedCents'), + balanceAvailableCents: pick('balanceAvailableCents'), + balanceDate: pick('balanceDate'), + latestBookingDate: pick('latestBookingDate'), + } +} + +/** Buchung ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */ +const publicTransaction = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$'))) + +/** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */ +const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '') + +router.get('/bank', async (_req, res) => { + try { + const connection = await getConnection() + return res.json({ verbindung: connection ? publicConnection(connection) : null }) + } catch (err) { + logFailure('Bankstatus laden', err) + return res.status(500).json({ error: 'Bankstatus konnte nicht geladen werden.' }) + } +}) + +/** + * Sperre des Knopfs "Aktualisieren": prueft lastManualSyncAt und setzt es gleich, bevor der Abruf beginnt (zwei + * Klicks gleichzeitig starten so nur einen Abruf). Liefert null (frei) oder die Minuten bis zum naechsten Abruf. + * Ohne Bankzugang gibt es nichts zu sperren; der Abruf meldet das dann selbst. + */ +function claimManualSync() { + return withLocks(MANUAL_SYNC_LOCK, async () => { + const connection = await getConnection() + if (!connection) return null + const now = Date.now() + const lastMs = Date.parse(connection.lastManualSyncAt ?? '') + if (!Number.isNaN(lastMs) && now - lastMs < MANUAL_SYNC_COOLDOWN_MS) { + return Math.ceil((MANUAL_SYNC_COOLDOWN_MS - (now - lastMs)) / 60_000) + } + await saveConnection({ lastManualSyncAt: new Date(now).toISOString() }) + return null + }) +} + +router.post('/bank/aktualisieren', async (req, res) => { + try { + const waitMinutes = await claimManualSync() + if (waitMinutes !== null) { + return res.status(429).json({ error: `Bitte in ${waitMinutes} ${waitMinutes === 1 ? 'Minute' : 'Minuten'} erneut versuchen.` }) + } + // Ein Mensch loest den Abruf aus: IP und Browser gehen an die Bank (zaehlt nicht auf ihr Tageslimit) + return res.json(await syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } })) + } catch (err) { + logFailure('Bankabruf starten', err) + return res.status(500).json({ error: 'Der Bankabruf konnte nicht gestartet werden.' }) + } +}) + +router.post('/bank/verbinden', async (_req, res) => { + try { + return res.json(await startBankAuth()) + } catch (err) { + logFailure('Freigabe starten', err) + return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE }) + } +}) + +router.post('/bank/rueckkehr', async (req, res) => { + const { code, state, error } = req.body || {} + try { + const { validUntil } = await completeBankAuth({ code, state, error }, { psu: { ip: req.ip, userAgent: req.get('User-Agent') } }) + return res.json({ ok: true, validUntil }) + } catch (err) { + // 400 mit deutscher Meldung kommt aus der Pruefung der Rueckkehr (state, Ablauf, Fehler von Finom); ein 400 der + // Bank selbst (EnableBankingError, z. B. ungueltiger Code) gehoert nicht dazu + if (err?.status === 400 && !(err instanceof EnableBankingError)) return res.status(400).json({ error: err.message }) + // Alles andere (auch ein 401 der Bank beim Codetausch) ist fuer die Seite: Freigabe nicht zustande gekommen + logFailure('Rückkehr von der Bank', err) + return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE }) + } +}) + +router.get('/buchungen', async (_req, res) => { + try { + const buchungen = (await listTransactions()).map(publicTransaction) + return res.json({ buchungen }) + } catch (err) { + logFailure('Buchungen laden', err) + return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' }) + } +}) + +export default router diff --git a/test/financeRoutesBank.test.js b/test/financeRoutesBank.test.js new file mode 100644 index 0000000..58a34a6 --- /dev/null +++ b/test/financeRoutesBank.test.js @@ -0,0 +1,435 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { startFakeEnableBanking } from './support/fakeEnableBanking.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 financeBankAuth.test.js) +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) +const pem = (key, type) => key.export({ type, format: 'pem' }) +const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bank-routes-test-')) +const keyPath = path.join(keyDir, 'private.pem') +fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8')) + +const SESSION = 'sess-geheim-4711' +const ACCOUNT = 'konto-4711' +const NEW_SESSION = 'sess-neu-geheim-4712' +const NEW_ACCOUNT = 'konto-neu-1' +const IBAN = 'DE00000000000000009313' +const BANK_GONE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.' + +const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' }) +const aw = await startAppwriteMock({ + schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.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 store = await import('../server/services/finance/bankStore.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') + +const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) +after(async () => { + await Promise.all([app.close(), fake.close(), aw.close(), st.close()]) + fs.rmSync(keyDir, { recursive: true, force: true }) +}) + +const NOW = Date.now() +const MINUTE = 60_000 +const minutesAgo = (minutes) => new Date(NOW - minutes * MINUTE).toISOString() + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const base = '/api/admin/finanzen' +const connection = () => aw.docs(config.collections.bankConnections).get('finom') +const txDocs = () => aw.docs(config.collections.bankTransactions) +const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix)) + +const FULL_KEYS = [ + 'status', + 'ibanMasked', + 'validUntil', + 'lastSyncAt', + 'lastSyncOk', + 'lastError', + 'balanceBookedCents', + 'balanceAvailableCents', + 'balanceDate', + 'latestBookingDate', +] + +/** + * Ausgangszustand: aktiver Zugang mit Konto ACCOUNT, zwei Buchungen und Salden beim Nachbau; der Nachbau kennt + * ausserdem den Code 'neu' -> Sitzung NEW_SESSION (Konto NEW_ACCOUNT mit derselben IBAN). + */ +async function reset({ connected = true, stored = {} } = {}) { + for (const id of txDocs().keys()) await store.deleteTransaction(id) + await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {}) + fake.calls.length = 0 + fake.accounts.clear() + fake.sessions.clear() + fake.codes.clear() + fake.transactions.clear() + fake.balances.clear() + for (const uid of [ACCOUNT, NEW_ACCOUNT]) { + fake.accounts.set(uid, { uid, identification_hash: 'h-4711', account_id: { iban: IBAN }, name: 'Webklar', currency: 'EUR' }) + } + fake.sessions.set('s-neu', { + session_id: NEW_SESSION, + status: 'AUTHORIZED', + accounts: [NEW_ACCOUNT], + access: { valid_until: '2027-03-29T00:00:00.000Z', balances: true, transactions: true }, + aspsp: { name: 'Finom', country: 'DE' }, + psu_type: 'business', + }) + fake.codes.set('neu', 's-neu') + fake.transactions.set(ACCOUNT, [ + { + entry_reference: 'r1', + status: 'BOOK', + booking_date: '2026-09-29', + transaction_amount: { amount: '4.49', currency: 'EUR' }, + credit_debit_indicator: 'DBIT', + creditor: { name: 'PORKBUN.COM' }, + }, + ]) + fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }]) + if (!connected) return + await store.saveConnection({ + status: 'aktiv', + sessionId: SESSION, + accountUid: ACCOUNT, + accountIdHash: 'h-4711', + iban: IBAN, + ibanMasked: 'DE00…9313', + validUntil: '2027-03-29T00:00:00.000Z', + ...stored, + }) +} + +/** Freigabe starten und den gespeicherten state lesen (wie die Seite nach "Verbinden"). */ +async function begin() { + const { body } = await post(`${base}/bank/verbinden`, {}, admin) + assert.match(body.url, /fake-bank\/auth\?state=/) + return connection().authState +} + +// ---------- Zugriff ---------- + +test('ohne JWT 401, mit Mitarbeiter-JWT ohne Admin-Label 403, bei jeder Route', async () => { + await reset() + const routes = [ + ['GET', '/bank'], + ['POST', '/bank/aktualisieren'], + ['POST', '/bank/verbinden'], + ['POST', '/bank/rueckkehr'], + ['GET', '/buchungen'], + ] + for (const [method, route] of routes) { + const call = (headers) => (method === 'GET' ? rawGet(base + route, { headers }) : post(base + route, {}, { headers })) + assert.equal((await call({})).status, 401, `${method} ${route} ohne JWT`) + assert.equal((await call({ Authorization: 'Bearer jwt-unbekannt' })).status, 401, `${method} ${route} mit falschem JWT`) + assert.equal((await call({ Authorization: 'Bearer jwt-staff' })).status, 403, `${method} ${route} ohne Admin-Label`) + } + assert.equal(fake.calls.length, 0) +}) + +// ---------- GET /bank ---------- + +test('GET /bank: ohne Verbindung { verbindung: null }', async () => { + await reset({ connected: false }) + assert.deepEqual(await get(`${base}/bank`, admin), { verbindung: null }) +}) + +test('GET /bank: genau die Anzeigefelder, nie Sitzung, IBAN, state oder Konto-Kennungen', async () => { + await reset({ + stored: { + lastSyncAt: '2026-10-01T09:00:00.000Z', + lastSyncOk: true, + lastError: '', + lastManualSyncAt: '2026-10-01T08:00:00.000Z', + balanceBookedCents: 10893, + balanceAvailableCents: 10893, + balanceDate: '2026-10-01', + latestBookingDate: '2026-09-29', + authState: 'state-geheim-abc', + authStartedAt: '2026-10-01T08:30:00.000Z', + warnedThresholds: '30', + }, + }) + const res = await rawGet(`${base}/bank`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['verbindung']) + assert.deepEqual(res.body.verbindung, { + status: 'aktiv', + ibanMasked: 'DE00…9313', + validUntil: '2027-03-29T00:00:00.000Z', + lastSyncAt: '2026-10-01T09:00:00.000Z', + lastSyncOk: true, + lastError: '', + balanceBookedCents: 10893, + balanceAvailableCents: 10893, + balanceDate: '2026-10-01', + latestBookingDate: '2026-09-29', + }) + const text = JSON.stringify(res.body) + for (const secret of [SESSION, ACCOUNT, IBAN, 'h-4711', 'state-geheim-abc', 'sessionId', 'iban"', 'authState', 'accountUid', 'accountIdHash', 'authStartedAt']) { + assert.ok(!text.includes(secret), `Antwort enthält ${secret}`) + } +}) + +test('GET /bank: noch nie abgerufen -> alle Anzeigefelder vorhanden, fehlende null', async () => { + await reset() + const { verbindung } = await get(`${base}/bank`, admin) + assert.deepEqual(Object.keys(verbindung).sort(), [...FULL_KEYS].sort()) + assert.equal(verbindung.status, 'aktiv') + assert.equal(verbindung.lastSyncAt, null) + assert.equal(verbindung.balanceBookedCents, null) +}) + +// ---------- POST /bank/aktualisieren ---------- + +test('aktualisieren: zweimal hintereinander -> 200, dann 429 ohne zweiten Bankabruf', async () => { + await reset() + const first = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(first.status, 200) + assert.deepEqual(first.body, { ok: true, neu: 1, aktualisiert: 0, geloescht: 0 }) + assert.equal(txDocs().size, 1) + assert.ok(Date.now() - Date.parse(connection().lastManualSyncAt) < 10_000) + const bankCalls = fake.calls.length + + const second = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(second.status, 429) + assert.equal(second.body.error, 'Bitte in 10 Minuten erneut versuchen.') + assert.equal(fake.calls.length, bankCalls) +}) + +test('aktualisieren: die Bank bekommt IP und Browserkennung des Nutzers (Abruf durch einen Menschen)', async () => { + await reset() + const res = await post(`${base}/bank/aktualisieren`, {}, { headers: { ...admin.headers, 'User-Agent': 'WOMS-Test/1.0', 'X-Forwarded-For': '203.0.113.7' } }) + assert.equal(res.status, 200) + const [balances] = callsTo('/balances') + assert.equal(balances.headers['psu-user-agent'], 'WOMS-Test/1.0') + // trust proxy 1: die Adresse aus X-Forwarded-For + assert.equal(balances.headers['psu-ip-address'], '203.0.113.7') +}) + +test('aktualisieren: Wartezeit zählt Minuten nach oben; nach 10 Minuten geht es wieder', async () => { + await reset({ stored: { lastManualSyncAt: minutesAgo(3) } }) + const blocked = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(blocked.status, 429) + assert.equal(blocked.body.error, 'Bitte in 7 Minuten erneut versuchen.') + assert.equal(connection().lastManualSyncAt, minutesAgo(3), 'ein abgelehnter Aufruf verlängert die Sperre nicht') + + await store.saveConnection({ lastManualSyncAt: new Date(NOW - 9.5 * MINUTE).toISOString() }) + const almost = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(almost.status, 429) + assert.equal(almost.body.error, 'Bitte in 1 Minute erneut versuchen.') + + await store.saveConnection({ lastManualSyncAt: minutesAgo(10.5) }) + assert.equal((await post(`${base}/bank/aktualisieren`, {}, admin)).status, 200) +}) + +test('aktualisieren: zwei gleichzeitige Klicks -> ein Abruf, der andere 429', async () => { + await reset() + const [a, b] = await Promise.all([post(`${base}/bank/aktualisieren`, {}, admin), post(`${base}/bank/aktualisieren`, {}, admin)]) + assert.deepEqual([a.status, b.status].sort(), [200, 429]) + assert.equal(callsTo('/balances').length, 1) +}) + +test('aktualisieren: Fehler der Bank kommen als Ergebnis (200, ok false), ohne Sitzungs-ID', async () => { + await reset() + fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } }) + const res = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(res.status, 200) + assert.equal(res.body.ok, false) + assert.equal(typeof res.body.error, 'string') + assert.ok(!JSON.stringify(res.body).includes(SESSION)) +}) + +test('aktualisieren: ohne Bankzugang -> ok false, es wird keine Verbindung angelegt', async () => { + await reset({ connected: false }) + const res = await post(`${base}/bank/aktualisieren`, {}, admin) + assert.equal(res.status, 200) + assert.equal(res.body.ok, false) + assert.equal(res.body.error, 'Kein aktiver Bankzugang') + assert.equal(connection(), undefined) +}) + +// ---------- POST /bank/verbinden ---------- + +test('verbinden: liefert { url } der Bank und merkt sich state', async () => { + await reset() + const res = await post(`${base}/bank/verbinden`, {}, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['url']) + assert.match(res.body.url, /^http:\/\/127\.0\.0\.1:\d+\/fake-bank\/auth\?state=[a-f0-9]{24}$/) + assert.ok(res.body.url.endsWith(connection().authState)) + // der laufende Zugang bleibt unberührt + assert.equal(connection().sessionId, SESSION) + assert.equal(connection().status, 'aktiv') +}) + +test('verbinden: scheitert die Bank -> 502 mit fester Meldung, ohne Einzelheiten', async () => { + await reset() + fake.failNext({ path: '/auth', status: 500 }) + const res = await post(`${base}/bank/verbinden`, {}, admin) + assert.equal(res.status, 502) + assert.deepEqual(res.body, { error: BANK_GONE }) + assert.equal(connection().authState, undefined) +}) + +test('verbinden: antwortet die Bank mit 401, kommt trotzdem 502 (nie 401 durchreichen)', async () => { + await reset() + fake.failNext({ path: '/auth', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } }) + const res = await post(`${base}/bank/verbinden`, {}, admin) + assert.equal(res.status, 502) + assert.deepEqual(res.body, { error: BANK_GONE }) +}) + +// ---------- POST /bank/rueckkehr ---------- + +test('rueckkehr: falscher state -> 400 mit Meldung, Zugang bleibt', async () => { + await reset() + await begin() + const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state: 'falsch' }, admin) + assert.equal(res.status, 400) + assert.deepEqual(res.body, { error: 'Diese Rückkehr passt zu keiner gestarteten Freigabe.' }) + assert.equal(connection().sessionId, SESSION) + assert.equal(callsTo('/sessions').length, 0) +}) + +test('rueckkehr: ohne Body / ohne state -> 400', async () => { + await reset() + await begin() + assert.equal((await post(`${base}/bank/rueckkehr`, {}, admin)).status, 400) + assert.equal((await post(`${base}/bank/rueckkehr`, { code: 'neu' }, admin)).status, 400) +}) + +test('rueckkehr: Finom meldet einen Fehler -> 400 mit Meldung', async () => { + await reset() + const state = await begin() + const res = await post(`${base}/bank/rueckkehr`, { state, error: 'access_denied' }, admin) + assert.equal(res.status, 400) + assert.equal(res.body.error, 'Finom hat die Freigabe nicht erteilt (access_denied).') +}) + +test('rueckkehr: abgelaufene Freigabe -> 400', async () => { + await reset() + const state = await begin() + await store.saveConnection({ authStartedAt: minutesAgo(45) }) + const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(res.status, 400) + assert.equal(res.body.error, 'Die Freigabe ist abgelaufen. Bitte neu starten.') +}) + +test('rueckkehr: richtiger state -> { ok: true, validUntil }, Sitzung gespeichert und nie in der Antwort', async () => { + await reset() + const state = await begin() + const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { ok: true, validUntil: '2027-03-29T00:00:00.000Z' }) + const text = JSON.stringify(res.body) + assert.ok(!text.includes(NEW_SESSION) && !text.includes(SESSION)) + + assert.equal(connection().sessionId, NEW_SESSION) + assert.equal(connection().accountUid, NEW_ACCOUNT) + assert.equal(connection().status, 'aktiv') + assert.equal(connection().authState, '') + // die Rückkehr hat einmal abgerufen + assert.equal(callsTo('/balances').length, 1) + assert.equal(txDocs().size, 0, 'Nachbau kennt für das neue Konto keine Buchungen') +}) + +test('rueckkehr: scheitert der Codetausch bei der Bank (401) -> 502 mit fester Meldung, nie 401, keine Sitzungs-ID', async () => { + await reset() + const state = await begin() + fake.failNext({ path: '/sessions', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } }) + const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(res.status, 502) + assert.deepEqual(res.body, { error: BANK_GONE }) + assert.ok(!JSON.stringify(res.body).includes(SESSION)) + // der bisherige Zugang bleibt, der state gilt weiter (neuer Versuch möglich) + assert.equal(connection().sessionId, SESSION) + assert.equal(connection().authState, state) +}) + +test('rueckkehr: lehnt die Bank den Code mit 400 ab, ist das für die Seite ebenfalls 502 (nur die eigene Prüfung gibt 400)', async () => { + await reset() + const state = await begin() + fake.failNext({ path: '/sessions', status: 400, body: { code: 400, error: 'INVALID_CODE' } }) + const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(res.status, 502) + assert.deepEqual(res.body, { error: BANK_GONE }) +}) + +test('rueckkehr: jeder andere Fehler (Bank 500, Appwrite gestört) -> 502 mit fester Meldung', async () => { + await reset() + let state = await begin() + fake.failNext({ path: '/sessions', status: 500 }) + const bank = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(bank.status, 502) + assert.deepEqual(bank.body, { error: BANK_GONE }) + + state = connection().authState + aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Appwrite-Geheimnis' }) + const appwrite = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin) + assert.equal(appwrite.status, 502) + assert.deepEqual(appwrite.body, { error: BANK_GONE }) + assert.ok(!JSON.stringify(appwrite.body).includes('Appwrite-Geheimnis')) +}) + +// ---------- GET /buchungen ---------- + +test('buchungen: neueste zuerst, nur Felder der Buchung und $id', async () => { + await reset() + const row = (id, bookingDate, amountCents) => ({ + $id: id, + accountUid: ACCOUNT, + status: 'BOOK', + bookingDate, + valueDate: bookingDate, + amountCents, + currency: 'EUR', + counterpartyName: 'PORKBUN.COM', + counterpartyIban: '', + remittance: '', + mcc: '4816', + entryReference: id, + category: '', + matchedInvoiceId: '', + matchedInvoiceNumber: '', + matchedPayoutId: '', + fixedCostId: '', + note: '', + syncedAt: '2026-10-01T09:00:00.000Z', + }) + aw.seed(config.collections.bankTransactions, row('b-mitte', '2026-09-15', -449)) + aw.seed(config.collections.bankTransactions, row('b-alt', '2026-08-05', 16879)) + aw.seed(config.collections.bankTransactions, row('b-neu', '2026-09-29', -50)) + + const res = await rawGet(`${base}/buchungen`, admin) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['buchungen']) + assert.deepEqual(res.body.buchungen.map((b) => b.$id), ['b-neu', 'b-mitte', 'b-alt']) + assert.deepEqual(res.body.buchungen.map((b) => b.amountCents), [-50, -449, 16879]) + for (const buchung of res.body.buchungen) { + assert.deepEqual(Object.keys(buchung).filter((key) => key.startsWith('$')), ['$id']) + } + assert.equal(res.body.buchungen[0].counterpartyName, 'PORKBUN.COM') + assert.equal(res.body.buchungen[0].category, '') +}) + +test('buchungen: leer -> { buchungen: [] }', async () => { + await reset() + assert.deepEqual(await get(`${base}/buchungen`, admin), { buchungen: [] }) +})