Löschfristen: Probelauf, Bericht ans Team, Löschen nach drei Jahren
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc42ab16c1
commit
9342ba10c8
5 files changed
+484
-1
No files matched your search
@@ -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 <container> node scripts/loeschfristen.mjs (Probelauf: Bericht, loescht nichts)
|
||||
* docker exec -w /app -e LOESCHFRISTEN=mail <container> node scripts/loeschfristen.mjs (Bericht als T14 ans Team)
|
||||
* docker exec -w /app -e LOESCHFRISTEN=apply <container> 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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
T14 · Löschfristen fällig (Datenschutz)
|
||||
Betreff: [Datenschutz] Löschfristen fällig
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{modus}}, {{anzahl_kunden}}, {{bericht}}
|
||||
modus ist „Probelauf“ (Bericht vor dem Löschen) oder „Gelöscht“; bericht ist der Text aus scripts/loeschfristen.mjs (Zeilenumbrüche bleiben).
|
||||
-->
|
||||
<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>[Datenschutz] Löschfristen fällig</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;}.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;">{{modus}}: {{anzahl_kunden}} Kunde(n) mit abgelaufener Löschfrist. ͏ ͏ ͏</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 · Kundenportal</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:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Datenschutz</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;">Löschfristen fällig</td></tr>
|
||||
<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="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lauf</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{modus}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Kunden mit abgelaufener Frist</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{anzahl_kunden}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 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:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bericht</div><div class="mut" style="color:#4a4843;">{{bericht}}</div></td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#4a4843;" ><span class="mut" style="color:#4a4843;">Gelöscht wird nur mit <strong>LOESCHFRISTEN=apply</strong> (im Container: <code>node scripts/loeschfristen.mjs</code>). Rechnungen bleiben (Aufbewahrung acht Jahre).</span></td></tr>
|
||||
|
||||
<tr><td style="padding:0 0 44px 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 · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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}`]))
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user