checkBankConsent mailt bei 30, 14, 3 Tagen und am Ablauftag (Berliner Kalendertage), merkt sich die erreichten Stufen in warnedThresholds und holt eine nicht versandte Mail beim naechsten Lauf nach. Jobs nur mit Enable Banking: Abruf nach 2 Minuten und alle 6 Stunden, Pruefung nach 3 Minuten und taeglich. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
341 lines
17 KiB
JavaScript
341 lines
17 KiB
JavaScript
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 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 }
|
|
}
|
|
|
|
// ---------- 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 }
|
|
}
|