Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3dd2fac50b
commit
588cc95f5d
5 files changed
+961
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { EnableBankingError, getBalances, getTransactions, 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).
|
||||
*/
|
||||
|
||||
// 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 }))
|
||||
}
|
||||
Reference in new issue
Block a user