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') }