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:
1 parent
cba14ed0f0
commit
7b35ed67a5
5 files changed
+375
-5
No files matched your search
@@ -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. ͏ ͏ ͏</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 → 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 →</a></td></tr></table></td></tr>
|
||||
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;"> </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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeBankSync.test.js)
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { checkBankConsent } = 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 { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(async () => {
|
||||
setMailTransportForTests(null)
|
||||
await Promise.all([aw.close(), st.close()])
|
||||
})
|
||||
|
||||
// "Jetzt" der Pruefung: 01.10.2026 11:00 Berliner Zeit
|
||||
const NOW = new Date('2026-10-01T09:00:00Z')
|
||||
const DAY_MS = 86_400_000
|
||||
const SESSION = 'sess-geheim-4711'
|
||||
const ACCOUNT = 'konto-uid-4711'
|
||||
|
||||
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
|
||||
/** validUntil, das von NOW aus gesehen in tage Tagen (zur selben Uhrzeit) liegt. */
|
||||
const inDays = (tage, from = NOW) => new Date(from.getTime() + tage * DAY_MS).toISOString()
|
||||
const connect = (patch = {}) => store.saveConnection({ status: 'aktiv', sessionId: SESSION, accountUid: ACCOUNT, warnedThresholds: '', ...patch })
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
|
||||
mails.sent.length = 0
|
||||
setMailTransportForTests(mails)
|
||||
})
|
||||
|
||||
test('29 Tage: Warnung bei 30 Tagen, am selben Tag nicht noch einmal', async () => {
|
||||
await connect({ validUntil: inDays(29) })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
const [mail] = mails.sent
|
||||
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom läuft in 29 Tagen ab')
|
||||
assert.match(mail.html, /30\.10\.2026/)
|
||||
assert.ok(mail.html.includes(`${config.womsBaseUrl}/finance/einstellungen`), 'Link zu WOMS Finanzen Einstellungen')
|
||||
assert.doesNotMatch(mail.html, /Der Zugang ist bereits abgelaufen/)
|
||||
assert.equal(connection().warnedThresholds, '30')
|
||||
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + 3 * 3_600_000) }), { sent: null })
|
||||
assert.equal(mails.sent.length, 1, 'keine zweite Mail')
|
||||
})
|
||||
|
||||
test('der Beispielwert 29.03.2027 steht als gueltig_bis in der Mail', async () => {
|
||||
await connect({ validUntil: '2027-03-29T00:00:00.000Z' })
|
||||
assert.deepEqual(await checkBankConsent({ now: new Date('2027-03-10T09:00:00Z') }), { sent: 30 })
|
||||
assert.match(mails.sent[0].html, /29\.03\.2027/)
|
||||
assert.match(mails.sent[0].text, /29\.03\.2027/)
|
||||
})
|
||||
|
||||
test('13 Tage: Schwelle 14 (die darueberliegende 30 gilt damit als erledigt)', async () => {
|
||||
await connect({ validUntil: inDays(13) })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 14 })
|
||||
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom läuft in 13 Tagen ab')
|
||||
assert.equal(connection().warnedThresholds, '30,14')
|
||||
// am naechsten Tag weder 14 noch 30
|
||||
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + DAY_MS) }), { sent: null })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('mehr als 30 Tage: keine Mail', async () => {
|
||||
await connect({ validUntil: inDays(31) })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(connection().warnedThresholds, '')
|
||||
})
|
||||
|
||||
test('Schwellen nacheinander: 30, 14, 3, am Ablauftag 0, jeweils einmal', async () => {
|
||||
// Ablauf am 10.11. um 14:00 Berliner Zeit: am Ablauftag um 11:00 gilt der Zugang noch
|
||||
await connect({ validUntil: new Date(NOW.getTime() + 40 * DAY_MS + 3 * 3_600_000).toISOString() })
|
||||
const sentAt = []
|
||||
for (let day = 0; day <= 41; day += 1) {
|
||||
const now = new Date(NOW.getTime() + day * DAY_MS)
|
||||
const { sent } = await checkBankConsent({ now })
|
||||
if (sent !== null) sentAt.push([40 - day, sent])
|
||||
}
|
||||
// [verbleibende Tage, Schwelle]; 41 = der Tag nach dem Ablauf: 0 war schon gemeldet
|
||||
assert.deepEqual(sentAt, [[30, 30], [14, 14], [3, 3], [0, 0]])
|
||||
assert.deepEqual(
|
||||
mails.sent.map((m) => m.subject),
|
||||
[
|
||||
'[Finanzen] Bankzugang Finom läuft in 30 Tagen ab',
|
||||
'[Finanzen] Bankzugang Finom läuft in 14 Tagen ab',
|
||||
'[Finanzen] Bankzugang Finom läuft in 3 Tagen ab',
|
||||
'[Finanzen] Bankzugang Finom läuft heute ab',
|
||||
]
|
||||
)
|
||||
assert.equal(connection().warnedThresholds, '30,14,3,0')
|
||||
})
|
||||
|
||||
test('Betreff: 2 Tage, morgen, heute (noch gueltig) und abgelaufen', async () => {
|
||||
const cases = [
|
||||
[2, 3, 'läuft in 2 Tagen ab'],
|
||||
[1, 3, 'läuft morgen ab'],
|
||||
[0, 0, 'läuft heute ab'],
|
||||
]
|
||||
for (const [tage, threshold, text] of cases) {
|
||||
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
|
||||
mails.sent.length = 0
|
||||
// gleicher Berliner Tag wie NOW, aber noch in der Zukunft (13:00 Berliner Zeit)
|
||||
const validUntil = tage === 0 ? '2026-10-01T11:00:00.000Z' : inDays(tage)
|
||||
await connect({ validUntil })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: threshold }, text)
|
||||
assert.equal(mails.sent[0].subject, `[Finanzen] Bankzugang Finom ${text}`)
|
||||
assert.doesNotMatch(mails.sent[0].html, /Der Zugang ist bereits abgelaufen/, text)
|
||||
}
|
||||
})
|
||||
|
||||
test('abgelaufen: Schwelle 0, Betreff "ist abgelaufen", Block abgelaufen, nur einmal', async () => {
|
||||
await connect({ validUntil: inDays(-3) })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 0 })
|
||||
const [mail] = mails.sent
|
||||
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom ist abgelaufen')
|
||||
assert.match(mail.html, /Der Zugang ist bereits abgelaufen/)
|
||||
assert.match(mail.html, /28\.09\.2026/)
|
||||
assert.equal(connection().warnedThresholds, '30,14,3,0')
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('Ablaufzeit heute schon vorbei: abgelaufen, nicht "heute"', async () => {
|
||||
await connect({ validUntil: '2026-10-01T06:00:00.000Z' })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 0 })
|
||||
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom ist abgelaufen')
|
||||
})
|
||||
|
||||
test('der Berliner Kalendertag zaehlt (Ablauf kurz nach Mitternacht, spaeter Abend davor)', async () => {
|
||||
// 23:30 Berliner Zeit am 01.10.; Ablauf 02.10. 00:30 Berliner Zeit -> morgen
|
||||
await connect({ validUntil: '2026-10-01T22:30:00.000Z' })
|
||||
assert.deepEqual(await checkBankConsent({ now: new Date('2026-10-01T21:30:00Z') }), { sent: 3 })
|
||||
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom läuft morgen ab')
|
||||
assert.match(mails.sent[0].html, /02\.10\.2026/, 'gueltig_bis ist der Berliner Tag')
|
||||
})
|
||||
|
||||
test('keine Verbindung, kein oder kaputtes validUntil: nichts zu tun', async () => {
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
await connect({ validUntil: '' })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
await connect({ validUntil: 'morgen' })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Versand scheitert: Schwelle bleibt offen und wird beim naechsten Lauf nachgeholt', async () => {
|
||||
await connect({ validUntil: inDays(29) })
|
||||
setMailTransportForTests({
|
||||
async sendMail() {
|
||||
throw new Error('SMTP down')
|
||||
},
|
||||
})
|
||||
const originalError = console.error
|
||||
console.error = () => {}
|
||||
try {
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
} finally {
|
||||
console.error = originalError
|
||||
}
|
||||
assert.equal(connection().warnedThresholds, '')
|
||||
setMailTransportForTests(mails)
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('ohne Mailversand (SMTP nicht eingerichtet) bleibt die Schwelle offen', async () => {
|
||||
await connect({ validUntil: inDays(29) })
|
||||
setMailTransportForTests(null)
|
||||
// useTestEnv leert SMTP_HOST: sendAdminNotice meldet "nicht konfiguriert"
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
assert.equal(connection().warnedThresholds, '')
|
||||
})
|
||||
|
||||
test('keine Sitzungs-ID, kein Kontoschluessel in der Mail', async () => {
|
||||
await connect({ validUntil: inDays(2) })
|
||||
await checkBankConsent({ now: NOW })
|
||||
const [mail] = mails.sent
|
||||
for (const secret of [SESSION, ACCOUNT]) {
|
||||
assert.ok(!mail.html.includes(secret) && !mail.text.includes(secret) && !mail.subject.includes(secret))
|
||||
}
|
||||
})
|
||||
|
||||
test('Mail an das Team (chatNotify) mit Text- und HTML-Fassung', async () => {
|
||||
await connect({ validUntil: inDays(2) })
|
||||
await checkBankConsent({ now: NOW })
|
||||
const [mail] = mails.sent
|
||||
assert.deepEqual(mail.to.map((t) => t.address).sort(), [...config.chatNotify.emails].sort())
|
||||
assert.match(mail.text, /Neu verbinden/)
|
||||
assert.match(mail.text, /Kontostand und Umsätze/)
|
||||
assert.match(mail.html, /Neu verbinden/)
|
||||
})
|
||||
|
||||
test('Neuverbindung loescht die Warnstufen: die naechste Frist warnt wieder bei 30', async () => {
|
||||
await connect({ validUntil: inDays(2), warnedThresholds: '30,14,3' })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
|
||||
// Erneuerung wie in completeBankAuth: neue Frist, Warnstufen leer
|
||||
await store.saveConnection({ validUntil: inDays(179), warnedThresholds: '' })
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null }, 'noch weit weg')
|
||||
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + 150 * DAY_MS) }), { sent: 30 })
|
||||
})
|
||||
|
||||
test('Frist zwischen Pruefung und Speichern erneuert: die neuen Warnstufen bleiben leer', async () => {
|
||||
await connect({ validUntil: inDays(29) })
|
||||
// Der Versand dauert; waehrenddessen wird der Zugang erneuert (neue Frist, Warnstufen geleert)
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
await store.saveConnection({ validUntil: inDays(179), warnedThresholds: '' })
|
||||
return mails.sendMail(mail)
|
||||
},
|
||||
})
|
||||
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
|
||||
assert.equal(connection().warnedThresholds, '', 'die Erneuerung wurde nicht mit der alten Schwelle ueberschrieben')
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
|
||||
const EXPECTED = [
|
||||
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||||
'K12',
|
||||
@@ -24,6 +24,7 @@ const EXPECTED = [
|
||||
'T15',
|
||||
'T16',
|
||||
'T17',
|
||||
'T18',
|
||||
]
|
||||
|
||||
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||||
@@ -39,8 +40,8 @@ for (const id of EXPECTED) {
|
||||
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
|
||||
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||||
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail) oder [Finanzen] (Bankzugang)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit] oder [Finanzen] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
|
||||
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||||
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||||
|
||||
Reference in new issue
Block a user