From 7b35ed67a59617481a75e48a13e73587c7c8c03e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:42:07 +0000 Subject: [PATCH] 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 --- server/index.js | 18 ++ .../T18 Bankzugang erneuern.html | 55 +++++ server/services/finance/bankSync.js | 71 +++++- test/financeBankConsent.test.js | 229 ++++++++++++++++++ test/mailTemplates.test.js | 7 +- 5 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 server/mail-templates/T18 Bankzugang erneuern.html create mode 100644 test/financeBankConsent.test.js diff --git a/server/index.js b/server/index.js index 4883f88..39104f9 100644 --- a/server/index.js +++ b/server/index.js @@ -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() diff --git a/server/mail-templates/T18 Bankzugang erneuern.html b/server/mail-templates/T18 Bankzugang erneuern.html new file mode 100644 index 0000000..8527468 --- /dev/null +++ b/server/mail-templates/T18 Bankzugang erneuern.html @@ -0,0 +1,55 @@ + + + + + + + + +[Finanzen] Bankzugang Finom {{ablauf_text}} + + + + +Bankzugang Finom {{ablauf_text}} – in WOMS erneuern. ͏ ͏ ͏ + + + + + diff --git a/server/services/finance/bankSync.js b/server/services/finance/bankSync.js index 188a624..86faa66 100644 --- a/server/services/finance/bankSync.js +++ b/server/services/finance/bankSync.js @@ -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 } +} diff --git a/test/financeBankConsent.test.js b/test/financeBankConsent.test.js new file mode 100644 index 0000000..31bf883 --- /dev/null +++ b/test/financeBankConsent.test.js @@ -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') +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index b7ab648..373cd1a 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -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(/([\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')