import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { withLocks } from '../../services/keyedLock.js' import { EnableBankingError } from '../../services/finance/enableBanking.js' import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js' import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js' import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js' import { SettingsError, createFixedCost, deleteFixedCost, getSettings, listFixedCosts, patchSettings, updateFixedCost, } from '../../services/finance/settings.js' /** * Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, Einstellungen und * Fixkosten. Die Handler sind klein und rufen die Dienste in server/services/finance auf; weitere Finanz-Routen kommen * in dieselbe Datei. */ const router = Router() router.use(requireAppwriteStaff) // Der Knopf "Aktualisieren" darf die Bank hoechstens alle 10 Minuten abfragen const MANUAL_SYNC_COOLDOWN_MS = 10 * 60_000 const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync'] // Eine Meldung fuer jeden Fehler auf dem Weg zur Bank (Freigabe starten, Rueckkehr): der Status der Bank // (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.' /** Was die Seite vom Bankzugang sieht: nie Sitzung, IBAN, state oder Konto-Kennungen. */ function publicConnection(connection) { const pick = (key) => connection[key] ?? null return { status: pick('status'), ibanMasked: pick('ibanMasked'), validUntil: pick('validUntil'), lastSyncAt: pick('lastSyncAt'), lastSyncOk: pick('lastSyncOk'), lastError: pick('lastError'), balanceBookedCents: pick('balanceBookedCents'), balanceAvailableCents: pick('balanceAvailableCents'), balanceDate: pick('balanceDate'), latestBookingDate: pick('latestBookingDate'), } } /** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */ const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$'))) /** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */ const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '') router.get('/bank', async (_req, res) => { try { const connection = await getConnection() return res.json({ verbindung: connection ? publicConnection(connection) : null }) } catch (err) { logFailure('Bankstatus laden', err) return res.status(500).json({ error: 'Bankstatus konnte nicht geladen werden.' }) } }) /** * Sperre des Knopfs "Aktualisieren": prueft lastManualSyncAt und setzt es gleich, bevor der Abruf beginnt (zwei * Klicks gleichzeitig starten so nur einen Abruf). Liefert null (frei) oder die Minuten bis zum naechsten Abruf. * Ohne Bankzugang gibt es nichts zu sperren; der Abruf meldet das dann selbst. */ function claimManualSync() { return withLocks(MANUAL_SYNC_LOCK, async () => { const connection = await getConnection() if (!connection) return null const now = Date.now() const lastMs = Date.parse(connection.lastManualSyncAt ?? '') if (!Number.isNaN(lastMs) && now - lastMs < MANUAL_SYNC_COOLDOWN_MS) { return Math.ceil((MANUAL_SYNC_COOLDOWN_MS - (now - lastMs)) / 60_000) } await saveConnection({ lastManualSyncAt: new Date(now).toISOString() }) return null }) } router.post('/bank/aktualisieren', async (req, res) => { try { const waitMinutes = await claimManualSync() if (waitMinutes !== null) { return res.status(429).json({ error: `Bitte in ${waitMinutes} ${waitMinutes === 1 ? 'Minute' : 'Minuten'} erneut versuchen.` }) } // Ein Mensch loest den Abruf aus: IP und Browser gehen an die Bank (zaehlt nicht auf ihr Tageslimit) return res.json(await syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } })) } catch (err) { logFailure('Bankabruf starten', err) return res.status(500).json({ error: 'Der Bankabruf konnte nicht gestartet werden.' }) } }) router.post('/bank/verbinden', async (_req, res) => { try { return res.json(await startBankAuth()) } catch (err) { logFailure('Freigabe starten', err) return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE }) } }) router.post('/bank/rueckkehr', async (req, res) => { const { code, state, error } = req.body || {} try { const { validUntil } = await completeBankAuth({ code, state, error }, { psu: { ip: req.ip, userAgent: req.get('User-Agent') } }) return res.json({ ok: true, validUntil }) } catch (err) { // 400 mit deutscher Meldung kommt aus der Pruefung der Rueckkehr (state, Ablauf, Fehler von Finom); ein 400 der // Bank selbst (EnableBankingError, z. B. ungueltiger Code) gehoert nicht dazu if (err?.status === 400 && !(err instanceof EnableBankingError)) return res.status(400).json({ error: err.message }) // Alles andere (auch ein 401 der Bank beim Codetausch) ist fuer die Seite: Freigabe nicht zustande gekommen logFailure('Rückkehr von der Bank', err) return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE }) } }) router.get('/buchungen', async (_req, res) => { try { // Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt return res.json(await withSuggestions((await listTransactions()).map(publicDoc))) } catch (err) { logFailure('Buchungen laden', err) return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' }) } }) const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.' const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.' // Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..." const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe') /** * Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt * ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite * und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite. */ function sendAssignError(res, what, err) { if (err instanceof AssignError) return res.status(err.status).json({ error: err.message }) logFailure(what, err) if (isStripeError(err)) { const status = err.statusCode if (status >= 400 && status < 500 && status !== 429) return res.status(409).json({ error: STRIPE_REJECTED_MESSAGE }) return res.status(502).json({ error: STRIPE_UNAVAILABLE_MESSAGE }) } return res.status(500).json({ error: 'Interner Fehler.' }) } router.post('/buchungen/:id/zuordnen', async (req, res) => { const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {} try { const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note }) return res.json({ buchung: publicDoc(buchung) }) } catch (err) { return sendAssignError(res, 'Buchung zuordnen', err) } }) router.delete('/buchungen/:id/zuordnung', async (req, res) => { try { return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) }) } catch (err) { return sendAssignError(res, 'Zuordnung lösen', err) } }) /** * Antwort auf einen Fehler bei Einstellungen und Fixkosten: SettingsError (400 ungueltige Eingabe, 404 unbekannte * Position, mit anzeigbarer Meldung); alles andere (Appwrite) wird protokolliert und ist ein 500 ohne dessen Meldung. */ function sendSettingsError(res, what, err) { if (err instanceof SettingsError) return res.status(err.status).json({ error: err.message }) logFailure(what, err) return res.status(500).json({ error: 'Interner Fehler.' }) } router.get('/einstellungen', async (_req, res) => { try { return res.json({ einstellungen: await getSettings() }) } catch (err) { return sendSettingsError(res, 'Einstellungen laden', err) } }) router.patch('/einstellungen', async (req, res) => { try { return res.json({ einstellungen: await patchSettings(req.body) }) } catch (err) { return sendSettingsError(res, 'Einstellungen speichern', err) } }) router.get('/fixkosten', async (_req, res) => { try { return res.json({ fixkosten: (await listFixedCosts()).map(publicDoc) }) } catch (err) { return sendSettingsError(res, 'Fixkosten laden', err) } }) router.post('/fixkosten', async (req, res) => { try { return res.status(201).json({ position: publicDoc(await createFixedCost(req.body)) }) } catch (err) { return sendSettingsError(res, 'Fixkosten anlegen', err) } }) router.patch('/fixkosten/:id', async (req, res) => { try { return res.json({ position: publicDoc(await updateFixedCost(req.params.id, req.body)) }) } catch (err) { return sendSettingsError(res, 'Fixkosten ändern', err) } }) router.delete('/fixkosten/:id', async (req, res) => { try { await deleteFixedCost(req.params.id) return res.json({ ok: true }) } catch (err) { return sendSettingsError(res, 'Fixkosten löschen', err) } }) export default router