diff --git a/server/index.js b/server/index.js index 5874453..59dae9e 100644 --- a/server/index.js +++ b/server/index.js @@ -6,7 +6,8 @@ import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' import { syncPreviewTlsOnStartup } from './services/previewDeploy.js' import { runReconcile } from './services/previewReconcile.js' -import { checkBankConsent, syncBank } from './services/finance/bankSync.js' +import { checkBankConsent, shouldRunStartupSync, syncBank } from './services/finance/bankSync.js' +import { getConnection } from './services/finance/bankStore.js' import { requireSession, sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' @@ -189,15 +190,25 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(repoAssign, 60 * 1000).unref() setInterval(repoAssign, 10 * 60 * 1000).unref() - // Finanzen (nur wenn Enable Banking eingerichtet ist): Bankabruf 2 Minuten nach dem Start und alle 6 Stunden, - // Warnung vor dem Ablauf des Bankzugangs 3 Minuten nach dem Start und taeglich. syncBank wirft nie, meldet einen - // Fehlschlag aber im Ergebnis; die Meldung enthaelt keine Sitzungs- oder Schluesselangaben. + // Finanzen (nur wenn Enable Banking eingerichtet ist): Bankabruf 2 Minuten nach dem Start (nur wenn der letzte + // nicht frisch war) und alle 6 Stunden, Warnung vor dem Ablauf des Bankzugangs 3 Minuten nach dem Start und + // taeglich. syncBank wirft nie, meldet einen Fehlschlag aber im Ergebnis; die Meldung enthaelt keine Sitzungs- + // oder Schluesselangaben. if (config.enableBanking.enabled) { const bankSync = () => syncBank() .then((r) => !r.ok && console.warn('[finanzen] Bankabruf fehlgeschlagen:', r.error)) .catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message)) - setTimeout(bankSync, 2 * 60 * 1000).unref() + // Beim Start nur abrufen, wenn der letzte Abruf nicht frisch war: Deployments starten den Server oft neu und + // wuerden sonst das Tageslimit der Bank aufbrauchen + const startupBankSync = () => + getConnection() + .then((connection) => { + if (shouldRunStartupSync(connection, new Date())) return bankSync() + console.log('[finanzen] Bankabruf beim Start übersprungen (letzter Abruf ist frisch)') + }) + .catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message)) + setTimeout(startupBankSync, 2 * 60 * 1000).unref() setInterval(bankSync, 6 * 60 * 60 * 1000).unref() const bankConsent = () => diff --git a/server/services/finance/bankSync.js b/server/services/finance/bankSync.js index 86faa66..255fc04 100644 --- a/server/services/finance/bankSync.js +++ b/server/services/finance/bankSync.js @@ -119,12 +119,18 @@ async function runSync({ now, psu }) { const balances = await getBalances(accountUid, { psu }) const rawTransactions = await getTransactions(accountUid, { dateFrom, psu }) - // Erst alles umrechnen, dann schreiben: eine kaputte Buchung der Bank bricht ab, bevor etwas gespeichert ist + // Erst alles umrechnen, dann schreiben. Eine unlesbare Buchung der Bank (kaputter Betrag, unbekannte Richtung) + // wird uebersprungen und gezaehlt, sie blockiert nicht jeden weiteren Abruf const syncedAt = now.toISOString() const docs = new Map() + let skipped = 0 for (const raw of rawTransactions) { - const doc = toTransactionDoc(raw, { accountUid, syncedAt }) - docs.set(doc.$id, doc) + try { + const doc = toTransactionDoc(raw, { accountUid, syncedAt }) + docs.set(doc.$id, doc) + } catch { + skipped += 1 + } } let latestBookingDate = latest || '' @@ -147,7 +153,7 @@ async function runSync({ now, psu }) { latestBookingDate, lastSyncAt: syncedAt, lastSyncOk: true, - lastError: '', + lastError: skipped ? `${skipped} Buchung(en) übersprungen (unlesbar).` : '', }) return { ...result, ok: true } } catch (err) { @@ -155,7 +161,8 @@ async function runSync({ now, psu }) { const expired = err instanceof EnableBankingError && (err.status === 401 || err.status === 403) const error = expired ? EXPIRED_MESSAGE : errorText(err) try { - await saveConnection({ ...(expired ? { status: 'abgelaufen' } : {}), lastSyncOk: false, lastError: error }) + const saved = await saveConnection({ ...(expired ? { status: 'abgelaufen' } : {}), lastSyncOk: false, lastError: error }) + if (expired) await notifyRevoked(saved) } catch { // Appwrite selbst gestoert: der Fehler geht trotzdem an den Aufrufer } @@ -163,6 +170,39 @@ async function runSync({ now, psu }) { } } +/** + * Ablauf oder Widerruf per Mail (T18) melden, hoechstens einmal je Freigabe: Stufe 0 steht danach in + * warnedThresholds, dann mailt auch checkBankConsent nicht mehr. War Stufe 0 schon gemeldet (am Ablauftag), bleibt es + * bei dieser Mail. Kommt die Mail nicht raus, bleibt die Stufe offen (checkBankConsent versucht es spaeter). + */ +async function notifyRevoked(connection) { + const warned = parseWarned(connection?.warnedThresholds) + if (warned.includes(0)) return + const validUntilMs = Date.parse(connection?.validUntil ?? '') + const result = await sendAdminNotice({ + template: 'T18', + values: { + ablauf_text: 'ist abgelaufen oder wurde widerrufen', + gueltig_bis: Number.isNaN(validUntilMs) ? 'unbekannt' : formatDe(berlinDate(new Date(validUntilMs))), + link: `${config.womsBaseUrl}/finance/einstellungen`, + }, + blocks: { abgelaufen: true }, + }) + if (result.sent) await saveConnection({ warnedThresholds: [...new Set([...warned, 0])].sort((a, b) => b - a).join(',') }) +} + +// Ein Abruf beim Start des Servers wird uebersprungen, wenn der letzte erfolgreiche weniger als 5,5 Stunden her ist: +// jedes Deployment startet den Server neu, und die Bank erlaubt nur etwa vier Abrufe ohne Nutzer pro Tag +const STARTUP_SYNC_MIN_AGE_MS = 5.5 * 3_600_000 + +/** Soll der Abruf nach dem Start des Servers laufen? Nein, wenn der letzte Abruf gelang und noch frisch ist. */ +export function shouldRunStartupSync(connection, now = new Date()) { + if (connection?.lastSyncOk !== true) return true + const lastMs = Date.parse(connection.lastSyncAt ?? '') + if (Number.isNaN(lastMs)) return true + return now.getTime() - lastMs >= STARTUP_SYNC_MIN_AGE_MS +} + /** * Salden und Buchungen abrufen und speichern. psu = { ip, userAgent } nur, wenn ein Mensch den Abruf ausgeloest * hat (zaehlt dann nicht auf das Tageslimit der Bank). Liefert { ok, neu, aktualisiert, geloescht, error? } und diff --git a/test/financeBankSync.test.js b/test/financeBankSync.test.js index c3d64e8..85ff648 100644 --- a/test/financeBankSync.test.js +++ b/test/financeBankSync.test.js @@ -9,6 +9,7 @@ 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 }) @@ -30,12 +31,17 @@ 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 } = await import('../server/services/finance/bankSync.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 }) }) @@ -91,6 +97,7 @@ 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) } @@ -506,23 +513,11 @@ test('syncBank: Fehler bei den Umsätzen lässt den Saldo der Verbindung unverä assert.equal(connection().lastSyncOk, false) }) -test('syncBank: wirft nie - kaputter Betrag der Bank, Netzwerkfehler, Appwrite-Fehler', async () => { - // kaputter Betrag: nichts wird geschrieben, Fehler gemerkt - const broken = nine() - broken[4].transaction_amount.amount = 'abc' - await reset({ transactions: broken }) - await connect() - let result = await syncBank({ now: NOW }) - assert.equal(result.ok, false) - assert.match(result.error, /Betrag/) - assert.equal(txDocs().size, 0) - assert.equal(connection().status, 'aktiv') - 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() - result = await syncBank({ now: NOW }) + let result = await syncBank({ now: NOW }) assert.equal(result.ok, false) assert.equal(connection().lastSyncOk, false) @@ -544,6 +539,103 @@ test('syncBank: wirft nie - kaputter Betrag der Bank, Netzwerkfehler, Appwrite-F 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()