import { rm } from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { loescheShopDaten } from './shop/loeschen.js' import { hatUkDaten, loescheUkDaten } from './unterkunft/aufbewahrung.js' import { disable } from './siteFeatures.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. * - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos * (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in * plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt. * - Funktionsbestellungen: Die Zustimmungsnachweise zu bestellten Funktionen (featureConsents, Datenschutzerklaerung Abschnitt 7: * "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet") loescht derselbe Lauf fuer faellige * Kunden mit (plan.news[].consentIds). Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig; ihre Nachweise bleiben, wie ihre * Beitraege, ausserhalb dieser Frist. * - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument * (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren * Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey). * - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Archiv der bestaetigten Rechtstexte, Artikelbilder, shopConfig; * plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der * Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem * Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js). * - Oeffnungszeiten live: Zeigt der Plan Funktionen 'zeiten' (plan.news[].zeitenIds, Teilmenge von featureIds), loescht der Lauf vor dem * Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures). * Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter * "Fehlgeschlagen" im Bericht. * - Buchungskalender: Fuer faellige Kunden loescht derselbe Lauf auch alle uk*-Daten ihrer Funktionen 'unterkunft' (plan.news[].ukIds, nur wenn es welche gibt; * Deckel UK_LOESCH_DECKEL je Kunde, Rest = Kunde gehalten). Die eigenen Fristen (ipHash, Anonymisierung, Rechnungen 8 Jahre, 90 Tage nach dem * Abschalten) laufen im Tagesjob des Buchungskalenders (unterkunft/aufbewahrung.js). * Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr. * Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden * weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine * Liste abgeschnitten (>= LIST_LIMIT), bricht der Lauf ab, bevor etwas geplant oder geloescht wird. */ const SUBSCRIPTIONS = () => config.collections.subscriptions const EVENTS = () => config.collections.contractEvents const TAKEOVER = () => config.collections.takeoverCredits const NEWS_POSTS = () => config.collections.newsPosts const FEATURES = () => config.collections.portalFeatures const CONSENTS = () => config.collections.featureConsents const SHOP_CONFIG = () => config.collections.shopConfig const SHOP_LOESCH_DECKEL = 2000 const UK_LOESCH_DECKEL = 2000 /** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */ const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt'] const BLANK_FEATURE_FIELDS = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null } const hasConsentFields = (doc) => FEATURE_CONSENT_FIELDS.some((key) => doc[key]) 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) { // Stripe hat das Abo nie aktiviert und verfallen lassen: zaehlt als beendet if (doc.status === 'incomplete_expired' && !validDate(doc.endedAt)) { return validDate(doc.canceledAt) || validDate(doc.$updatedAt) || validDate(doc.startedAt) } if (!validDate(doc.endedAt) && doc.status !== 'canceled') return null return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd) } /** Shops (shopConfig) eines Kunden als { id, siteKey }. Gibt es die Collection noch nicht (Setup-Skript nicht gelaufen), gibt es keine Shops. */ async function shopsOf(customerId, list) { try { const docs = await list(SHOP_CONFIG(), [Query.equal('customerId', customerId)]) return docs.map((d) => ({ id: d.$id, siteKey: d.siteKey || '' })) } catch (err) { if (err?.type === 'collection_not_found' || err?.code === 'collection_not_found') return [] throw err } } /** * @param {Date} [now] * @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[], * news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[], * featureFieldIds: string[] }[], * takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[], * pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>} */ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } = {}) { const currentYear = berlinYear(now) const list = async (collection, queries = []) => { const docs = await listDocuments(collection, [...queries, Query.limit(limit)]) if (docs.length >= limit) throw new Error(`Liste abgeschnitten, Paginierung nötig (${collection}, ${docs.length} Einträge)`) return docs } const subs = await list(SUBSCRIPTIONS()) 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 = [] const pending = [] const skipped = [] for (const [customerId, docs] of byCustomer) { const incomplete = docs.filter((d) => d.status === 'incomplete') const others = docs.filter((d) => d.status !== 'incomplete') if (incomplete.length && others.every((d) => endOf(d))) { pending.push({ customerId, subscriptionIds: incomplete.map((d) => d.$id), reason: 'Abo im Status incomplete hält Löschung auf (Stripe klären: wird es aktiv oder verfällt es?)', }) } 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 list(EVENTS(), [Query.equal('customerId', customerId)]) customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) }) } const news = [] for (const { customerId } of customers) { const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)]) const allFeatures = await list(FEATURES(), [Query.equal('customerId', customerId)]) const features = allFeatures.filter((f) => f.state !== 'disabled') // Bestellangaben (IP, Browser, Text, Zeit) leeren wir an jedem Dokument des Kunden, auch am schon abgeschalteten const withFields = allFeatures.filter(hasConsentFields) const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)]) const shops = await shopsOf(customerId, list) // Buchungskalender: Funktionen 'unterkunft' mit uk*-Daten (auch abgeschaltete); ohne Collections (Setup nicht gelaufen) gibt es keine const ukIds = [] for (const f of allFeatures.filter((x) => x.featureKey === 'unterkunft')) if (await hatUkDaten(f.$id)) ukIds.push(f.$id) if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length && !ukIds.length) continue news.push({ customerId, posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })), featureIds: features.map((f) => f.$id), consentIds: consents.map((c) => c.$id), featureFieldIds: withFields.map((f) => f.$id), ...(shops.length ? { shops } : {}), ...(ukIds.length ? { ukIds } : {}), zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id), }) } const takeover = [] const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000 const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)]) 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 list(TAKEOVER(), [Query.equal('status', STATUS.approved)]) for (const doc of approved) { const decided = validDate(doc.decidedAt) if (!decided) { skipped.push({ id: doc.$id, reason: 'genehmigt, aber ohne Entscheidungsdatum (decidedAt); Frist nicht berechenbar, von Hand prüfen' }) continue } if (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, news, takeover, pending, skipped } } 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 die abhaengigen Daten (News-Beitraege samt Fotos, Funktionen abschalten, * Zustimmungsnachweise der Funktionsbestellungen), dann die Stripe-Metadaten (Abo-ID steht noch im Plan) und zuletzt die * Appwrite-Dokumente der Abos (Vertragsereignisse, dann subscriptions). Grund: findDueDeletions leitet faellige Kunden allein aus den * Abo-Dokumenten ab. Schlaegt ein Loeschen abhaengiger Daten fehl (oder das eines Vertragsereignisses), bleiben die Abo-Dokumente des * Kunden stehen (result.held), der naechste Lauf findet den Kunden wieder und versucht es erneut; sonst blieben die uebrig gebliebenen * Daten fuer immer liegen. Lehnt Stripe die Aenderung 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, * newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number, * zeiten: number, unterkuenfte: number, ukDocs: number }, * manual: { customerId: string, subscriptionId: string, reason: string }[], * failed: { what: string, id: string, reason: string }[], held: string[] }>} */ export async function applyDeletions(plan) { const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0, unterkuenfte: 0, ukDocs: 0 } const manual = [] const failed = [] const held = [] const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) const attempt = async (what, id, fn) => { try { return await fn() } catch (err) { failed.push({ what, id, reason: err.message }) return false } } // News-Beitraege, Funktionen und Zustimmungsnachweise eines Kunden (Eintrag aus plan.news) const deleteDependents = async (entry) => { for (const post of entry.posts) { // erst das Bild (Dokument bleibt bei Fehler), dann der Beitrag if (post.imageId) { const removed = await attempt('newsImages', post.imageId, async () => { await deleteNewsImage(post.projectId, post.imageId) return true }) if (!removed) continue deleted.newsImages += 1 } if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1 } // Oeffnungszeiten: erst die Daten (Dokumente, Merker, Speicher), dann das Abschalten; scheitert das Loeschen, bleiben diese Funktionen // unangetastet und der Fehler steht im Bericht (wie beim Bild vor dem News-Beitrag) const zeitenIds = new Set(entry.zeitenIds || []) let zeitenWeg = true if (zeitenIds.size) { zeitenWeg = await attempt('oeffnungszeiten', entry.customerId, async () => { const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js') deleted.zeiten += await loescheZeitenVonKunde(entry.customerId) return true }) } for (const id of entry.featureIds) { if (!zeitenWeg && zeitenIds.has(id)) continue const off = await attempt('portalFeatures', id, async () => { // disable() meldet bei Analyse auch den Sammeldienst ab (best effort) await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) return true }) if (off) deleted.features += 1 } // IP, Browser, Text und Zeit der Bestellung am Funktionsdokument (auch am abgeschalteten): das Dokument bleibt, die Angaben gehen for (const id of entry.featureFieldIds || []) { const blanked = await attempt('portalFeatures', id, async () => { try { await updateDocument(FEATURES(), id, BLANK_FEATURE_FIELDS) return true } catch (err) { if (isGone(err)) return false throw err } }) if (blanked) deleted.featureFields += 1 } // Zustimmungsnachweise der Funktionsbestellungen: ein Fehler stoppt den Rest nicht for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } // Buchungskalender: alle uk*-Daten der Funktion (Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte, Einstellungen); gemeinsamer Deckel je Kunde let ukRest = UK_LOESCH_DECKEL for (const featureId of entry.ukIds || []) { await attempt('unterkunft', featureId, async () => { const weg = await loescheUkDaten({ featureId, deckel: ukRest }) ukRest -= weg.geloescht deleted.ukDocs += weg.geloescht if (!weg.vollstaendig) throw new Error('Buchungsdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter') deleted.unterkuenfte += 1 return true }) } // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) let shopRest = SHOP_LOESCH_DECKEL for (const shop of entry.shops || []) { await attempt('shopConfig', shop.id, async () => { const weg = await loescheShopDaten({ shopId: shop.id, siteKey: shop.siteKey, deckel: shopRest }) shopRest -= weg.geloescht deleted.shopDocs += weg.geloescht if (!weg.vollstaendig) throw new Error('Shopdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter') deleted.shops += 1 return true }) } } const newsOf = new Map((plan.news || []).map((entry) => [entry.customerId, entry])) for (const customer of plan.customers || []) { const failedBefore = failed.length const entry = newsOf.get(customer.customerId) if (entry) { newsOf.delete(customer.customerId) await deleteDependents(entry) } if (failed.length > failedBefore) { held.push(customer.customerId) continue } 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 attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1 } if (failed.length > failedBefore) { // ein Vertragsereignis ist stehen geblieben: das Abo-Dokument bleibt, sonst wuerde das Ereignis zur Waise held.push(customer.customerId) continue } for (const id of customer.subscriptionIds) { if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1 } } // Eintraege ohne Kunden-Eintrag im Plan (kein Abo-Dokument, das zu halten waere) for (const entry of newsOf.values()) await deleteDependents(entry) for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) { const removed = await attempt('takeoverCredits', item.id, async () => { await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true }) return true }) if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1 } return { deleted, manual, failed, held } } 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)` ) } for (const n of plan.news || []) { const images = n.posts.filter((p) => p.imageId).length const consents = n.consentIds?.length || 0 const fields = n.featureFieldIds?.length || 0 const shops = n.shops?.length || 0 const uk = n.ukIds?.length ? `; ${n.ukIds.length} Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen` : '' const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : '' lines.push( `Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${uk}${zeiten}` ) } 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 (plan.pending?.length) { lines.push('', 'Hält Löschung auf:', ...plan.pending.map((p) => ` Kunde ${p.customerId}, Abo ${p.subscriptionIds.join(', ')}: ${p.reason}`)) } if (plan.skipped?.length) { lines.push('', 'Übersprungen (von Hand prüfen):', ...plan.skipped.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).`, `News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`, `Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`, `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`, ...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : []) ) if (d.unterkuenfte || d.ukDocs) lines.push(`Buchungskalender: ${d.unterkuenfte} Funktion(en) gelöscht (${d.ukDocs} Dokument(e)).`) if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) } if (result.held?.length) { lines.push('', 'Abo-Daten bleiben stehen, bis die abhängigen Daten gelöscht sind (der nächste Lauf findet den Kunden wieder):', ...result.held.map((id) => ` Kunde ${id}`)) } 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') }