From 588cc95f5d038e07d36e647698b921083f6e0404 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:24:49 +0000 Subject: [PATCH] Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/bankStore.js | 116 ++++++ server/services/finance/bankSync.js | 167 ++++++++ server/services/finance/dates.js | 47 +++ test/financeBankSync.test.js | 580 +++++++++++++++++++++++++++ test/financeDates.test.js | 51 +++ 5 files changed, 961 insertions(+) create mode 100644 server/services/finance/bankStore.js create mode 100644 server/services/finance/bankSync.js create mode 100644 server/services/finance/dates.js create mode 100644 test/financeBankSync.test.js create mode 100644 test/financeDates.test.js diff --git a/server/services/finance/bankStore.js b/server/services/finance/bankStore.js new file mode 100644 index 0000000..407bc90 --- /dev/null +++ b/server/services/finance/bankStore.js @@ -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) +} diff --git a/server/services/finance/bankSync.js b/server/services/finance/bankSync.js new file mode 100644 index 0000000..52107b6 --- /dev/null +++ b/server/services/finance/bankSync.js @@ -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 })) +} diff --git a/server/services/finance/dates.js b/server/services/finance/dates.js new file mode 100644 index 0000000..a7a0c6b --- /dev/null +++ b/server/services/finance/dates.js @@ -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}` +} diff --git a/test/financeBankSync.test.js b/test/financeBankSync.test.js new file mode 100644 index 0000000..1f22bd5 --- /dev/null +++ b/test/financeBankSync.test.js @@ -0,0 +1,580 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { startFakeEnableBanking } from './support/fakeEnableBanking.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie aboRoutes.test.js) +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) +const pem = (key, type) => key.export({ type, format: 'pem' }) +const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bank-sync-test-')) +const keyPath = path.join(keyDir, 'private.pem') +fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8')) + +const ACCOUNT = 'konto-uid-4711' +const SESSION = 'sess-geheim-4711' + +const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' }) +fake.accounts.set(ACCOUNT, { uid: ACCOUNT, identification_hash: 'h1', account_id: { iban: 'DE00000000000000009313' }, name: 'Webklar', currency: 'EUR' }) +const aw = await startAppwriteMock({ + schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.url }) + +const { config } = await import('../server/config.js') +const { syncBank, transactionDocId, toTransactionDoc } = await import('../server/services/finance/bankSync.js') +const store = await import('../server/services/finance/bankStore.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { addDays, berlinDate } = await import('../server/services/finance/dates.js') + +after(async () => { + await Promise.all([fake.close(), aw.close(), st.close()]) + fs.rmSync(keyDir, { recursive: true, force: true }) +}) + +// "Jetzt" der Abrufe: 01.10.2026 11:00 Berliner Zeit, heute - 89 Tage = 04.07.2026 +const NOW = new Date('2026-10-01T09:00:00Z') +const TODAY = '2026-10-01' +const WINDOW_START = '2026-07-04' + +/** Rohbuchung in der Form von Enable Banking (Finom). */ +function raw({ ref, date, amount, indicator = 'DBIT', name = '', iban = null, mcc = null, remittance = [], status = 'BOOK', extra = {} }) { + const party = indicator === 'CRDT' ? 'debtor' : 'creditor' + return { + ...(ref ? { entry_reference: ref } : {}), + status, + booking_date: date, + value_date: date, + transaction_amount: { amount, currency: 'EUR' }, + credit_debit_indicator: indicator, + [party]: { name }, + ...(iban ? { [`${party}_account`]: { iban } } : {}), + ...(mcc ? { merchant_category_code: mcc } : {}), + remittance_information: remittance, + ...extra, + } +} + +// Die neun Buchungen des echten Abrufs vom 01.10.2026 (IBANs gekuerzt/ersetzt) +const nine = () => [ + raw({ ref: 'r1', date: '2026-08-05', amount: '168.79', indicator: 'CRDT', name: 'EWV SOFTWARE GMBH', iban: 'DE00000000000000008511', remittance: ['Rechnung 0005 vom 28.07.2026'] }), + raw({ ref: 'r2', date: '2026-08-16', amount: '43.58', name: 'OPENROUTER, INC', mcc: '5734' }), + raw({ ref: 'r3', date: '2026-09-15', amount: '1.31', name: 'PNL Fintech B.V.', iban: 'NL00000000000000003855' }), + raw({ ref: 'r4', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }), + raw({ ref: 'r5', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }), + raw({ ref: 'r6', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }), + raw({ ref: 'r7', date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }), + raw({ ref: 'r8', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }), + raw({ ref: 'r9', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }), +] +const balances = () => [ + { balance_type: 'ITAV', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }, + { balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }, + { balance_type: 'CLBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-09-30' }, +] + +const docId = (ref) => transactionDocId({ entry_reference: ref }) +const txDocs = () => aw.docs(config.collections.bankTransactions) +const connection = () => aw.docs(config.collections.bankConnections).get('finom') +const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix)) + +/** Leerer Ausgangszustand: keine Buchungen, keine Verbindung, Nachbau mit den neun Buchungen und Salden. */ +async function reset({ transactions = nine(), bal = balances() } = {}) { + for (const id of txDocs().keys()) await store.deleteTransaction(id) + await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {}) + fake.calls.length = 0 + fake.transactions.set(ACCOUNT, transactions) + fake.balances.set(ACCOUNT, bal) +} +const connect = (patch = {}) => + store.saveConnection({ status: 'aktiv', sessionId: SESSION, accountUid: ACCOUNT, validUntil: '2027-03-29T00:00:00.000Z', ...patch }) + +// ---------- Aufbereitung einer Buchung ---------- + +test('toTransactionDoc: Eingang mit Zahler, IBAN und Verwendungszweck', () => { + const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: '2026-10-01T09:00:00.000Z' }) + assert.equal(doc.$id, docId('r1')) + assert.equal(doc.amountCents, 16879) + assert.equal(doc.counterpartyName, 'EWV SOFTWARE GMBH') + assert.equal(doc.counterpartyIban, 'DE00000000000000008511') + assert.equal(doc.remittance, 'Rechnung 0005 vom 28.07.2026') + assert.equal(doc.status, 'BOOK') + assert.equal(doc.bookingDate, '2026-08-05') + assert.equal(doc.valueDate, '2026-08-05') + assert.equal(doc.currency, 'EUR') + assert.equal(doc.mcc, '') + assert.equal(doc.entryReference, 'r1') + assert.equal(doc.accountUid, ACCOUNT) + assert.equal(doc.syncedAt, '2026-10-01T09:00:00.000Z') +}) + +test('toTransactionDoc: Kartenzahlung ist negativ, hat MCC und keine IBAN', () => { + const doc = toTransactionDoc(nine()[7], { accountUid: ACCOUNT, syncedAt: 'x' }) + assert.equal(doc.amountCents, -449) + assert.equal(doc.mcc, '4816') + assert.equal(doc.counterpartyName, 'PORKBUN.COM') + assert.equal(doc.counterpartyIban, '') + assert.equal(doc.remittance, '') + // halbe Euro: 0.5 -> 50 Cent + assert.equal(toTransactionDoc(nine()[3], { accountUid: ACCOUNT, syncedAt: 'x' }).amountCents, -50) +}) + +test('toTransactionDoc: Ausgang liest den Empfaenger, Eingang den Zahler (nicht die eigene Seite)', () => { + const dbit = raw({ ref: 'x', date: '2026-09-01', amount: '10', name: 'EMPFAENGER' }) + dbit.debtor = { name: 'WEBKLAR (eigene Seite)' } + assert.equal(toTransactionDoc(dbit, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'EMPFAENGER') + const crdt = raw({ ref: 'y', date: '2026-09-01', amount: '10', indicator: 'CRDT', name: 'ZAHLER' }) + crdt.creditor = { name: 'WEBKLAR (eigene Seite)' } + assert.equal(toTransactionDoc(crdt, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'ZAHLER') +}) + +test('toTransactionDoc: IBAN auch aus other.identification, mehrere Zweckzeilen, Kuerzung auf 1000', () => { + const sepa = raw({ ref: 'a', date: '2026-09-01', amount: '1', name: 'Konto ohne IBAN' }) + sepa.creditor_account = { other: { identification: 'ABC123', scheme_name: 'BBAN' } } + assert.equal(toTransactionDoc(sepa, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyIban, 'ABC123') + + const lines = raw({ ref: 'b', date: '2026-09-01', amount: '1', name: 'N', remittance: ['Rechnung 1', 'Kunde 2'] }) + assert.equal(toTransactionDoc(lines, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance, 'Rechnung 1 Kunde 2') + + const long = raw({ ref: 'c', date: '2026-09-01', amount: '1', name: 'N', remittance: ['x'.repeat(1500)] }) + assert.equal(toTransactionDoc(long, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance.length, 1000) +}) + +test('toTransactionDoc: Feldlaengen passen zum Schema, Vormerkung ohne Buchungsdatum nimmt das Wertstellungsdatum', () => { + const pending = raw({ ref: 'p', date: undefined, amount: '2.5', name: 'N'.repeat(400), status: 'PDNG' }) + delete pending.booking_date + pending.value_date = '2026-09-30' + const doc = toTransactionDoc(pending, { accountUid: ACCOUNT, syncedAt: 'x' }) + assert.equal(doc.bookingDate, '2026-09-30') + assert.equal(doc.status, 'PDNG') + assert.equal(doc.counterpartyName.length, 255) +}) + +test('toTransactionDoc: unbekannte Richtung oder kaputter Betrag wird abgelehnt statt geraten', () => { + const noDirection = nine()[1] + delete noDirection.credit_debit_indicator + assert.throws(() => toTransactionDoc(noDirection, { accountUid: ACCOUNT, syncedAt: 'x' }), /Richtung/) + const broken = nine()[1] + broken.transaction_amount.amount = '4,49 EUR' + assert.throws(() => toTransactionDoc(broken, { accountUid: ACCOUNT, syncedAt: 'x' }), /Betrag/) +}) + +test('transactionDocId: gleiche entry_reference -> gleiche ID, 36 Hex-Zeichen, Hash der Referenz', () => { + const a = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-01-01' }) + const b = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-02-02', transaction_amount: { amount: '9.99' } }) + assert.equal(a, b) + assert.match(a, /^[a-f0-9]{36}$/) + assert.equal(a, crypto.createHash('sha256').update('ref-1').digest('hex').slice(0, 36)) + assert.notEqual(a, transactionDocId({ entry_reference: 'ref-2' })) +}) + +test('transactionDocId: ohne entry_reference zaehlt transaction_id, danach Datum|Betrag|Gegenpartei|Zweck', () => { + const viaTransactionId = transactionDocId({ transaction_id: 't-77', booking_date: '2026-01-01' }) + assert.equal(viaTransactionId, crypto.createHash('sha256').update('t-77').digest('hex').slice(0, 36)) + + const base = raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] }) + const id = transactionDocId(base) + assert.match(id, /^[a-f0-9]{36}$/) + // stabil: gleiche Angaben (auch anders formatierter Betrag) -> gleiche ID + assert.equal(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.490', name: 'PORKBUN.COM', remittance: ['Domain'] }))) + // jede der vier Angaben aendert die ID, ebenso die Richtung + assert.notEqual(id, transactionDocId(raw({ date: '2026-09-28', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] }))) + assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.50', name: 'PORKBUN.COM', remittance: ['Domain'] }))) + assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'ANDERER', remittance: ['Domain'] }))) + assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Anderes'] }))) + assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', indicator: 'CRDT', name: 'PORKBUN.COM', remittance: ['Domain'] }))) +}) + +// ---------- bankStore ---------- + +test('bankStore: Verbindung anlegen, aktualisieren, fehlende Verbindung ist null', async () => { + await reset() + assert.equal(await store.getConnection(), null) + const created = await store.saveConnection({ status: 'aktiv', accountUid: ACCOUNT, sessionId: SESSION }) + assert.equal(created.$id, 'finom') + assert.equal((await store.getConnection()).accountUid, ACCOUNT) + const updated = await store.saveConnection({ lastError: 'x', lastSyncOk: false }) + assert.equal(updated.lastError, 'x') + assert.equal(updated.status, 'aktiv') + assert.equal(aw.docs(config.collections.bankConnections).size, 1) +}) + +test('bankStore: upsertTransaction legt neu an und aktualisiert nur Bankfelder', async () => { + await reset() + const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: 'erst' }) + assert.equal(await store.upsertTransaction(doc), 'neu') + const created = await store.getTransaction(doc.$id) + // Zuordnungsfelder starten leer + for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId', 'note']) assert.equal(created[key], '') + + await store.updateTransaction(doc.$id, { category: 'rechnung-extern', note: 'x', matchedInvoiceId: 'in_1' }) + assert.equal(await store.upsertTransaction({ ...doc, remittance: 'neuer Zweck', syncedAt: 'zweit', category: 'privat', note: 'ueberschrieben?' }), 'aktualisiert') + const after = await store.getTransaction(doc.$id) + assert.equal(after.remittance, 'neuer Zweck') + assert.equal(after.syncedAt, 'zweit') + assert.equal(after.category, 'rechnung-extern') + assert.equal(after.note, 'x') + assert.equal(after.matchedInvoiceId, 'in_1') + assert.equal(txDocs().size, 1) +}) + +test('bankStore: listTransactions neueste zuerst, mit Limit; getTransaction null; deleteTransaction', async () => { + await reset() + for (const r of nine()) await store.upsertTransaction(toTransactionDoc(r, { accountUid: ACCOUNT, syncedAt: 'x' })) + const all = await store.listTransactions() + assert.equal(all.length, 9) + const dates = all.map((t) => t.bookingDate) + assert.deepEqual(dates, [...dates].sort().reverse()) + assert.equal(dates[0], '2026-09-29') + assert.equal((await store.listTransactions({ limit: 2 })).length, 2) + assert.equal(await store.getTransaction('gibtesnicht'), null) + await store.deleteTransaction(all[0].$id) + assert.equal(txDocs().size, 8) +}) + +// ---------- syncBank ---------- + +test('syncBank: ohne Verbindung oder bei abgelaufenem Zugang kein API-Aufruf', async () => { + await reset() + assert.deepEqual(await syncBank({ now: NOW }), { ok: false, neu: 0, aktualisiert: 0, geloescht: 0, error: 'Kein aktiver Bankzugang' }) + await connect({ status: 'abgelaufen' }) + const result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(result.error, 'Kein aktiver Bankzugang') + assert.equal(fake.calls.length, 0) + assert.equal(connection().status, 'abgelaufen') +}) + +test('syncBank: erster Abruf speichert alle Buchungen, Saldo und jüngstes Buchungsdatum', async () => { + await reset() + await connect() + const result = await syncBank({ now: NOW }) + assert.deepEqual(result, { ok: true, neu: 9, aktualisiert: 0, geloescht: 0 }) + assert.equal(txDocs().size, 9) + + const conn = connection() + assert.equal(conn.balanceBookedCents, 10893) + assert.equal(conn.balanceAvailableCents, 10893) + assert.equal(conn.balanceDate, '2026-10-01') + assert.equal(conn.latestBookingDate, '2026-09-29') + assert.equal(conn.lastSyncOk, true) + assert.equal(conn.lastError, '') + assert.equal(conn.lastSyncAt, NOW.toISOString()) + assert.equal(conn.status, 'aktiv') + // Zugangsdaten bleiben unberuehrt + assert.equal(conn.sessionId, SESSION) + assert.equal(conn.accountUid, ACCOUNT) + + // ohne bekannte Buchung: heute - 89 Tage + assert.equal(addDays(berlinDate(NOW), -89), WINDOW_START) + const txCalls = callsTo('/transactions') + assert.equal(txCalls.length, 1) + assert.equal(txCalls[0].query.date_from, WINDOW_START) + // Konto-UID in der Anfrage, nichts anderes als die gespeicherte + assert.equal(txCalls[0].path, `/accounts/${ACCOUNT}/transactions`) + + const ewv = txDocs().get(docId('r1')) + assert.equal(ewv.amountCents, 16879) + assert.equal(ewv.counterpartyName, 'EWV SOFTWARE GMBH') + assert.equal(txDocs().get(docId('r8')).amountCents, -449) +}) + +test('syncBank: Abrufzeitraum ab letzter Buchung minus 7 Tage, nie weiter zurück als 89 Tage', async () => { + await reset() + await connect() + await syncBank({ now: NOW }) + fake.calls.length = 0 + await syncBank({ now: NOW }) + assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-22') + + // sehr alte letzte Buchung: Untergrenze heute - 89 Tage + await store.saveConnection({ latestBookingDate: '2026-01-01' }) + fake.calls.length = 0 + await syncBank({ now: NOW }) + assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START) + + // letzte Buchung heute: sieben Tage zurueck + await store.saveConnection({ latestBookingDate: TODAY }) + fake.calls.length = 0 + await syncBank({ now: NOW }) + assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-24') +}) + +test('syncBank: "heute" ist der Berliner Tag (kurz nach Mitternacht Berliner Zeit)', async () => { + await reset() + await connect() + // 30.09. 22:30 UTC = 01.10. 00:30 in Berlin + await syncBank({ now: new Date('2026-09-30T22:30:00Z') }) + assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START) + // 30.09. 21:30 UTC = 30.09. 23:30 in Berlin -> ein Tag frueher + await reset() + await connect() + await syncBank({ now: new Date('2026-09-30T21:30:00Z') }) + assert.equal(callsTo('/transactions')[0].query.date_from, '2026-07-03') +}) + +test('syncBank: zweiter Abruf mit denselben Buchungen legt nichts neu an und lässt Zuordnung und Notiz stehen', async () => { + await reset() + await connect() + await syncBank({ now: NOW }) + const id = docId('r1') + await store.updateTransaction(id, { category: 'rechnung-extern', note: 'x', matchedInvoiceNumber: '0005' }) + + const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') }) + assert.equal(result.ok, true) + assert.equal(result.neu, 0) + assert.equal(result.geloescht, 0) + assert.equal(txDocs().size, 9) + const doc = txDocs().get(id) + assert.equal(doc.category, 'rechnung-extern') + assert.equal(doc.note, 'x') + assert.equal(doc.matchedInvoiceNumber, '0005') + assert.equal(doc.syncedAt, '2026-10-01T15:00:00.000Z') +}) + +test('syncBank: geänderte Bankfelder werden übernommen (Zweck, Gegenpartei, Datum)', async () => { + await reset() + await connect() + await syncBank({ now: NOW }) + const changed = nine() + changed[0].remittance_information = ['Rechnung 0005 vom 28.07.2026 (korrigiert)'] + changed[1].booking_date = '2026-08-17' + fake.transactions.set(ACCOUNT, changed) + const result = await syncBank({ now: NOW }) + assert.equal(result.neu, 0) + assert.equal(txDocs().get(docId('r1')).remittance, 'Rechnung 0005 vom 28.07.2026 (korrigiert)') + assert.equal(txDocs().get(docId('r2')).bookingDate, '2026-08-17') +}) + +test('syncBank: Vormerkung verschwindet, die Buchung erscheint einmal', async () => { + await reset({ transactions: [...nine(), raw({ ref: 'p1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT', status: 'PDNG' })] }) + await connect() + const first = await syncBank({ now: NOW }) + assert.equal(first.neu, 10) + assert.equal(txDocs().get(docId('p1')).status, 'PDNG') + // eine Vormerkung zaehlt nicht als jüngste Buchung + assert.equal(connection().latestBookingDate, '2026-09-29') + + fake.transactions.set(ACCOUNT, [...nine(), raw({ ref: 'b1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT' })]) + const second = await syncBank({ now: NOW }) + assert.equal(second.ok, true) + assert.equal(second.geloescht, 1) + assert.equal(second.neu, 1) + assert.equal(txDocs().has(docId('p1')), false) + assert.equal(txDocs().get(docId('b1')).status, 'BOOK') + assert.equal(txDocs().size, 10) + assert.equal(connection().latestBookingDate, '2026-09-30') +}) + +test('syncBank: gleiche entry_reference bei Vormerkung und Buchung -> ein Dokument, Status wechselt auf BOOK', async () => { + await reset({ transactions: [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N', status: 'PDNG' })] }) + await connect() + await syncBank({ now: NOW }) + fake.transactions.set(ACCOUNT, [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N' })]) + const result = await syncBank({ now: NOW }) + assert.deepEqual(result, { ok: true, neu: 0, aktualisiert: 1, geloescht: 0 }) + assert.equal(txDocs().size, 1) + assert.equal(txDocs().get(docId('same')).status, 'BOOK') +}) + +test('syncBank: Vormerkungen vor dem Abrufzeitraum bleiben, gebuchte Dokumente werden nie gelöscht', async () => { + await reset() + await connect() + await syncBank({ now: NOW }) + // alte Vormerkung ausserhalb des Abrufzeitraums (ab 2026-09-22) und eine innerhalb, die die Bank nicht mehr nennt + aw.seed(config.collections.bankTransactions, { $id: 'alt-vormerkung', status: 'PDNG', bookingDate: '2026-08-01', amountCents: -100 }) + aw.seed(config.collections.bankTransactions, { $id: 'neu-vormerkung', status: 'PDNG', bookingDate: '2026-09-25', amountCents: -100 }) + // Buchung, die die Bank (im Zeitraum) nicht mehr liefert, bleibt: nur Vormerkungen werden gelöscht + fake.transactions.set(ACCOUNT, nine().slice(5)) + const result = await syncBank({ now: NOW }) + assert.equal(result.geloescht, 1) + assert.equal(txDocs().has('alt-vormerkung'), true) + assert.equal(txDocs().has('neu-vormerkung'), false) + assert.equal(txDocs().has(docId('r1')), true) +}) + +test('syncBank: Saldo-Auswahl ITBD vor CLBD, ITAV für verfügbar, Datum des gebuchten Saldos', async () => { + await reset({ + transactions: [], + bal: [ + { balance_type: 'CLBD', balance_amount: { amount: '100.00', currency: 'EUR' }, reference_date: '2026-09-30' }, + { balance_type: 'ITAV', balance_amount: { amount: '90.50', currency: 'EUR' }, reference_date: '2026-10-01' }, + { balance_type: 'ITBD', balance_amount: { amount: '120.25', currency: 'EUR' }, reference_date: '2026-10-01' }, + ], + }) + await connect() + assert.equal((await syncBank({ now: NOW })).ok, true) + assert.equal(connection().balanceBookedCents, 12025) + assert.equal(connection().balanceAvailableCents, 9050) + assert.equal(connection().balanceDate, '2026-10-01') + + // nur CLBD: gilt als gebuchter Saldo + fake.balances.set(ACCOUNT, [{ balance_type: 'CLBD', balance_amount: { amount: '-5.10', currency: 'EUR' }, reference_date: '2026-09-30' }]) + assert.equal((await syncBank({ now: NOW })).ok, true) + assert.equal(connection().balanceBookedCents, -510) + assert.equal(connection().balanceDate, '2026-09-30') +}) + +test('syncBank: 401 beim Abruf -> Zugang abgelaufen, Buchungen bleiben, danach kein API-Aufruf mehr', async () => { + await reset() + await connect() + assert.equal((await syncBank({ now: NOW })).ok, true) + + fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } }) + const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') }) + assert.equal(result.ok, false) + assert.equal(result.error, 'Bankzugang abgelaufen oder widerrufen') + assert.equal(connection().status, 'abgelaufen') + assert.equal(connection().lastSyncOk, false) + assert.equal(connection().lastError, 'Bankzugang abgelaufen oder widerrufen') + // letzter erfolgreicher Abruf bleibt stehen + assert.equal(connection().lastSyncAt, NOW.toISOString()) + assert.equal(txDocs().size, 9) + + const callsBefore = fake.calls.length + const third = await syncBank({ now: NOW }) + assert.equal(third.ok, false) + assert.equal(fake.calls.length, callsBefore) +}) + +test('syncBank: 403 bei den Umsätzen gilt ebenfalls als abgelaufen', async () => { + await reset() + await connect() + fake.failNext({ path: /\/transactions$/, status: 403, body: { code: 403, error: 'ACCESS_DENIED' } }) + const result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(connection().status, 'abgelaufen') +}) + +test('syncBank: 500 -> Status bleibt aktiv, Fehler gemerkt, keine Zugangsdaten in der Meldung', async () => { + await reset() + await connect() + fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } }) + const result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.ok(result.error.length > 0) + const conn = connection() + assert.equal(conn.status, 'aktiv') + assert.equal(conn.lastSyncOk, false) + assert.equal(conn.lastError, result.error) + assert.match(conn.lastError, /500/) + for (const secret of [ACCOUNT, SESSION, keyPath]) { + assert.ok(!result.error.includes(secret), `Meldung enthält ${secret}`) + assert.ok(!conn.lastError.includes(secret), `lastError enthält ${secret}`) + } + // nächster Abruf läuft normal weiter und räumt den Fehler weg + const retry = await syncBank({ now: NOW }) + assert.equal(retry.ok, true) + assert.equal(connection().lastSyncOk, true) + assert.equal(connection().lastError, '') +}) + +test('syncBank: Fehler bei den Umsätzen lässt den Saldo der Verbindung unverändert', async () => { + await reset() + await connect() + await syncBank({ now: NOW }) + fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '5.00', currency: 'EUR' }, reference_date: '2026-10-02' }]) + fake.failNext({ path: /\/transactions$/, status: 500 }) + const result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(connection().balanceBookedCents, 10893) + assert.equal(connection().lastSyncOk, false) +}) + +test('syncBank: wirft nie - kaputter Betrag der Bank, Netzwerkfehler, Appwrite-Fehler', async () => { + // kaputter Betrag: nichts wird geschrieben, Fehler gemerkt + const broken = nine() + broken[4].transaction_amount.amount = 'abc' + await reset({ transactions: broken }) + await connect() + let result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.match(result.error, /Betrag/) + assert.equal(txDocs().size, 0) + assert.equal(connection().status, 'aktiv') + assert.equal(connection().lastSyncOk, false) + + // fehlerhafter Saldo + await reset({ bal: [{ balance_type: 'ITBD', balance_amount: { amount: 'kaputt', currency: 'EUR' }, reference_date: '2026-10-01' }] }) + await connect() + result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(connection().lastSyncOk, false) + + // Netzwerkabbruch + await reset() + await connect() + fake.failNext({ path: /\/balances$/, destroy: true }) + result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(connection().status, 'aktiv') + + // Appwrite scheitert beim Speichern einer Buchung + await reset() + await connect() + aw.failNext({ collection: config.collections.bankTransactions, method: 'PATCH', status: 500, message: 'Nachbau: Appwrite gestoert' }) + result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.equal(connection().lastSyncOk, false) + assert.match(connection().lastError, /Appwrite gestoert/) +}) + +test('syncBank: wirft auch dann nicht, wenn die Verbindung nicht geladen werden kann', async () => { + await reset() + await connect() + aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Nachbau: Verbindung nicht lesbar' }) + const result = await syncBank({ now: NOW }) + assert.equal(result.ok, false) + assert.match(result.error, /Verbindung nicht lesbar/) + assert.equal(fake.calls.length, 0) +}) + +test('syncBank: PSU-Kopfzeilen werden an alle Aufrufe weitergegeben', async () => { + await reset() + await connect() + await syncBank({ now: NOW, psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } }) + assert.ok(fake.calls.length >= 2) + for (const call of fake.calls) { + assert.equal(call.headers['psu-ip-address'], '203.0.113.7') + assert.equal(call.headers['psu-user-agent'], 'Mozilla/5.0 Test') + } + fake.calls.length = 0 + await syncBank({ now: NOW }) + for (const call of fake.calls) assert.equal(call.headers['psu-ip-address'], undefined) +}) + +test('syncBank: gleichzeitige Abrufe laufen nacheinander und legen keine Buchung doppelt an', async () => { + await reset() + await connect() + const [a, b] = await Promise.all([syncBank({ now: NOW }), syncBank({ now: NOW })]) + assert.equal(a.ok, true) + assert.equal(b.ok, true) + assert.equal(a.neu + b.neu, 9) + assert.equal(txDocs().size, 9) + // nacheinander: erst Salden + Umsätze des ersten Abrufs, dann die des zweiten (nicht ineinander verschränkt) + assert.deepEqual( + fake.calls.map((c) => c.path.split('/').pop()), + ['balances', 'transactions', 'balances', 'transactions'] + ) +}) + +test('syncBank: mehrere Seiten der Bank werden vollständig gespeichert', async () => { + await reset() + await connect() + fake.pageSize = 4 + try { + const result = await syncBank({ now: NOW }) + assert.equal(result.neu, 9) + assert.equal(callsTo('/transactions').length, 3) + } finally { + fake.pageSize = 100 + } +}) diff --git a/test/financeDates.test.js b/test/financeDates.test.js new file mode 100644 index 0000000..06e72f9 --- /dev/null +++ b/test/financeDates.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { berlinDate, addDays, daysBetween, formatDe } from '../server/services/finance/dates.js' + +test('berlinDate: Kalendertag in Europe/Berlin, nicht in UTC', () => { + assert.equal(berlinDate(new Date('2026-09-30T22:30:00Z')), '2026-10-01') + assert.equal(berlinDate(new Date('2026-09-30T21:59:00Z')), '2026-09-30') + // Winterzeit (UTC+1) + assert.equal(berlinDate(new Date('2026-12-31T23:30:00Z')), '2027-01-01') + assert.equal(berlinDate(new Date('2026-12-31T22:59:00Z')), '2026-12-31') +}) + +test('berlinDate: ohne Argument der heutige Tag im Format YYYY-MM-DD', () => { + assert.match(berlinDate(), /^\d{4}-\d{2}-\d{2}$/) +}) + +test('addDays: Tagesrechnung ueber die Zeitumstellung hinweg', () => { + assert.equal(addDays('2026-03-29', 1), '2026-03-30') + assert.equal(addDays('2026-10-25', 1), '2026-10-26') + assert.equal(addDays('2026-03-28', 2), '2026-03-30') +}) + +test('addDays: Monats- und Jahreswechsel, Schaltjahr, negative Werte', () => { + assert.equal(addDays('2026-12-31', 1), '2027-01-01') + assert.equal(addDays('2028-02-28', 1), '2028-02-29') + assert.equal(addDays('2026-10-01', -89), '2026-07-04') + assert.equal(addDays('2026-10-01', 0), '2026-10-01') +}) + +test('daysBetween: ganze Tage, toYmd minus fromYmd', () => { + assert.equal(daysBetween('2026-10-01', '2027-03-29'), 179) + assert.equal(daysBetween('2027-03-29', '2026-10-01'), -179) + assert.equal(daysBetween('2026-10-01', '2026-10-01'), 0) + // Zeitumstellung: 23-Stunden-Tag zaehlt als ein Tag + assert.equal(daysBetween('2026-03-28', '2026-03-30'), 2) + assert.equal(daysBetween('2026-10-24', '2026-10-26'), 2) +}) + +test('formatDe: TT.MM.JJJJ', () => { + assert.equal(formatDe('2027-03-29'), '29.03.2027') + assert.equal(formatDe('2026-01-05'), '05.01.2026') +}) + +test('ungueltige Daten werden abgelehnt', () => { + for (const bad of ['', '2026-13-01', '2026-02-30', '29.03.2027', null, undefined, '2026-1-1']) { + assert.throws(() => addDays(bad, 1), /Ungültiges Datum/) + assert.throws(() => daysBetween(bad, '2026-10-01'), /Ungültiges Datum/) + assert.throws(() => formatDe(bad), /Ungültiges Datum/) + } + assert.throws(() => addDays('2026-10-01', 1.5), /ganze Zahl/) +})