Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf

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 3dd2fac50b
commit 588cc95f5d
5 files changed
+961

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
/**
* Speicher fuer den Bankzugang (ein Dokument "finom" in bankConnections) und die Buchungen
* (bankTransactions). Die Dokument-ID einer Buchung ist der Hash ihrer Bank-Referenz (siehe bankSync),
* dieselbe Buchung landet also bei jedem Abruf im selben Dokument.
*/
/** ID des einen Verbindungsdokuments. */
export const CONNECTION_ID = 'finom'
// Felder, die von der Bank kommen und bei jedem Abruf ueberschrieben werden duerfen. Alles andere an einer
// Buchung (category, matched*, fixedCostId, note) gehoert dem Nutzer und bleibt beim Abruf unberuehrt.
const BANK_FIELDS = [
'status',
'bookingDate',
'valueDate',
'amountCents',
'currency',
'counterpartyName',
'counterpartyIban',
'remittance',
'mcc',
'entryReference',
'accountUid',
'syncedAt',
]
// Zuordnungsfelder einer neuen Buchung: leer (Spec: "category leer" = noch nicht zugeordnet)
const USER_FIELD_DEFAULTS = {
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
}
const withoutMeta = (doc) => Object.fromEntries(Object.entries(doc).filter(([key, value]) => !key.startsWith('$') && value !== undefined))
const pick = (doc, keys) => Object.fromEntries(keys.filter((key) => doc[key] !== undefined).map((key) => [key, doc[key]]))
const isStatus = (err, status) => err?.status === status
/**
* Dokument aktualisieren; gibt es das Dokument noch nicht (404), wird es mit createData angelegt. Meldet
* zwischen beiden Schritten ein anderer Aufruf dasselbe Dokument an (409), gilt wieder das Aktualisieren.
* Liefert 'neu' oder 'aktualisiert' (und das Dokument).
*/
async function upsert(collectionId, id, patch, createData) {
try {
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
} catch (err) {
if (!isStatus(err, 404)) throw err
}
try {
return { how: 'neu', doc: await createDocument(collectionId, createData, id) }
} catch (err) {
if (!isStatus(err, 409)) throw err
}
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
}
/** Der Bankzugang oder null, wenn es noch keinen gibt. */
export async function getConnection() {
try {
return await getDocument(config.collections.bankConnections, CONNECTION_ID)
} catch (err) {
if (isStatus(err, 404)) return null
throw err
}
}
/** Bankzugang anlegen oder die genannten Felder aktualisieren (die anderen bleiben). Liefert das Dokument. */
export async function saveConnection(patch) {
const data = withoutMeta(patch)
return (await upsert(config.collections.bankConnections, CONNECTION_ID, data, data)).doc
}
/**
* Buchung speichern. doc traegt die Dokument-ID in $id (so liefert sie toTransactionDoc). Neu: alle
* Bankfelder plus leere Zuordnungsfelder; vorhanden: nur die Bankfelder. 'neu' | 'aktualisiert'.
*/
export async function upsertTransaction(doc) {
const id = doc.$id
if (!id) throw new Error('upsertTransaction: $id fehlt')
const fields = pick(doc, BANK_FIELDS)
return (await upsert(config.collections.bankTransactions, id, fields, { ...USER_FIELD_DEFAULTS, ...fields })).how
}
/** Gespeicherte Buchungen, neueste zuerst nach Buchungsdatum (hoechstens limit). */
export async function listTransactions({ limit = 1000 } = {}) {
return listDocuments(config.collections.bankTransactions, [Query.orderDesc('bookingDate'), Query.limit(limit)])
}
/** Alle vorgemerkten Buchungen (Status PDNG). */
export async function listPendingTransactions() {
return listDocuments(config.collections.bankTransactions, [Query.equal('status', 'PDNG'), Query.limit(1000)])
}
/** Eine Buchung oder null. */
export async function getTransaction(id) {
try {
return await getDocument(config.collections.bankTransactions, id)
} catch (err) {
if (isStatus(err, 404)) return null
throw err
}
}
/** Felder einer Buchung aendern (z. B. Zuordnung, Notiz). */
export async function updateTransaction(id, patch) {
return updateDocument(config.collections.bankTransactions, id, withoutMeta(patch))
}
export async function deleteTransaction(id) {
return deleteDocument(config.collections.bankTransactions, id)
}
+167
View File
@@ -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 }))
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Kalendertage fuer die Finanzen als Zeichenkette YYYY-MM-DD ("ymd"). "Heute" ist immer der Berliner Tag
* (Europe/Berlin, MESZ/MEZ automatisch). Gerechnet wird mit UTC-Mitternacht des jeweiligen Tages, damit
* die Zeitumstellung (23- und 25-Stunden-Tage) keinen Tag verschluckt oder verdoppelt.
*/
const DAY_MS = 86_400_000
// Formatierer einmal anlegen (neu anlegen ist teuer); sv-SE liefert YYYY-MM-DD
const berlinDayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' })
/** Kalendertag in Berlin (YYYY-MM-DD) zu einem Zeitpunkt. */
export function berlinDate(date = new Date()) {
return berlinDayFormat.format(date)
}
/** UTC-Mitternacht (Millisekunden) eines ymd; wirft bei Formfehlern und Tagen, die es nicht gibt (30.02.). */
function dayMs(ymd) {
const match = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (match) {
const [year, month, day] = match.slice(1).map(Number)
const ms = Date.UTC(year, month - 1, day)
const check = new Date(ms)
if (check.getUTCFullYear() === year && check.getUTCMonth() === month - 1 && check.getUTCDate() === day) return ms
}
throw new Error(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
}
const ymdOf = (ms) => new Date(ms).toISOString().slice(0, 10)
/** ymd plus n ganze Tage (n darf negativ sein). */
export function addDays(ymd, n) {
if (!Number.isInteger(n)) throw new Error('Tage müssen eine ganze Zahl sein.')
return ymdOf(dayMs(ymd) + n * DAY_MS)
}
/** Ganze Tage von fromYmd bis toYmd (toYmd minus fromYmd, negativ wenn toYmd frueher liegt). */
export function daysBetween(fromYmd, toYmd) {
return Math.round((dayMs(toYmd) - dayMs(fromYmd)) / DAY_MS)
}
/** ymd als TT.MM.JJJJ. */
export function formatDe(ymd) {
dayMs(ymd)
const [year, month, day] = ymd.split('-')
return `${day}.${month}.${year}`
}