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

+229
View File
@@ -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')
})
+4 -3
View File
@@ -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')