Files
ClaudeandClaude Sonnet 5.5 ef2ce49669 Finanzen: Start-Abruf nur bei altem Stand, unlesbare Buchungen ueberspringen, Mail bei Widerruf des Bankzugangs
I1: shouldRunStartupSync; der Abruf 2 Minuten nach dem Start entfaellt, wenn der letzte erfolgreiche
Abruf weniger als 5,5 Stunden her ist (Deployments verbrauchten das Tageslimit der Bank).
M4: eine unlesbare Buchung wird uebersprungen und gezaehlt, der Abruf endet ok.
M10: 401/403 beim Abruf sendet T18 einmal (Stufe 0 in warnedThresholds).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

690 lines
33 KiB
JavaScript

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'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie aboRoutes.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-sync-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const ACCOUNT = 'konto-uid-4711'
const SESSION = 'sess-geheim-4711'
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
fake.accounts.set(ACCOUNT, { uid: ACCOUNT, identification_hash: 'h1', account_id: { iban: 'DE00000000000000009313' }, name: 'Webklar', currency: 'EUR' })
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 { syncBank, transactionDocId, toTransactionDoc, shouldRunStartupSync, checkBankConsent } = await import('../server/services/finance/bankSync.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(async () => {
setMailTransportForTests(null)
await Promise.all([fake.close(), aw.close(), st.close()])
fs.rmSync(keyDir, { recursive: true, force: true })
})
// "Jetzt" der Abrufe: 01.10.2026 11:00 Berliner Zeit, heute - 89 Tage = 04.07.2026
const NOW = new Date('2026-10-01T09:00:00Z')
const TODAY = '2026-10-01'
const WINDOW_START = '2026-07-04'
/** Rohbuchung in der Form von Enable Banking (Finom). */
function raw({ ref, date, amount, indicator = 'DBIT', name = '', iban = null, mcc = null, remittance = [], status = 'BOOK', extra = {} }) {
const party = indicator === 'CRDT' ? 'debtor' : 'creditor'
return {
...(ref ? { entry_reference: ref } : {}),
status,
booking_date: date,
value_date: date,
transaction_amount: { amount, currency: 'EUR' },
credit_debit_indicator: indicator,
[party]: { name },
...(iban ? { [`${party}_account`]: { iban } } : {}),
...(mcc ? { merchant_category_code: mcc } : {}),
remittance_information: remittance,
...extra,
}
}
// Die neun Buchungen des echten Abrufs vom 01.10.2026 (IBANs gekuerzt/ersetzt)
const nine = () => [
raw({ ref: 'r1', date: '2026-08-05', amount: '168.79', indicator: 'CRDT', name: 'EWV SOFTWARE GMBH', iban: 'DE00000000000000008511', remittance: ['Rechnung 0005 vom 28.07.2026'] }),
raw({ ref: 'r2', date: '2026-08-16', amount: '43.58', name: 'OPENROUTER, INC', mcc: '5734' }),
raw({ ref: 'r3', date: '2026-09-15', amount: '1.31', name: 'PNL Fintech B.V.', iban: 'NL00000000000000003855' }),
raw({ ref: 'r4', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r5', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r6', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r7', date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
raw({ ref: 'r8', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
raw({ ref: 'r9', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
]
const balances = () => [
{ balance_type: 'ITAV', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'CLBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-09-30' },
]
const docId = (ref) => transactionDocId({ entry_reference: ref })
const txDocs = () => aw.docs(config.collections.bankTransactions)
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix))
/** Leerer Ausgangszustand: keine Buchungen, keine Verbindung, Nachbau mit den neun Buchungen und Salden. */
async function reset({ transactions = nine(), bal = balances() } = {}) {
for (const id of txDocs().keys()) await store.deleteTransaction(id)
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
fake.calls.length = 0
mails.sent.length = 0
fake.transactions.set(ACCOUNT, transactions)
fake.balances.set(ACCOUNT, bal)
}
const connect = (patch = {}) =>
store.saveConnection({ status: 'aktiv', sessionId: SESSION, accountUid: ACCOUNT, validUntil: '2027-03-29T00:00:00.000Z', ...patch })
// ---------- Aufbereitung einer Buchung ----------
test('toTransactionDoc: Eingang mit Zahler, IBAN und Verwendungszweck', () => {
const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: '2026-10-01T09:00:00.000Z' })
assert.equal(doc.$id, docId('r1'))
assert.equal(doc.amountCents, 16879)
assert.equal(doc.counterpartyName, 'EWV SOFTWARE GMBH')
assert.equal(doc.counterpartyIban, 'DE00000000000000008511')
assert.equal(doc.remittance, 'Rechnung 0005 vom 28.07.2026')
assert.equal(doc.status, 'BOOK')
assert.equal(doc.bookingDate, '2026-08-05')
assert.equal(doc.valueDate, '2026-08-05')
assert.equal(doc.currency, 'EUR')
assert.equal(doc.mcc, '')
assert.equal(doc.entryReference, 'r1')
assert.equal(doc.accountUid, ACCOUNT)
assert.equal(doc.syncedAt, '2026-10-01T09:00:00.000Z')
})
test('toTransactionDoc: Kartenzahlung ist negativ, hat MCC und keine IBAN', () => {
const doc = toTransactionDoc(nine()[7], { accountUid: ACCOUNT, syncedAt: 'x' })
assert.equal(doc.amountCents, -449)
assert.equal(doc.mcc, '4816')
assert.equal(doc.counterpartyName, 'PORKBUN.COM')
assert.equal(doc.counterpartyIban, '')
assert.equal(doc.remittance, '')
// halbe Euro: 0.5 -> 50 Cent
assert.equal(toTransactionDoc(nine()[3], { accountUid: ACCOUNT, syncedAt: 'x' }).amountCents, -50)
})
test('toTransactionDoc: Ausgang liest den Empfaenger, Eingang den Zahler (nicht die eigene Seite)', () => {
const dbit = raw({ ref: 'x', date: '2026-09-01', amount: '10', name: 'EMPFAENGER' })
dbit.debtor = { name: 'WEBKLAR (eigene Seite)' }
assert.equal(toTransactionDoc(dbit, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'EMPFAENGER')
const crdt = raw({ ref: 'y', date: '2026-09-01', amount: '10', indicator: 'CRDT', name: 'ZAHLER' })
crdt.creditor = { name: 'WEBKLAR (eigene Seite)' }
assert.equal(toTransactionDoc(crdt, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'ZAHLER')
})
test('toTransactionDoc: IBAN auch aus other.identification, mehrere Zweckzeilen, Kuerzung auf 1000', () => {
const sepa = raw({ ref: 'a', date: '2026-09-01', amount: '1', name: 'Konto ohne IBAN' })
sepa.creditor_account = { other: { identification: 'ABC123', scheme_name: 'BBAN' } }
assert.equal(toTransactionDoc(sepa, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyIban, 'ABC123')
const lines = raw({ ref: 'b', date: '2026-09-01', amount: '1', name: 'N', remittance: ['Rechnung 1', 'Kunde 2'] })
assert.equal(toTransactionDoc(lines, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance, 'Rechnung 1 Kunde 2')
const long = raw({ ref: 'c', date: '2026-09-01', amount: '1', name: 'N', remittance: ['x'.repeat(1500)] })
assert.equal(toTransactionDoc(long, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance.length, 1000)
})
test('toTransactionDoc: Feldlaengen passen zum Schema, Vormerkung ohne Buchungsdatum nimmt das Wertstellungsdatum', () => {
const pending = raw({ ref: 'p', date: undefined, amount: '2.5', name: 'N'.repeat(400), status: 'PDNG' })
delete pending.booking_date
pending.value_date = '2026-09-30'
const doc = toTransactionDoc(pending, { accountUid: ACCOUNT, syncedAt: 'x' })
assert.equal(doc.bookingDate, '2026-09-30')
assert.equal(doc.status, 'PDNG')
assert.equal(doc.counterpartyName.length, 255)
})
test('toTransactionDoc: unbekannte Richtung oder kaputter Betrag wird abgelehnt statt geraten', () => {
const noDirection = nine()[1]
delete noDirection.credit_debit_indicator
assert.throws(() => toTransactionDoc(noDirection, { accountUid: ACCOUNT, syncedAt: 'x' }), /Richtung/)
const broken = nine()[1]
broken.transaction_amount.amount = '4,49 EUR'
assert.throws(() => toTransactionDoc(broken, { accountUid: ACCOUNT, syncedAt: 'x' }), /Betrag/)
})
test('transactionDocId: gleiche entry_reference -> gleiche ID, 36 Hex-Zeichen, Hash der Referenz', () => {
const a = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-01-01' })
const b = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-02-02', transaction_amount: { amount: '9.99' } })
assert.equal(a, b)
assert.match(a, /^[a-f0-9]{36}$/)
assert.equal(a, crypto.createHash('sha256').update('ref-1').digest('hex').slice(0, 36))
assert.notEqual(a, transactionDocId({ entry_reference: 'ref-2' }))
})
test('transactionDocId: ohne entry_reference zaehlt transaction_id, danach Datum|Betrag|Gegenpartei|Zweck', () => {
const viaTransactionId = transactionDocId({ transaction_id: 't-77', booking_date: '2026-01-01' })
assert.equal(viaTransactionId, crypto.createHash('sha256').update('t-77').digest('hex').slice(0, 36))
const base = raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] })
const id = transactionDocId(base)
assert.match(id, /^[a-f0-9]{36}$/)
// stabil: gleiche Angaben (auch anders formatierter Betrag) -> gleiche ID
assert.equal(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.490', name: 'PORKBUN.COM', remittance: ['Domain'] })))
// jede der vier Angaben aendert die ID, ebenso die Richtung
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-28', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.50', name: 'PORKBUN.COM', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'ANDERER', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Anderes'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', indicator: 'CRDT', name: 'PORKBUN.COM', remittance: ['Domain'] })))
})
// ---------- bankStore ----------
test('bankStore: Verbindung anlegen, aktualisieren, fehlende Verbindung ist null', async () => {
await reset()
assert.equal(await store.getConnection(), null)
const created = await store.saveConnection({ status: 'aktiv', accountUid: ACCOUNT, sessionId: SESSION })
assert.equal(created.$id, 'finom')
assert.equal((await store.getConnection()).accountUid, ACCOUNT)
const updated = await store.saveConnection({ lastError: 'x', lastSyncOk: false })
assert.equal(updated.lastError, 'x')
assert.equal(updated.status, 'aktiv')
assert.equal(aw.docs(config.collections.bankConnections).size, 1)
})
test('bankStore: upsertTransaction legt neu an und aktualisiert nur Bankfelder', async () => {
await reset()
const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: 'erst' })
assert.equal(await store.upsertTransaction(doc), 'neu')
const created = await store.getTransaction(doc.$id)
// Zuordnungsfelder starten leer
for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId', 'note']) assert.equal(created[key], '')
await store.updateTransaction(doc.$id, { category: 'rechnung-extern', note: 'x', matchedInvoiceId: 'in_1' })
assert.equal(await store.upsertTransaction({ ...doc, remittance: 'neuer Zweck', syncedAt: 'zweit', category: 'privat', note: 'ueberschrieben?' }), 'aktualisiert')
const after = await store.getTransaction(doc.$id)
assert.equal(after.remittance, 'neuer Zweck')
assert.equal(after.syncedAt, 'zweit')
assert.equal(after.category, 'rechnung-extern')
assert.equal(after.note, 'x')
assert.equal(after.matchedInvoiceId, 'in_1')
assert.equal(txDocs().size, 1)
})
test('bankStore: listTransactions neueste zuerst, mit Limit; getTransaction null; deleteTransaction', async () => {
await reset()
for (const r of nine()) await store.upsertTransaction(toTransactionDoc(r, { accountUid: ACCOUNT, syncedAt: 'x' }))
const all = await store.listTransactions()
assert.equal(all.length, 9)
const dates = all.map((t) => t.bookingDate)
assert.deepEqual(dates, [...dates].sort().reverse())
assert.equal(dates[0], '2026-09-29')
assert.equal((await store.listTransactions({ limit: 2 })).length, 2)
assert.equal(await store.getTransaction('gibtesnicht'), null)
await store.deleteTransaction(all[0].$id)
assert.equal(txDocs().size, 8)
})
// ---------- syncBank ----------
test('syncBank: ohne Verbindung oder bei abgelaufenem Zugang kein API-Aufruf', async () => {
await reset()
assert.deepEqual(await syncBank({ now: NOW }), { ok: false, neu: 0, aktualisiert: 0, geloescht: 0, error: 'Kein aktiver Bankzugang' })
await connect({ status: 'abgelaufen' })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(result.error, 'Kein aktiver Bankzugang')
assert.equal(fake.calls.length, 0)
assert.equal(connection().status, 'abgelaufen')
})
test('syncBank: erster Abruf speichert alle Buchungen, Saldo und jüngstes Buchungsdatum', async () => {
await reset()
await connect()
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 9, aktualisiert: 0, geloescht: 0 })
assert.equal(txDocs().size, 9)
const conn = connection()
assert.equal(conn.balanceBookedCents, 10893)
assert.equal(conn.balanceAvailableCents, 10893)
assert.equal(conn.balanceDate, '2026-10-01')
assert.equal(conn.latestBookingDate, '2026-09-29')
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.lastError, '')
assert.equal(conn.lastSyncAt, NOW.toISOString())
assert.equal(conn.status, 'aktiv')
// Zugangsdaten bleiben unberuehrt
assert.equal(conn.sessionId, SESSION)
assert.equal(conn.accountUid, ACCOUNT)
// ohne bekannte Buchung: heute - 89 Tage
assert.equal(addDays(berlinDate(NOW), -89), WINDOW_START)
const txCalls = callsTo('/transactions')
assert.equal(txCalls.length, 1)
assert.equal(txCalls[0].query.date_from, WINDOW_START)
// Konto-UID in der Anfrage, nichts anderes als die gespeicherte
assert.equal(txCalls[0].path, `/accounts/${ACCOUNT}/transactions`)
const ewv = txDocs().get(docId('r1'))
assert.equal(ewv.amountCents, 16879)
assert.equal(ewv.counterpartyName, 'EWV SOFTWARE GMBH')
assert.equal(txDocs().get(docId('r8')).amountCents, -449)
})
test('syncBank: Abrufzeitraum ab letzter Buchung minus 7 Tage, nie weiter zurück als 89 Tage', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-22')
// sehr alte letzte Buchung: Untergrenze heute - 89 Tage
await store.saveConnection({ latestBookingDate: '2026-01-01' })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START)
// letzte Buchung heute: sieben Tage zurueck
await store.saveConnection({ latestBookingDate: TODAY })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-24')
})
test('syncBank: "heute" ist der Berliner Tag (kurz nach Mitternacht Berliner Zeit)', async () => {
await reset()
await connect()
// 30.09. 22:30 UTC = 01.10. 00:30 in Berlin
await syncBank({ now: new Date('2026-09-30T22:30:00Z') })
assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START)
// 30.09. 21:30 UTC = 30.09. 23:30 in Berlin -> ein Tag frueher
await reset()
await connect()
await syncBank({ now: new Date('2026-09-30T21:30:00Z') })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-07-03')
})
test('syncBank: zweiter Abruf mit denselben Buchungen legt nichts neu an und lässt Zuordnung und Notiz stehen', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
const id = docId('r1')
await store.updateTransaction(id, { category: 'rechnung-extern', note: 'x', matchedInvoiceNumber: '0005' })
const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') })
assert.equal(result.ok, true)
assert.equal(result.neu, 0)
assert.equal(result.geloescht, 0)
assert.equal(txDocs().size, 9)
const doc = txDocs().get(id)
assert.equal(doc.category, 'rechnung-extern')
assert.equal(doc.note, 'x')
assert.equal(doc.matchedInvoiceNumber, '0005')
assert.equal(doc.syncedAt, '2026-10-01T15:00:00.000Z')
})
test('syncBank: geänderte Bankfelder werden übernommen (Zweck, Gegenpartei, Datum)', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
const changed = nine()
changed[0].remittance_information = ['Rechnung 0005 vom 28.07.2026 (korrigiert)']
changed[1].booking_date = '2026-08-17'
fake.transactions.set(ACCOUNT, changed)
const result = await syncBank({ now: NOW })
assert.equal(result.neu, 0)
assert.equal(txDocs().get(docId('r1')).remittance, 'Rechnung 0005 vom 28.07.2026 (korrigiert)')
assert.equal(txDocs().get(docId('r2')).bookingDate, '2026-08-17')
})
test('syncBank: Vormerkung verschwindet, die Buchung erscheint einmal', async () => {
await reset({ transactions: [...nine(), raw({ ref: 'p1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT', status: 'PDNG' })] })
await connect()
const first = await syncBank({ now: NOW })
assert.equal(first.neu, 10)
assert.equal(txDocs().get(docId('p1')).status, 'PDNG')
// eine Vormerkung zaehlt nicht als jüngste Buchung
assert.equal(connection().latestBookingDate, '2026-09-29')
fake.transactions.set(ACCOUNT, [...nine(), raw({ ref: 'b1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT' })])
const second = await syncBank({ now: NOW })
assert.equal(second.ok, true)
assert.equal(second.geloescht, 1)
assert.equal(second.neu, 1)
assert.equal(txDocs().has(docId('p1')), false)
assert.equal(txDocs().get(docId('b1')).status, 'BOOK')
assert.equal(txDocs().size, 10)
assert.equal(connection().latestBookingDate, '2026-09-30')
})
test('syncBank: gleiche entry_reference bei Vormerkung und Buchung -> ein Dokument, Status wechselt auf BOOK', async () => {
await reset({ transactions: [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N', status: 'PDNG' })] })
await connect()
await syncBank({ now: NOW })
fake.transactions.set(ACCOUNT, [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N' })])
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 0, aktualisiert: 1, geloescht: 0 })
assert.equal(txDocs().size, 1)
assert.equal(txDocs().get(docId('same')).status, 'BOOK')
})
test('syncBank: scheitert die Suche nach Vormerkungen, wird im Speicher gefiltert und der Abruf endet trotzdem ok', async () => {
await reset({ transactions: [...nine(), raw({ ref: 'p1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT', status: 'PDNG' })] })
await connect()
assert.equal((await syncBank({ now: NOW })).neu, 10)
assert.equal(txDocs().get(docId('p1')).status, 'PDNG')
// die Bank nennt die Vormerkung nicht mehr; die Suche nach status=PDNG (erste Lese-Anfrage an bankTransactions) scheitert
fake.transactions.set(ACCOUNT, nine())
aw.failNext({ collection: config.collections.bankTransactions, method: 'GET', status: 400, message: 'Nachbau: Index status fehlt' })
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 0, aktualisiert: 9, geloescht: 1 })
assert.equal(txDocs().has(docId('p1')), false)
assert.equal(txDocs().size, 9)
assert.equal(connection().lastSyncOk, true)
assert.equal(connection().lastError, '')
})
test('syncBank: Vormerkungen vor dem Abrufzeitraum bleiben, gebuchte Dokumente werden nie gelöscht', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
// alte Vormerkung ausserhalb des Abrufzeitraums (ab 2026-09-22) und eine innerhalb, die die Bank nicht mehr nennt
aw.seed(config.collections.bankTransactions, { $id: 'alt-vormerkung', status: 'PDNG', bookingDate: '2026-08-01', amountCents: -100 })
aw.seed(config.collections.bankTransactions, { $id: 'neu-vormerkung', status: 'PDNG', bookingDate: '2026-09-25', amountCents: -100 })
// Buchung, die die Bank (im Zeitraum) nicht mehr liefert, bleibt: nur Vormerkungen werden gelöscht
fake.transactions.set(ACCOUNT, nine().slice(5))
const result = await syncBank({ now: NOW })
assert.equal(result.geloescht, 1)
assert.equal(txDocs().has('alt-vormerkung'), true)
assert.equal(txDocs().has('neu-vormerkung'), false)
assert.equal(txDocs().has(docId('r1')), true)
})
test('syncBank: Saldo-Auswahl ITBD vor CLBD, ITAV für verfügbar, Datum des gebuchten Saldos', async () => {
await reset({
transactions: [],
bal: [
{ balance_type: 'CLBD', balance_amount: { amount: '100.00', currency: 'EUR' }, reference_date: '2026-09-30' },
{ balance_type: 'ITAV', balance_amount: { amount: '90.50', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'ITBD', balance_amount: { amount: '120.25', currency: 'EUR' }, reference_date: '2026-10-01' },
],
})
await connect()
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().balanceBookedCents, 12025)
assert.equal(connection().balanceAvailableCents, 9050)
assert.equal(connection().balanceDate, '2026-10-01')
// nur CLBD: gilt als gebuchter Saldo
fake.balances.set(ACCOUNT, [{ balance_type: 'CLBD', balance_amount: { amount: '-5.10', currency: 'EUR' }, reference_date: '2026-09-30' }])
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().balanceBookedCents, -510)
assert.equal(connection().balanceDate, '2026-09-30')
})
test('syncBank: 401 beim Abruf -> Zugang abgelaufen, Buchungen bleiben, danach kein API-Aufruf mehr', async () => {
await reset()
await connect()
assert.equal((await syncBank({ now: NOW })).ok, true)
fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') })
assert.equal(result.ok, false)
assert.equal(result.error, 'Bankzugang abgelaufen oder widerrufen')
assert.equal(connection().status, 'abgelaufen')
assert.equal(connection().lastSyncOk, false)
assert.equal(connection().lastError, 'Bankzugang abgelaufen oder widerrufen')
// letzter erfolgreicher Abruf bleibt stehen
assert.equal(connection().lastSyncAt, NOW.toISOString())
assert.equal(txDocs().size, 9)
const callsBefore = fake.calls.length
const third = await syncBank({ now: NOW })
assert.equal(third.ok, false)
assert.equal(fake.calls.length, callsBefore)
})
test('syncBank: 403 bei den Umsätzen gilt ebenfalls als abgelaufen', async () => {
await reset()
await connect()
fake.failNext({ path: /\/transactions$/, status: 403, body: { code: 403, error: 'ACCESS_DENIED' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'abgelaufen')
})
test('syncBank: 500 -> Status bleibt aktiv, Fehler gemerkt, keine Zugangsdaten in der Meldung', async () => {
await reset()
await connect()
fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.ok(result.error.length > 0)
const conn = connection()
assert.equal(conn.status, 'aktiv')
assert.equal(conn.lastSyncOk, false)
assert.equal(conn.lastError, result.error)
assert.match(conn.lastError, /500/)
for (const secret of [ACCOUNT, SESSION, keyPath]) {
assert.ok(!result.error.includes(secret), `Meldung enthält ${secret}`)
assert.ok(!conn.lastError.includes(secret), `lastError enthält ${secret}`)
}
// nächster Abruf läuft normal weiter und räumt den Fehler weg
const retry = await syncBank({ now: NOW })
assert.equal(retry.ok, true)
assert.equal(connection().lastSyncOk, true)
assert.equal(connection().lastError, '')
})
test('syncBank: Fehler bei den Umsätzen lässt den Saldo der Verbindung unverändert', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '5.00', currency: 'EUR' }, reference_date: '2026-10-02' }])
fake.failNext({ path: /\/transactions$/, status: 500 })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().balanceBookedCents, 10893)
assert.equal(connection().lastSyncOk, false)
})
test('syncBank: wirft nie - fehlerhafter Saldo, Netzwerkfehler, Appwrite-Fehler', async () => {
// fehlerhafter Saldo
await reset({ bal: [{ balance_type: 'ITBD', balance_amount: { amount: 'kaputt', currency: 'EUR' }, reference_date: '2026-10-01' }] })
await connect()
let result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().lastSyncOk, false)
// Netzwerkabbruch
await reset()
await connect()
fake.failNext({ path: /\/balances$/, destroy: true })
result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'aktiv')
// Appwrite scheitert beim Speichern einer Buchung
await reset()
await connect()
aw.failNext({ collection: config.collections.bankTransactions, method: 'PATCH', status: 500, message: 'Nachbau: Appwrite gestoert' })
result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().lastSyncOk, false)
assert.match(connection().lastError, /Appwrite gestoert/)
})
test('syncBank: eine unlesbare Buchung wird übersprungen und gezählt, der Abruf endet ok (M4)', async () => {
const broken = nine()
broken[4].transaction_amount.amount = 'abc'
broken[5].credit_debit_indicator = 'XXXX'
await reset({ transactions: broken })
await connect()
const result = await syncBank({ now: NOW })
assert.equal(result.ok, true)
assert.equal(result.neu, 7)
assert.equal(txDocs().size, 7, 'die sieben lesbaren Buchungen sind gespeichert')
assert.ok(!txDocs().has(docId('r5')) && !txDocs().has(docId('r6')))
const conn = connection()
assert.equal(conn.status, 'aktiv')
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.lastError, '2 Buchung(en) übersprungen (unlesbar).')
assert.equal(conn.balanceBookedCents, 10893)
// der nächste Abruf ohne kaputte Buchung räumt die Meldung weg
fake.transactions.set(ACCOUNT, nine())
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().lastError, '')
})
test('shouldRunStartupSync: frischer erfolgreicher Abruf (unter 5,5 Stunden) überspringt den Start-Abruf (I1)', () => {
const at = (hoursAgo) => new Date(NOW.getTime() - hoursAgo * 3_600_000).toISOString()
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(1) }, NOW), false)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(5.4) }, NOW), false)
// ab 5,5 Stunden läuft der Abruf wieder (der 6-Stunden-Takt wäre sonst fast eine Stunde zu spät)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(5.5) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(20) }, NOW), true)
// ein fehlgeschlagener oder nie gelaufener Abruf wird beim Start wiederholt
assert.equal(shouldRunStartupSync({ lastSyncOk: false, lastSyncAt: at(1) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncAt: at(1) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: 'kein Datum' }, NOW), true)
assert.equal(shouldRunStartupSync(null, NOW), true)
})
test('syncBank: 401 -> einmalige Mail T18 "abgelaufen oder widerrufen", Stufe 0 gemerkt, checkBankConsent mailt nicht noch einmal (M10)', async () => {
await reset()
await connect({ warnedThresholds: '30,14' })
fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'abgelaufen')
assert.equal(mails.sent.length, 1)
const [mail] = mails.sent
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom ist abgelaufen oder wurde widerrufen')
assert.match(mail.html, /Der Zugang ist bereits abgelaufen/, 'Block abgelaufen')
assert.match(mail.html, /29\.03\.2027/)
assert.ok(mail.html.includes(`${config.womsBaseUrl}/finance/einstellungen`))
assert.deepEqual(connection().warnedThresholds.split(','), ['30', '14', '0'])
// weitere Abrufe (kein API-Aufruf mehr) und die tägliche Prüfung senden nichts
await syncBank({ now: NOW })
assert.deepEqual(await checkBankConsent({ now: new Date('2027-03-30T09:00:00Z') }), { sent: null })
assert.equal(mails.sent.length, 1, 'keine zweite Mail')
})
test('syncBank: 403 meldet ebenso, ein anderer Fehler (500) und ein bereits gemeldeter Ablauf nicht (M10)', async () => {
await reset()
await connect()
fake.failNext({ path: /\/transactions$/, status: 403 })
await syncBank({ now: NOW })
assert.equal(mails.sent.length, 1)
await reset()
await connect()
fake.failNext({ path: /\/balances$/, status: 500 })
await syncBank({ now: NOW })
assert.equal(connection().status, 'aktiv')
assert.equal(mails.sent.length, 0, '500 ist kein Ablauf')
// am Ablauftag war schon gewarnt (Stufe 0): der 401 danach löst keine zweite Mail aus
await reset()
await connect({ warnedThresholds: '30,14,3,0' })
fake.failNext({ path: /\/balances$/, status: 401 })
await syncBank({ now: NOW })
assert.equal(connection().status, 'abgelaufen')
assert.equal(mails.sent.length, 0)
})
test('syncBank: kommt die Mail zum Ablauf nicht raus, bleibt Stufe 0 offen und der Abruf endet trotzdem sauber (M10)', async () => {
await reset()
await connect()
setMailTransportForTests({ sendMail: async () => Promise.reject(new Error('SMTP kaputt')) })
try {
fake.failNext({ path: /\/balances$/, status: 401 })
const result = await syncBank({ now: NOW })
assert.equal(result.error, 'Bankzugang abgelaufen oder widerrufen')
assert.equal(connection().status, 'abgelaufen')
assert.ok(!String(connection().warnedThresholds || '').split(',').includes('0'))
} finally {
setMailTransportForTests(mails)
}
})
test('syncBank: wirft auch dann nicht, wenn die Verbindung nicht geladen werden kann', async () => {
await reset()
await connect()
aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Nachbau: Verbindung nicht lesbar' })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.match(result.error, /Verbindung nicht lesbar/)
assert.equal(fake.calls.length, 0)
})
test('syncBank: PSU-Kopfzeilen werden an alle Aufrufe weitergegeben', async () => {
await reset()
await connect()
await syncBank({ now: NOW, psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } })
assert.ok(fake.calls.length >= 2)
for (const call of fake.calls) {
assert.equal(call.headers['psu-ip-address'], '203.0.113.7')
assert.equal(call.headers['psu-user-agent'], 'Mozilla/5.0 Test')
}
fake.calls.length = 0
await syncBank({ now: NOW })
for (const call of fake.calls) assert.equal(call.headers['psu-ip-address'], undefined)
})
test('syncBank: gleichzeitige Abrufe laufen nacheinander und legen keine Buchung doppelt an', async () => {
await reset()
await connect()
const [a, b] = await Promise.all([syncBank({ now: NOW }), syncBank({ now: NOW })])
assert.equal(a.ok, true)
assert.equal(b.ok, true)
assert.equal(a.neu + b.neu, 9)
assert.equal(txDocs().size, 9)
// nacheinander: erst Salden + Umsätze des ersten Abrufs, dann die des zweiten (nicht ineinander verschränkt)
assert.deepEqual(
fake.calls.map((c) => c.path.split('/').pop()),
['balances', 'transactions', 'balances', 'transactions']
)
})
test('syncBank: mehrere Seiten der Bank werden vollständig gespeichert', async () => {
await reset()
await connect()
fake.pageSize = 4
try {
const result = await syncBank({ now: NOW })
assert.equal(result.neu, 9)
assert.equal(callsTo('/transactions').length, 3)
} finally {
fake.pageSize = 100
}
})