import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import { withLocks } from '../keyedLock.js' import { sendAdminNotice } from '../mailer.js' import { createSession, EnableBankingError, getBalances, getTransactions, startAuth, toCents } from './enableBanking.js' import { addDays, berlinDate, daysBetween, formatDe } from './dates.js' import { deleteTransaction, getConnection, listPendingTransactions, saveConnection, upsertTransaction } from './bankStore.js' /** * Bankabruf: holt Salden und Umsaetze des Finom-Kontos ueber Enable Banking und speichert die Buchungen * idempotent in Appwrite (bankStore). Dieselbe Buchung kommt bei jedem Abruf wieder und landet im selben * Dokument; Zuordnung und Notiz des Nutzers bleiben dabei unberuehrt. Vorgemerkte Buchungen (PDNG), die die * Bank nicht mehr nennt, werden geloescht. syncBank wirft nie: jeder Fehler landet in der Verbindung (lastError). * * Ausserdem der Weg zum neuen Bankzugang (startBankAuth, completeBankAuth): Freigabe bei Finom starten, nach der * Rueckkehr die Sitzung speichern und sofort einmal abrufen, und die Warnung vor dem Ablauf des Zugangs * (checkBankConsent, Mail T18). */ // Die Bank liefert hoechstens ~90 Tage: ein Abruf beginnt spaetestens heute - 89 Tage, sonst eine Woche vor der // juengsten bekannten Buchung (Vormerkungen und nachgebuchte Eintraege der letzten Tage werden so erneut gelesen) const MAX_HISTORY_DAYS = 89 const OVERLAP_DAYS = 7 const EXPIRED_MESSAGE = 'Bankzugang abgelaufen oder widerrufen' const NO_CONNECTION_MESSAGE = 'Kein aktiver Bankzugang' // Laenge von lastError im Schema const ERROR_MAX = 500 // Abrufe nacheinander (Job und Knopf "Aktualisieren" duerfen sich nicht ueberholen) const SYNC_LOCK = ['finanzen-bank-sync'] // Feldgroessen wie im Schema (scripts/setup-finanzen.mjs): zu lange Werte werden gekuerzt statt abgelehnt, // sonst blockiert eine einzige Buchung jeden weiteren Abruf const MAX = { accountUid: 64, status: 8, name: 255, iban: 40, mcc: 8, reference: 128, remittance: 1000 } const text = (value, max) => (typeof value === 'string' || typeof value === 'number' ? String(value).trim().slice(0, max) : '') const day = (value) => (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '') /** Betrag mit Vorzeichen in Cent: Eingang (CRDT) positiv, Ausgang (DBIT) negativ. Unbekannte Richtung wird nicht geraten. */ function signedCents(raw) { const cents = Math.abs(toCents(raw.transaction_amount?.amount)) if (raw.credit_debit_indicator === 'CRDT') return cents if (raw.credit_debit_indicator === 'DBIT') return cents === 0 ? 0 : -cents throw new Error('Ungültige Buchung von der Bank (Richtung unbekannt).') } /** Die von der Bank stammenden Felder einer Buchung (ohne ID, Konto und Abrufzeit). */ function bankFields(raw) { const incoming = raw.credit_debit_indicator === 'CRDT' const party = (incoming ? raw.debtor : raw.creditor) || {} const account = (incoming ? raw.debtor_account : raw.creditor_account) || {} const remittance = [].concat(raw.remittance_information ?? []).map((line) => text(line, MAX.remittance)).filter(Boolean) return { status: text(raw.status, MAX.status), // Vormerkungen haben oft kein Buchungsdatum: dann das Wertstellungs- bzw. Buchungsdatum der Bank bookingDate: day(raw.booking_date) || day(raw.value_date) || day(raw.transaction_date), valueDate: day(raw.value_date), amountCents: signedCents(raw), currency: text(raw.transaction_amount?.currency, 3), counterpartyName: text(party.name, MAX.name), counterpartyIban: text(account.iban ?? account.other?.identification, MAX.iban), remittance: remittance.join(' ').slice(0, MAX.remittance), mcc: text(raw.merchant_category_code, MAX.mcc), entryReference: text(raw.entry_reference, MAX.reference), } } /** * Dokument-ID einer Buchung: die ersten 36 Zeichen (Appwrite-Grenze) des SHA-256 aus entry_reference, sonst * transaction_id, sonst Datum|Betrag|Gegenpartei|Verwendungszweck. Gleiche Buchung -> gleiche ID. */ export function transactionDocId(raw) { let key = raw.entry_reference || raw.transaction_id if (!key) { const fields = bankFields(raw) key = [fields.bookingDate, fields.amountCents, fields.counterpartyName, fields.remittance].join('|') } return createHash('sha256').update(String(key)).digest('hex').slice(0, 36) } /** Rohbuchung von Enable Banking als Dokument fuer bankTransactions ($id = transactionDocId, Betrag in Cent). */ export function toTransactionDoc(raw, { accountUid, syncedAt }) { return { $id: transactionDocId(raw), accountUid: text(accountUid, MAX.accountUid), ...bankFields(raw), syncedAt } } /** Gebuchter Saldo (ITBD, sonst CLBD) und verfuegbarer Saldo (ITAV) als Felder der Verbindung. */ function balanceFields(balances) { const booked = balances.find((b) => b.type === 'ITBD') ?? balances.find((b) => b.type === 'CLBD') const available = balances.find((b) => b.type === 'ITAV') const fields = {} if (booked) { fields.balanceBookedCents = booked.amountCents fields.balanceDate = day(booked.date) } if (available) fields.balanceAvailableCents = available.amountCents return fields } const errorText = (err) => String(err?.message || 'Unbekannter Fehler beim Bankabruf.').slice(0, ERROR_MAX) async function runSync({ now, psu }) { const result = { ok: false, neu: 0, aktualisiert: 0, geloescht: 0 } let connection try { connection = await getConnection() } catch (err) { return { ...result, error: errorText(err) } } const accountUid = typeof connection?.accountUid === 'string' ? connection.accountUid.trim() : '' if (!connection || connection.status === 'abgelaufen' || !accountUid) return { ...result, error: NO_CONNECTION_MESSAGE } try { const earliest = addDays(berlinDate(now), -MAX_HISTORY_DAYS) const latest = /^\d{4}-\d{2}-\d{2}$/.test(connection.latestBookingDate || '') ? connection.latestBookingDate : null const overlapStart = latest ? addDays(latest, -OVERLAP_DAYS) : earliest const dateFrom = overlapStart > earliest ? overlapStart : earliest const balances = await getBalances(accountUid, { psu }) const rawTransactions = await getTransactions(accountUid, { dateFrom, psu }) // 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) { try { const doc = toTransactionDoc(raw, { accountUid, syncedAt }) docs.set(doc.$id, doc) } catch { skipped += 1 } } let latestBookingDate = latest || '' for (const doc of docs.values()) { if ((await upsertTransaction(doc)) === 'neu') result.neu += 1 else result.aktualisiert += 1 if (doc.status === 'BOOK' && doc.bookingDate > latestBookingDate) latestBookingDate = doc.bookingDate } // Eine Vormerkung, die die Bank im Abrufzeitraum nicht mehr nennt, ist gebucht (dann unter neuer Referenz) // oder storniert: loeschen. Vormerkungen vor dem Zeitraum kann der Abruf nicht beurteilen, ohne Datum schon. for (const pending of await listPendingTransactions()) { if (docs.has(pending.$id) || (pending.bookingDate && pending.bookingDate < dateFrom)) continue await deleteTransaction(pending.$id) result.geloescht += 1 } await saveConnection({ ...balanceFields(balances), latestBookingDate, lastSyncAt: syncedAt, lastSyncOk: true, lastError: skipped ? `${skipped} Buchung(en) übersprungen (unlesbar).` : '', }) return { ...result, ok: true } } catch (err) { // 401/403: Sitzung abgelaufen oder widerrufen -> keine weiteren Abrufe bis zur Neuverbindung const expired = err instanceof EnableBankingError && (err.status === 401 || err.status === 403) const error = expired ? EXPIRED_MESSAGE : errorText(err) try { 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 } return { ...result, error } } } /** * 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 * wirft nie. Gleichzeitige Aufrufe laufen nacheinander. */ export function syncBank({ now = new Date(), psu = null } = {}) { return withLocks(SYNC_LOCK, () => runSync({ now, psu })) } // ---------- Bankzugang neu verbinden ---------- // Freigabe beim Start fuer 179 Tage (die Bank erlaubt hoechstens 180); die Rueckkehr muss binnen 30 Minuten kommen const ACCESS_DAYS = 179 const DAY_MS = 86_400_000 const AUTH_MAX_AGE_MS = 30 * 60_000 // So viel vom error der Rueckkehradresse kommt in die Meldung (der Wert stammt aus der Adresse) const AUTH_ERROR_SHOWN_MAX = 80 const badRequest = (message) => Object.assign(new Error(message), { status: 400 }) const requestedValidUntil = (startedAtMs) => new Date(startedAtMs + ACCESS_DAYS * DAY_MS).toISOString() const compactIban = (iban) => String(iban || '').replace(/\s+/g, '').toUpperCase() const queryText = (value) => (typeof value === 'string' ? value.trim() : '') function sameText(a, b) { const left = Buffer.from(a) const right = Buffer.from(b) return left.length === right.length && timingSafeEqual(left, right) } /** Erste vier und letzte vier Zeichen der IBAN, dazwischen "…" (zu kurze Werte werden nicht gezeigt). */ function maskIban(iban) { const compact = compactIban(iban) return compact.length > 8 ? `${compact.slice(0, 4)}…${compact.slice(-4)}` : '' } /** * Das Konto der neuen Freigabe: dasselbe identificationHash wie gespeichert, sonst dieselbe IBAN, sonst das erste * Euro-Konto. known = es ist das bisherige Konto. null, wenn die Freigabe kein passendes Konto enthaelt. */ function pickAccount(accounts, connection) { const usable = accounts.filter((account) => typeof account?.uid === 'string' && account.uid) const hash = text(connection?.accountIdHash, 128) const iban = compactIban(connection?.iban) const known = (hash && usable.find((account) => account.identificationHash === hash)) || (iban && usable.find((account) => compactIban(account.iban) === iban)) if (known) return { account: known, known: true } const euro = usable.find((account) => account.currency === 'EUR') return euro ? { account: euro, known: false } : null } /** * Freigabe bei Finom starten. Liefert die Adresse, zu der der Nutzer geschickt wird; state und Startzeit stehen * danach in der Verbindung (authState, authStartedAt) und gelten fuer die Rueckkehr (completeBankAuth). */ export async function startBankAuth({ now = new Date() } = {}) { const state = randomBytes(12).toString('hex') const { url } = await startAuth({ state, validUntil: requestedValidUntil(now.getTime()) }) await saveConnection({ authState: state, authStartedAt: now.toISOString() }) return { url } } /** Prueft die Rueckkehr und speichert die neue Sitzung. Laeuft unter der Sperre der Abrufe. */ async function saveReturnedSession({ code, state, error, now }) { const connection = await getConnection() const expected = text(connection?.authState, 64) if (!state || !expected || !sameText(state, expected)) throw badRequest('Diese Rückkehr passt zu keiner gestarteten Freigabe.') const startedAt = Date.parse(connection.authStartedAt) // NaN (keine lesbare Startzeit) besteht den Vergleich nicht: dann gilt die Freigabe als abgelaufen if (!(now.getTime() - startedAt <= AUTH_MAX_AGE_MS)) throw badRequest('Die Freigabe ist abgelaufen. Bitte neu starten.') if (error) throw badRequest(`Finom hat die Freigabe nicht erteilt (${error.slice(0, AUTH_ERROR_SHOWN_MAX)}).`) if (!code) throw badRequest('Die Rückkehr enthält keinen Freigabecode.') const session = await createSession(code) const picked = pickAccount(session.accounts, connection) if (!picked) throw badRequest('Kein Euro-Konto in der Freigabe.') const { account, known } = picked const grantedUntil = Date.parse(session.validUntil ?? '') const validUntil = Number.isNaN(grantedUntil) ? requestedValidUntil(startedAt) : new Date(grantedUntil).toISOString() const iban = text(account.iban, 40) await saveConnection({ sessionId: text(session.sessionId, 64), accountUid: text(account.uid, 64), accountIdHash: text(account.identificationHash, 128), iban, ibanMasked: maskIban(iban), validUntil, status: 'aktiv', authState: '', authStartedAt: '', warnedThresholds: '', lastError: '', // Ein anderes Konto hat andere Buchungen: der naechste Abruf liest wieder den ganzen Zeitraum ...(known ? {} : { latestBookingDate: '' }), }) return { validUntil } } /** * Rueckkehr von Finom (code, state, error aus der Adresse): bei passender, nicht abgelaufener Freigabe wird die * Sitzung gespeichert (Status "aktiv", Warnstufen und Fehler geleert) und einmal abgerufen. Die Pruefung und das * Speichern laufen unter derselben Sperre wie der Abruf: ein gerade laufender Abruf endet zuerst und kann den neuen * Zugang nicht mehr ueberschreiben. Der Abruf danach laeuft nach Freigabe der Sperre (psu wie bei syncBank). * Fehler der Rueckkehr sind Error mit status 400 und deutscher Meldung. Liefert { ok: true, validUntil, sync } * (sync = Ergebnis von syncBank). */ export async function completeBankAuth({ code, state, error } = {}, { now = new Date(), psu = null } = {}) { const failure = typeof error === 'string' ? error.trim() : error ? 'unbekannt' : '' const { validUntil } = await withLocks(SYNC_LOCK, () => saveReturnedSession({ code: queryText(code), state: queryText(state), error: failure, now })) const sync = await syncBank({ now, psu }) return { ok: true, validUntil, sync } } // ---------- Warnung vor Ablauf des Bankzugangs ---------- // Warnstufen in Tagen bis zum Ablauf; 0 = am Ablauftag (und danach, falls die Mail zuvor nicht ankam) const WARN_THRESHOLDS = [30, 14, 3, 0] const parseWarned = (value) => String(value || '') .split(',') .map((part) => Number.parseInt(part, 10)) .filter((n) => Number.isInteger(n)) /** Der Satzteil im Betreff: "läuft in 14 Tagen ab", "läuft morgen ab", "läuft heute ab", "ist abgelaufen". */ function ablaufText({ expired, tage }) { if (expired) return 'ist abgelaufen' if (tage <= 0) return 'läuft heute ab' if (tage === 1) return 'läuft morgen ab' return `läuft in ${tage} Tagen ab` } /** * Warnt das Team per Mail (T18), wenn der Bankzugang bald ablaeuft oder abgelaufen ist. tage = Berliner * Kalendertage von heute bis zum Ablauftag (0 am Ablauftag, solange der Zugang noch gilt; abgelaufen, sobald * validUntil vorbei ist). Warnstufen 30, 14, 3 und 0: erreicht ist eine Stufe, wenn tage <= Stufe; gemailt wird * die kleinste erreichte, falls sie noch nicht in warnedThresholds steht. Gespeichert werden danach alle * erreichten Stufen (sonst kaeme nach "14" am naechsten Tag noch "30"). Die Stufen stehen in der Verbindung * und werden bei einer Neuverbindung geleert (completeBankAuth). Kommt die Mail nicht raus, bleibt die Stufe * offen und der naechste Lauf versucht es erneut. Liefert { sent: Stufe } oder { sent: null }; wirft nur, wenn * Appwrite nicht erreichbar ist (der Job faengt das ab). */ export async function checkBankConsent({ now = new Date() } = {}) { const connection = await getConnection() const validUntilMs = Date.parse(connection?.validUntil ?? '') if (Number.isNaN(validUntilMs)) return { sent: null } const ablaufTag = berlinDate(new Date(validUntilMs)) const tage = daysBetween(berlinDate(now), ablaufTag) const expired = validUntilMs <= now.getTime() const reached = WARN_THRESHOLDS.filter((threshold) => tage <= threshold) if (!reached.length) return { sent: null } const threshold = Math.min(...reached) if (parseWarned(connection.warnedThresholds).includes(threshold)) return { sent: null } const result = await sendAdminNotice({ template: 'T18', values: { ablauf_text: ablaufText({ expired, tage }), gueltig_bis: formatDe(ablaufTag), link: `${config.womsBaseUrl}/finance/einstellungen`, }, blocks: { abgelaufen: expired }, }) if (!result.sent) return { sent: null } // Speichern unter der Sperre der Abrufe: eine Neuverbindung, die waehrend des Versands ihre Warnstufen leert // (completeBankAuth), wird nicht mit den Stufen der alten Frist ueberschrieben await withLocks(SYNC_LOCK, async () => { const current = await getConnection() if (!current || current.validUntil !== connection.validUntil) return const warned = new Set([...parseWarned(current.warnedThresholds), ...reached]) await saveConnection({ warnedThresholds: [...warned].sort((a, b) => b - a).join(',') }) }) return { sent: threshold } }