From 9342ba10c8385886b164939daec2082d21fed471 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 14:51:50 +0000 Subject: [PATCH] =?UTF-8?q?L=C3=B6schfristen:=20Probelauf,=20Bericht=20ans?= =?UTF-8?q?=20Team,=20L=C3=B6schen=20nach=20drei=20Jahren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/loeschfristen.mjs | 44 ++++ server/mail-templates/T14 Loeschfristen.html | 49 +++++ server/services/retention.js | 182 ++++++++++++++++ test/mailTemplates.test.js | 3 +- test/retention.test.js | 207 +++++++++++++++++++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 scripts/loeschfristen.mjs create mode 100644 server/mail-templates/T14 Loeschfristen.html create mode 100644 server/services/retention.js create mode 100644 test/retention.test.js diff --git a/scripts/loeschfristen.mjs b/scripts/loeschfristen.mjs new file mode 100644 index 0000000..d6cedd3 --- /dev/null +++ b/scripts/loeschfristen.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Loeschfristen (Datenschutzerklaerung Abschnitt 7): Abo-Daten drei Jahre nach Ablauf des Jahres loeschen, in dem + * das letzte Abo eines Kunden endet. Einmal im Jahr im Januar ausfuehren, im Container als Datei: + * docker exec -w /app node scripts/loeschfristen.mjs (Probelauf: Bericht, loescht nichts) + * docker exec -w /app -e LOESCHFRISTEN=mail node scripts/loeschfristen.mjs (Bericht als T14 ans Team) + * docker exec -w /app -e LOESCHFRISTEN=apply node scripts/loeschfristen.mjs (loescht, Bericht danach) + */ +import 'dotenv/config' +import { applyDeletions, findDueDeletions, formatReport } from '../server/services/retention.js' +import { sendAdminNotice } from '../server/services/mailer.js' + +const MODES = new Set(['', 'mail', 'apply']) + +/** mode: '' (Probelauf) | 'mail' | 'apply'. Liefert { plan, result, applied }. */ +export async function run({ mode = '', now = new Date(), log = console.log } = {}) { + if (!MODES.has(mode)) throw new Error(`LOESCHFRISTEN="${mode}" ist unbekannt: leer (Probelauf), mail oder apply`) + const plan = await findDueDeletions(now) + if (mode === 'apply') { + const result = await applyDeletions(plan) + log('Löschfristen: gelöscht') + log(formatReport(plan, result)) + return { plan, result, applied: true } + } + const report = formatReport(plan) + log('Löschfristen: Probelauf, es wird nichts gelöscht') + log(report) + if (mode === 'mail') { + const sent = await sendAdminNotice({ + template: 'T14', + values: { modus: 'Probelauf', anzahl_kunden: plan.customers.length, bericht: report }, + }) + if (!sent.sent) log(`Bericht nicht verschickt: ${sent.reason}`) + else log('Bericht als T14 ans Team geschickt.') + } + return { plan, result: null, applied: false } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + run({ mode: process.env.LOESCHFRISTEN || '' }).catch((err) => { + console.error('❌', err.message) + process.exit(1) + }) +} diff --git a/server/mail-templates/T14 Loeschfristen.html b/server/mail-templates/T14 Loeschfristen.html new file mode 100644 index 0000000..3de7724 --- /dev/null +++ b/server/mail-templates/T14 Loeschfristen.html @@ -0,0 +1,49 @@ + + + + + + + + +[Datenschutz] Löschfristen fällig + + + + +{{modus}}: {{anzahl_kunden}} Kunde(n) mit abgelaufener Löschfrist. ͏ ͏ ͏ + + + + + diff --git a/server/services/retention.js b/server/services/retention.js new file mode 100644 index 0000000..888de0e --- /dev/null +++ b/server/services/retention.js @@ -0,0 +1,182 @@ +import { rm } from 'node:fs/promises' +import path from 'node:path' +import { config } from '../config.js' +import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js' +import { getStripe } from './stripeBilling.js' +import { filesOf, STATUS } from './takeoverCredits.js' + +/** + * Loeschfristen (Datenschutzerklaerung Abschnitt 7 und 9, Rechtsrunde Entscheidung 11): + * - Abo-Daten: "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet." + * Ein Kunde ist faellig, wenn alle seine Abos beendet sind und seit dem Ende des Kalenderjahres seines + * letzten Abo-Endes drei Jahre vergangen sind (Ende 15.03.2022 -> faellig am 01.01.2026). Geloescht werden + * die Dokumente in subscriptions und contractEvents sowie die Zustimmungsangaben in den Stripe-Metadaten + * (F41b). Rechnungen bleiben (Abschnitt 9). + * - Uebernahme-Antraege: Nachweise abgelehnter Antraege loescht schon die taegliche Bereinigung + * (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die + * Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des + * Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht. + * Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr. + */ + +const SUBSCRIPTIONS = () => config.collections.subscriptions +const EVENTS = () => config.collections.contractEvents +const TAKEOVER = () => config.collections.takeoverCredits + +export const RETENTION_YEARS = 3 +export const RECEIPT_YEARS = 8 +const LIST_LIMIT = 5000 + +/** Alle Zustimmungs-/Nachweisangaben, die das Portal in die Stripe-Metadaten des Abos schreibt. */ +export const CONSENT_METADATA_KEYS = [ + 'termsVersion', + 'termsAcceptedAt', + 'earlyStartConsentAt', + 'startChoice', + 'consentIp', + 'consentUserAgent', + 'consentTextVersion', + 'switchedAt', + 'switchedFrom', +] + +const berlinYear = (dateLike) => + Number(new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(new Date(dateLike))) + +const validDate = (value) => (value && !Number.isNaN(new Date(value).getTime()) ? value : null) + +/** Ende eines Abos (ISO) oder null, wenn es nicht beendet ist. */ +function endOf(doc) { + if (!validDate(doc.endedAt) && doc.status !== 'canceled') return null + return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd) +} + +/** + * @param {Date} [now] + * @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[], + * takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[] }>} + */ +export async function findDueDeletions(now = new Date()) { + const currentYear = berlinYear(now) + const subs = await listDocuments(SUBSCRIPTIONS(), [Query.limit(LIST_LIMIT)]) + const byCustomer = new Map() + for (const doc of subs) { + if (!doc.customerId) continue + if (!byCustomer.has(doc.customerId)) byCustomer.set(doc.customerId, []) + byCustomer.get(doc.customerId).push(doc) + } + + const customers = [] + for (const [customerId, docs] of byCustomer) { + const ends = docs.map(endOf) + // ein laufendes Abo (oder ein beendetes ohne erkennbares Datum) haelt alle Daten des Kunden + if (ends.some((end) => !end)) continue + const lastEndAt = ends.reduce((a, b) => (new Date(a) > new Date(b) ? a : b)) + if (currentYear < berlinYear(lastEndAt) + RETENTION_YEARS + 1) continue + const events = await listDocuments(EVENTS(), [Query.equal('customerId', customerId), Query.limit(LIST_LIMIT)]) + customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) }) + } + + const takeover = [] + const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000 + const rejected = await listDocuments(TAKEOVER(), [Query.equal('status', STATUS.rejected), Query.limit(LIST_LIMIT)]) + for (const doc of rejected) { + if (!filesOf(doc).length || !validDate(doc.decidedAt) || new Date(doc.decidedAt).getTime() > rejectedCutoff) continue + takeover.push({ + id: doc.$id, + action: 'melden', + reason: `abgelehnt am ${doc.decidedAt.slice(0, 10)}, Nachweise noch vorhanden (die tägliche Bereinigung löscht sie nach sechs Monaten; Logs und Upload-Ordner prüfen)`, + }) + } + const approved = await listDocuments(TAKEOVER(), [Query.equal('status', STATUS.approved), Query.limit(LIST_LIMIT)]) + for (const doc of approved) { + const decided = validDate(doc.decidedAt) || validDate(doc.appliedAt) + if (!decided || currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue + takeover.push({ + id: doc.$id, + action: 'loeschen', + reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`, + }) + } + return { customers, takeover } +} + +const isGone = (err) => err?.status === 404 + +async function deleteQuietly(collection, id) { + try { + await deleteDocument(collection, id) + return true + } catch (err) { + if (isGone(err)) return false + throw err + } +} + +/** + * Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die + * Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual; + * die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen. + * @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number }, + * manual: { customerId: string, subscriptionId: string, reason: string }[] }>} + */ +export async function applyDeletions(plan) { + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 } + const manual = [] + const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) + + for (const customer of plan.customers || []) { + for (const id of customer.subscriptionIds) { + try { + await getStripe().subscriptions.update(id, { metadata: clear }) + deleted.stripeCleared += 1 + } catch (err) { + manual.push({ + customerId: customer.customerId, + subscriptionId: id, + reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`, + }) + } + } + for (const id of customer.eventIds) { + if (await deleteQuietly(EVENTS(), id)) deleted.contractEvents += 1 + } + for (const id of customer.subscriptionIds) { + if (await deleteQuietly(SUBSCRIPTIONS(), id)) deleted.subscriptions += 1 + } + } + + for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) { + await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true }) + if (await deleteQuietly(TAKEOVER(), item.id)) deleted.takeover += 1 + } + return { deleted, manual } +} + +const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) + +/** Bericht als Text (Konsole und T14); result nur nach dem Loeschen. */ +export function formatReport(plan, result = null) { + const lines = [] + if (!plan.customers.length) lines.push('Kunden: nichts fällig.') + for (const c of plan.customers) { + lines.push( + `Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)` + ) + } + const del = plan.takeover.filter((t) => t.action === 'loeschen') + const report = plan.takeover.filter((t) => t.action === 'melden') + if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`)) + if (report.length) lines.push('', 'Zur Kenntnis (nicht von diesem Lauf gelöscht):', ...report.map((t) => ` ${t.id}: ${t.reason}`)) + if (result) { + const d = result.deleted + lines.push( + '', + `Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).` + ) + if (result.manual.length) { + lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`)) + } + } + return lines.join('\n') +} diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 850825c..4127d37 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“ kommt mit Task 21 dazu) +// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39) const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', @@ -15,6 +15,7 @@ const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), 'T12', 'T13', + 'T14', ] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) diff --git a/test/retention.test.js b/test/retention.test.js new file mode 100644 index 0000000..f5d42c4 --- /dev/null +++ b/test/retention.test.js @@ -0,0 +1,207 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +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 und temporaerer Upload-Ordner (F41d), BEVOR config.js geladen wird +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + takeoverCredits: schemaKeys('takeoverCredits'), + }, +}) +const st = await startFakeStripe() +const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-')) +process.env.TAKEOVER_UPLOAD_DIR = uploadDir +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { findDueDeletions, applyDeletions, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js') +const { run } = await import('../scripts/loeschfristen.mjs') +const { stripeSubscription } = await import('./support/fixtures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + rmSync(uploadDir, { recursive: true, force: true }) + return Promise.all([aw.close(), st.close()]) +}) + +// 01.01.2026, 12:00 Berlin +const JAN_2026 = new Date('2026-01-01T12:00:00+01:00') +const DEC_2025 = new Date('2025-12-31T12:00:00+01:00') + +const sub = (id, customerId, fields) => aw.seed('subscriptions', { $id: id, stripeSubscriptionId: id, customerId, ...fields }) +const event = (id, customerId, subscriptionId) => + aw.seed('contractEvents', { $id: id, customerId, subscriptionId, type: 'abschluss', status: 'erledigt', consentIp: '10.0.0.1', consentUserAgent: 'x' }) +const stripeSub = (id, customerId, extra = {}) => st.subscriptions.set(id, stripeSubscription({ id, customerId, status: 'canceled', ...extra })) +const dueIds = (plan) => plan.customers.map((c) => c.customerId).sort() + +test('Abo beendet am 15.03.2022: fällig am 01.01.2026, nicht am 31.12.2025', async () => { + sub('r1_sub', 'r1', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + event('r1_ev', 'r1', 'r1_sub') + assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1')) + const plan = await findDueDeletions(JAN_2026) + const entry = plan.customers.find((c) => c.customerId === 'r1') + assert.deepEqual(entry, { customerId: 'r1', lastEndAt: '2022-03-15T10:00:00.000Z', subscriptionIds: ['r1_sub'], eventIds: ['r1_ev'] }) +}) + +test('Ende zählt in deutscher Zeit: 31.12.2021 23:30 UTC ist schon 2022', async () => { + sub('r1b_sub', 'r1b', { status: 'canceled', endedAt: '2021-12-31T23:30:00.000Z' }) + assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1b')) + assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('r1b')) +}) + +test('Abo beendet vor zwei Jahren: nicht fällig', async () => { + sub('r2_sub', 'r2', { status: 'canceled', endedAt: new Date(JAN_2026.getTime() - 2 * 365 * 86400000).toISOString() }) + assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r2')) +}) + +test('altes beendetes Abo und ein laufendes Abo desselben Kunden: nicht fällig', async () => { + sub('r3_old', 'r3', { status: 'canceled', endedAt: '2019-05-01T00:00:00.000Z' }) + sub('r3_new', 'r3', { status: 'active', endedAt: null }) + assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r3')) +}) + +test('das letzte Ende zählt; Ende = endedAt, sonst canceledAt, sonst currentPeriodEnd bei canceled', async () => { + sub('r4_a', 'r4', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' }) + sub('r4_b', 'r4', { status: 'canceled', endedAt: null, canceledAt: '2022-06-01T00:00:00.000Z', currentPeriodEnd: '2010-01-01T00:00:00.000Z' }) + sub('r5_a', 'r5', { status: 'canceled', endedAt: null, canceledAt: null, currentPeriodEnd: '2022-02-01T00:00:00.000Z' }) + sub('r6_a', 'r6', { status: 'past_due', endedAt: null, canceledAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' }) + const plan = await findDueDeletions(JAN_2026) + const r4 = plan.customers.find((c) => c.customerId === 'r4') + assert.equal(r4.lastEndAt, '2022-06-01T00:00:00.000Z') + assert.deepEqual(r4.subscriptionIds.sort(), ['r4_a', 'r4_b']) + assert.ok(dueIds(plan).includes('r5')) + assert.ok(!dueIds(plan).includes('r6'), 'nicht beendet (past_due) zählt nicht') +}) + +test('applyDeletions: löscht Dokumente, leert alle Zustimmungs-Metadaten bei Stripe, lässt Rechnungen und andere Kunden', async () => { + sub('a1_sub', 'a1', { status: 'canceled', endedAt: '2021-01-10T00:00:00.000Z' }) + event('a1_ev', 'a1', 'a1_sub') + event('a1_ev2', 'a1', 'a1_sub') + sub('a2_sub', 'a2', { status: 'active' }) + event('a2_ev', 'a2', 'a2_sub') + stripeSub('a1_sub', 'a1', { + metadata: { switchedAt: '2021-01-01', switchedFrom: 'hosting' }, + }) + st.invoices.set('in_a1', { id: 'in_a1', object: 'invoice', subscription: 'a1_sub' }) + const callsBefore = st.calls.length + const plan = await findDueDeletions(JAN_2026) + assert.ok(plan.customers.some((c) => c.customerId === 'a1')) + const result = await applyDeletions({ customers: plan.customers.filter((c) => c.customerId === 'a1'), takeover: [] }) + assert.deepEqual(result.manual, []) + assert.equal(result.deleted.subscriptions, 1) + assert.equal(result.deleted.contractEvents, 2) + assert.ok(!aw.docs('subscriptions').has('a1_sub')) + assert.ok(!aw.docs('contractEvents').has('a1_ev') && !aw.docs('contractEvents').has('a1_ev2')) + assert.ok(aw.docs('subscriptions').has('a2_sub') && aw.docs('contractEvents').has('a2_ev')) + const update = st.calls.slice(callsBefore).find((c) => c.method === 'POST' && c.path === '/v1/subscriptions/a1_sub') + assert.ok(update, 'Stripe-Abo wurde geändert') + for (const key of CONSENT_METADATA_KEYS) assert.equal(update.body[`metadata[${key}]`], '', `${key} geleert`) + assert.deepEqual(CONSENT_METADATA_KEYS.slice().sort(), [ + 'consentIp', 'consentTextVersion', 'consentUserAgent', 'earlyStartConsentAt', 'startChoice', 'switchedAt', 'switchedFrom', 'termsAcceptedAt', 'termsVersion', + ]) + const meta = st.subscriptions.get('a1_sub').metadata + for (const key of CONSENT_METADATA_KEYS) assert.ok(!(key in meta), `${key} weg`) + assert.equal(meta.webklarCustomerId, 'a1', 'übrige Angaben (Zuordnung) bleiben') + assert.ok(st.invoices.has('in_a1'), 'Rechnungen bleiben') +}) + +test('abgelehnte Stripe-Änderung landet in manual, die Appwrite-Dokumente werden trotzdem gelöscht', async () => { + sub('m1_sub', 'm1', { status: 'canceled', endedAt: '2020-02-02T00:00:00.000Z' }) + event('m1_ev', 'm1', 'm1_sub') + stripeSub('m1_sub', 'm1') + st.failNext({ method: 'POST', path: '/v1/subscriptions/m1_sub', status: 400, message: 'A canceled subscription can only update its cancellation_details.' }) + const result = await applyDeletions({ + customers: [{ customerId: 'm1', lastEndAt: '2020-02-02T00:00:00.000Z', subscriptionIds: ['m1_sub'], eventIds: ['m1_ev'] }], + takeover: [], + }) + assert.equal(result.manual.length, 1) + assert.equal(result.manual[0].subscriptionId, 'm1_sub') + assert.equal(result.manual[0].customerId, 'm1') + assert.match(result.manual[0].reason, /canceled subscription/) + assert.ok(!aw.docs('subscriptions').has('m1_sub') && !aw.docs('contractEvents').has('m1_ev')) + assert.ok('consentIp' in st.subscriptions.get('m1_sub').metadata, 'bei Stripe unverändert, Hinweis im Bericht') +}) + +test('Übernahme-Anträge: abgelehnte nur melden, genehmigte erst nach 8 Jahren ab Jahresende löschen', async () => { + const withFiles = (id) => { + mkdirSync(path.join(uploadDir, id), { recursive: true }) + writeFileSync(path.join(uploadDir, id, 'nachweis.pdf'), 'x') + return JSON.stringify([{ id: 'f1', name: 'nachweis.pdf' }]) + } + const tc = (id, fields) => aw.seed('takeoverCredits', { $id: id, customerId: 't1', ...fields }) + // abgelehnt vor >6 Monaten, Nachweise noch da (tägliche Bereinigung hat sie nicht erwischt): nur melden + tc('tk_rej', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: withFiles('tk_rej') }) + // abgelehnt, Nachweise schon gelöscht: nichts zu melden + tc('tk_rej_clean', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: '[]' }) + // genehmigt 2017: Frist endet mit Ablauf 2025 (8 Jahre ab Jahresende 2017), am 01.01.2026 fällig + tc('tk_old', { status: 'genehmigt', decidedAt: '2017-11-20T00:00:00.000Z', files: withFiles('tk_old') }) + // genehmigt 2018: erst am 01.01.2027 fällig + tc('tk_2018', { status: 'genehmigt', decidedAt: '2018-03-01T00:00:00.000Z', files: withFiles('tk_2018') }) + tc('tk_open', { status: 'offen', files: withFiles('tk_open') }) + const plan = await findDueDeletions(JAN_2026) + const byId = Object.fromEntries(plan.takeover.map((t) => [t.id, t])) + assert.deepEqual(Object.keys(byId).sort(), ['tk_old', 'tk_rej']) + assert.equal(byId.tk_rej.action, 'melden') + assert.match(byId.tk_rej.reason, /abgelehnt/) + assert.equal(byId.tk_old.action, 'loeschen') + assert.match(byId.tk_old.reason, /acht Jahre|8 Jahre/) + + const result = await applyDeletions({ customers: [], takeover: plan.takeover }) + assert.equal(result.deleted.takeover, 1) + assert.ok(aw.docs('takeoverCredits').has('tk_rej') && existsSync(path.join(uploadDir, 'tk_rej')), 'abgelehnter Antrag bleibt der täglichen Bereinigung überlassen') + assert.ok(!aw.docs('takeoverCredits').has('tk_old') && !existsSync(path.join(uploadDir, 'tk_old'))) + assert.ok(aw.docs('takeoverCredits').has('tk_2018') && existsSync(path.join(uploadDir, 'tk_2018'))) + // 2027: auch 2018 ist fällig + const later = await findDueDeletions(new Date('2027-01-02T12:00:00+01:00')) + assert.ok(later.takeover.some((t) => t.id === 'tk_2018' && t.action === 'loeschen')) +}) + +test('Skript: Probelauf ändert nichts und schickt nichts', async () => { + sub('s1_sub', 's1', { status: 'canceled', endedAt: '2021-03-03T00:00:00.000Z' }) + event('s1_ev', 's1', 's1_sub') + stripeSub('s1_sub', 's1') + const callsBefore = st.calls.length + const sentBefore = mails.sent.length + const lines = [] + const result = await run({ mode: '', now: JAN_2026, log: (l) => lines.push(l) }) + assert.equal(result.applied, false) + assert.ok(aw.docs('subscriptions').has('s1_sub') && aw.docs('contractEvents').has('s1_ev')) + assert.equal(st.calls.length, callsBefore) + assert.equal(mails.sent.length, sentBefore) + const text = lines.join('\n') + assert.match(text, /Probelauf/) + assert.match(text, /s1/) +}) + +test('Skript: LOESCHFRISTEN=mail schickt den Bericht als T14 ans Team, löscht nichts', async () => { + const sentBefore = mails.sent.length + const result = await run({ mode: 'mail', now: JAN_2026, log: () => {} }) + assert.equal(result.applied, false) + assert.equal(mails.sent.length, sentBefore + 1) + const mail = mails.sent.at(-1) + assert.match(mail.subject, /^\[Datenschutz\] Löschfristen fällig/) + assert.match(mail.text, /s1/) + assert.match(mail.text, /Probelauf|Vorschau/) + assert.ok(aw.docs('subscriptions').has('s1_sub')) +}) + +test('Skript: LOESCHFRISTEN=apply löscht', async () => { + const result = await run({ mode: 'apply', now: JAN_2026, log: () => {} }) + assert.equal(result.applied, true) + assert.ok(!aw.docs('subscriptions').has('s1_sub') && !aw.docs('contractEvents').has('s1_ev')) + assert.ok(aw.docs('subscriptions').has('a2_sub'), 'laufendes Abo bleibt') +}) + +test('Skript: unbekannter Modus wird abgelehnt', async () => { + await assert.rejects(run({ mode: 'loeschen', now: JAN_2026, log: () => {} }), /LOESCHFRISTEN/) +})