Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)

startBankAuth/completeBankAuth in bankSync.js. Die Rueckkehr prueft und speichert die
Sitzung unter der Sperre der Abrufe (ein laufender Abruf kann den neuen Zugang nicht
mehr ueberschreiben) und ruft danach einmal ab. Nachbau: holdNext haelt einen Aufruf an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:41:54 +00:00
1 parent 75ca8b78c0
commit cba14ed0f0
3 files changed
+605 -2

No files matched your search

+108 -2
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { withLocks } from '../keyedLock.js'
import { EnableBankingError, getBalances, getTransactions, toCents } from './enableBanking.js'
import { createSession, EnableBankingError, getBalances, getTransactions, startAuth, toCents } from './enableBanking.js'
import { addDays, berlinDate } from './dates.js'
import { deleteTransaction, getConnection, listPendingTransactions, saveConnection, upsertTransaction } from './bankStore.js'
@@ -9,6 +9,9 @@ import { deleteTransaction, getConnection, listPendingTransactions, saveConnecti
* 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.
*/
// Die Bank liefert hoechstens ~90 Tage: ein Abruf beginnt spaetestens heute - 89 Tage, sonst eine Woche vor der
@@ -165,3 +168,106 @@ async function runSync({ now, psu }) {
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 }
}