Finanzen: Warnung vor Ablauf des Bankzugangs (T18) und Abruf-Jobs

checkBankConsent mailt bei 30, 14, 3 Tagen und am Ablauftag (Berliner Kalendertage),
merkt sich die erreichten Stufen in warnedThresholds und holt eine nicht versandte
Mail beim naechsten Lauf nach. Jobs nur mit Enable Banking: Abruf nach 2 Minuten und
alle 6 Stunden, Pruefung nach 3 Minuten und taeglich.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:01 +00:00
1 parent cba14ed0f0
commit 7b35ed67a5
5 files changed
+375 -5

No files matched your search

+18
View File
@@ -6,6 +6,7 @@ import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
import { runReconcile } from './services/previewReconcile.js'
import { checkBankConsent, syncBank } from './services/finance/bankSync.js'
import { requireSession, sessionMiddleware } from './middleware/session.js'
import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
@@ -186,6 +187,23 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(repoAssign, 60 * 1000).unref()
setInterval(repoAssign, 10 * 60 * 1000).unref()
// Finanzen (nur wenn Enable Banking eingerichtet ist): Bankabruf 2 Minuten nach dem Start und alle 6 Stunden,
// Warnung vor dem Ablauf des Bankzugangs 3 Minuten nach dem Start und taeglich. syncBank wirft nie, meldet einen
// Fehlschlag aber im Ergebnis; die Meldung enthaelt keine Sitzungs- oder Schluesselangaben.
if (config.enableBanking.enabled) {
const bankSync = () =>
syncBank()
.then((r) => !r.ok && console.warn('[finanzen] Bankabruf fehlgeschlagen:', r.error))
.catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message))
setTimeout(bankSync, 2 * 60 * 1000).unref()
setInterval(bankSync, 6 * 60 * 60 * 1000).unref()
const bankConsent = () =>
checkBankConsent().catch((err) => console.warn('[finanzen] Prüfung des Bankzugangs fehlgeschlagen:', err.message))
setTimeout(bankConsent, 3 * 60 * 1000).unref()
setInterval(bankConsent, 24 * 60 * 60 * 1000).unref()
}
// Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet)
if (config.previewRunner.enabled) {
setTimeout(runReconcile, 30 * 1000).unref()
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T18 · Bankzugang erneuern (Finanzen)
Betreff: [Finanzen] Bankzugang Finom {{ablauf_text}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: das Team (kenso@webklar.com, andrej@webklar.com)
Platzhalter: {{ablauf_text}}, {{gueltig_bis}}, {{link}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): abgelaufen
ablauf_text ist „läuft in N Tagen ab“ (ab 2 Tagen), „läuft morgen ab“, „läuft heute ab“ oder „ist abgelaufen“; gueltig_bis steht als TT.MM.JJJJ; link öffnet in WOMS Finanzen → Einstellungen („Neu verbinden“); der Block abgelaufen erscheint nur, wenn der Zugang schon abgelaufen ist. Die Mail kommt 30, 14 und 3 Tage vor Ablauf und am Ablauftag.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Finanzen] Bankzugang Finom {{ablauf_text}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bankzugang Finom {{ablauf_text}} – in WOMS erneuern.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Finanzen</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Bankzugang</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Der Bankzugang zu Finom {{ablauf_text}}</td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Läuft der Zugang ab, werden Kontostand und Umsätze nicht mehr abgerufen. Das Erneuern dauert etwa eine Minute.</td></tr>
<!-- WENN abgelaufen -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:14px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Der Zugang ist bereits abgelaufen. Kontostand und Umsätze werden nicht mehr abgerufen, bis er erneuert ist.</td></tr></table></td></tr>
<!-- ENDE abgelaufen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Gültig bis</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{gueltig_bis}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Erneuern</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">In WOMS unter Finanzen &rarr; Einstellungen auf „Neu verbinden“ klicken und die Freigabe bei Finom bestätigen.</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Bankzugang erneuern &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal. Sie kommt 30, 14 und 3 Tage vor Ablauf und am Ablauftag.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+69 -2
View File
@@ -1,7 +1,9 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { sendAdminNotice } from '../mailer.js'
import { createSession, EnableBankingError, getBalances, getTransactions, startAuth, toCents } from './enableBanking.js'
import { addDays, berlinDate } from './dates.js'
import { addDays, berlinDate, daysBetween, formatDe } from './dates.js'
import { deleteTransaction, getConnection, listPendingTransactions, saveConnection, upsertTransaction } from './bankStore.js'
/**
@@ -11,7 +13,8 @@ import { deleteTransaction, getConnection, listPendingTransactions, saveConnecti
* Bank nicht mehr nennt, werden geloescht. syncBank wirft nie: jeder Fehler landet in der Verbindung (lastError).
*
* Ausserdem der Weg zum neuen Bankzugang (startBankAuth, completeBankAuth): Freigabe bei Finom starten, nach der
* Rueckkehr die Sitzung speichern und sofort einmal abrufen.
* Rueckkehr die Sitzung speichern und sofort einmal abrufen, und die Warnung vor dem Ablauf des Zugangs
* (checkBankConsent, Mail T18).
*/
// Die Bank liefert hoechstens ~90 Tage: ein Abruf beginnt spaetestens heute - 89 Tage, sonst eine Woche vor der
@@ -271,3 +274,67 @@ export async function completeBankAuth({ code, state, error } = {}, { now = new
const sync = await syncBank({ now, psu })
return { ok: true, validUntil, sync }
}
// ---------- Warnung vor Ablauf des Bankzugangs ----------
// Warnstufen in Tagen bis zum Ablauf; 0 = am Ablauftag (und danach, falls die Mail zuvor nicht ankam)
const WARN_THRESHOLDS = [30, 14, 3, 0]
const parseWarned = (value) =>
String(value || '')
.split(',')
.map((part) => Number.parseInt(part, 10))
.filter((n) => Number.isInteger(n))
/** Der Satzteil im Betreff: "läuft in 14 Tagen ab", "läuft morgen ab", "läuft heute ab", "ist abgelaufen". */
function ablaufText({ expired, tage }) {
if (expired) return 'ist abgelaufen'
if (tage <= 0) return 'läuft heute ab'
if (tage === 1) return 'läuft morgen ab'
return `läuft in ${tage} Tagen ab`
}
/**
* Warnt das Team per Mail (T18), wenn der Bankzugang bald ablaeuft oder abgelaufen ist. tage = Berliner
* Kalendertage von heute bis zum Ablauftag (0 am Ablauftag, solange der Zugang noch gilt; abgelaufen, sobald
* validUntil vorbei ist). Warnstufen 30, 14, 3 und 0: erreicht ist eine Stufe, wenn tage <= Stufe; gemailt wird
* die kleinste erreichte, falls sie noch nicht in warnedThresholds steht. Gespeichert werden danach alle
* erreichten Stufen (sonst kaeme nach "14" am naechsten Tag noch "30"). Die Stufen stehen in der Verbindung
* und werden bei einer Neuverbindung geleert (completeBankAuth). Kommt die Mail nicht raus, bleibt die Stufe
* offen und der naechste Lauf versucht es erneut. Liefert { sent: Stufe } oder { sent: null }; wirft nur, wenn
* Appwrite nicht erreichbar ist (der Job faengt das ab).
*/
export async function checkBankConsent({ now = new Date() } = {}) {
const connection = await getConnection()
const validUntilMs = Date.parse(connection?.validUntil ?? '')
if (Number.isNaN(validUntilMs)) return { sent: null }
const ablaufTag = berlinDate(new Date(validUntilMs))
const tage = daysBetween(berlinDate(now), ablaufTag)
const expired = validUntilMs <= now.getTime()
const reached = WARN_THRESHOLDS.filter((threshold) => tage <= threshold)
if (!reached.length) return { sent: null }
const threshold = Math.min(...reached)
if (parseWarned(connection.warnedThresholds).includes(threshold)) return { sent: null }
const result = await sendAdminNotice({
template: 'T18',
values: {
ablauf_text: ablaufText({ expired, tage }),
gueltig_bis: formatDe(ablaufTag),
link: `${config.womsBaseUrl}/finance/einstellungen`,
},
blocks: { abgelaufen: expired },
})
if (!result.sent) return { sent: null }
// Speichern unter der Sperre der Abrufe: eine Neuverbindung, die waehrend des Versands ihre Warnstufen leert
// (completeBankAuth), wird nicht mit den Stufen der alten Frist ueberschrieben
await withLocks(SYNC_LOCK, async () => {
const current = await getConnection()
if (!current || current.validUntil !== connection.validUntil) return
const warned = new Set([...parseWarned(current.warnedThresholds), ...reached])
await saveConnection({ warnedThresholds: [...warned].sort((a, b) => b - a).join(',') })
})
return { sent: threshold }
}