Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b35ed67a5
commit
6af9834293
3 files changed
+561
No files matched your search
@@ -36,6 +36,7 @@ import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
@@ -86,6 +87,7 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
||||
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
||||
app.use('/api/admin/finanzen', adminFinanzenRoutes)
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
||||
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung und Buchungen. 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'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Buchung ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */
|
||||
const publicTransaction = (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 {
|
||||
const buchungen = (await listTransactions()).map(publicTransaction)
|
||||
return res.json({ buchungen })
|
||||
} catch (err) {
|
||||
logFailure('Buchungen laden', err)
|
||||
return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user