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>
274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
import { withLocks } from '../keyedLock.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'
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
// 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 kaputte Buchung der Bank bricht ab, bevor etwas gespeichert ist
|
|
const syncedAt = now.toISOString()
|
|
const docs = new Map()
|
|
for (const raw of rawTransactions) {
|
|
const doc = toTransactionDoc(raw, { accountUid, syncedAt })
|
|
docs.set(doc.$id, doc)
|
|
}
|
|
|
|
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: '',
|
|
})
|
|
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 {
|
|
await saveConnection({ ...(expired ? { status: 'abgelaufen' } : {}), lastSyncOk: false, lastError: error })
|
|
} catch {
|
|
// Appwrite selbst gestoert: der Fehler geht trotzdem an den Aufrufer
|
|
}
|
|
return { ...result, error }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
}
|