diff --git a/server/services/retention.js b/server/services/retention.js index 888de0e..b8c67b4 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -17,6 +17,9 @@ import { filesOf, STATUS } from './takeoverCredits.js' * 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. + * 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 @@ -47,6 +50,10 @@ const validDate = (value) => (value && !Number.isNaN(new Date(value).getTime()) /** 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) } @@ -54,11 +61,17 @@ function endOf(doc) { /** * @param {Date} [now] * @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[], - * takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[] }>} + * 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()) { +export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } = {}) { const currentYear = berlinYear(now) - const subs = await listDocuments(SUBSCRIPTIONS(), [Query.limit(LIST_LIMIT)]) + 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 @@ -67,19 +80,30 @@ export async function findDueDeletions(now = new Date()) { } 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 listDocuments(EVENTS(), [Query.equal('customerId', customerId), Query.limit(LIST_LIMIT)]) + 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 takeover = [] const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000 - const rejected = await listDocuments(TAKEOVER(), [Query.equal('status', STATUS.rejected), Query.limit(LIST_LIMIT)]) + 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({ @@ -88,17 +112,21 @@ export async function findDueDeletions(now = new Date()) { 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)]) + const approved = await list(TAKEOVER(), [Query.equal('status', STATUS.approved)]) for (const doc of approved) { - const decided = validDate(doc.decidedAt) || validDate(doc.appliedAt) - if (!decided || currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue + 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, takeover } + return { customers, takeover, pending, skipped } } const isGone = (err) => err?.status === 404 @@ -118,12 +146,22 @@ async function deleteQuietly(collection, id) { * 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 }[] }>} + * manual: { customerId: string, subscriptionId: string, reason: string }[], + * failed: { what: string, id: string, reason: string }[] }>} */ export async function applyDeletions(plan) { const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 } const manual = [] + const failed = [] 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 + } + } for (const customer of plan.customers || []) { for (const id of customer.subscriptionIds) { @@ -139,18 +177,22 @@ export async function applyDeletions(plan) { } } for (const id of customer.eventIds) { - if (await deleteQuietly(EVENTS(), id)) deleted.contractEvents += 1 + if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1 } for (const id of customer.subscriptionIds) { - if (await deleteQuietly(SUBSCRIPTIONS(), id)) deleted.subscriptions += 1 + if (await attempt('subscriptions', id, () => 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 + 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 } + return { deleted, manual, failed } } const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) @@ -168,12 +210,21 @@ export function formatReport(plan, result = null) { 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).` ) + if (result.failed?.length) { + lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) + } if (result.manual.length) { lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`)) } diff --git a/test/retention.test.js b/test/retention.test.js index f5d42c4..f939b17 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -21,7 +21,7 @@ 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 { findDueDeletions, applyDeletions, formatReport, 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') @@ -205,3 +205,62 @@ test('Skript: LOESCHFRISTEN=apply löscht', async () => { test('Skript: unbekannter Modus wird abgelehnt', async () => { await assert.rejects(run({ mode: 'loeschen', now: JAN_2026, log: () => {} }), /LOESCHFRISTEN/) }) + +test('abgeschnittene Liste: Abbruch vor jeder Planung', async () => { + sub('l1_a', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) + sub('l1_b', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) + await assert.rejects(findDueDeletions(JAN_2026, { limit: 2 }), /Liste abgeschnitten, Paginierung nötig/) + // mit ausreichendem Limit läuft es + assert.ok(Array.isArray((await findDueDeletions(JAN_2026)).customers)) +}) + +test('applyDeletions: Fehler bei einem Dokument stoppen den Rest nicht und landen in failed', async () => { + sub('f1_sub', 'f1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) + sub('f2_sub', 'f2', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) + event('f1_ev', 'f1', 'f1_sub') + event('f1_ev2', 'f1', 'f1_sub') + stripeSub('f1_sub', 'f1') + stripeSub('f2_sub', 'f2') + aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' }) + const plan = { + customers: [ + { customerId: 'f1', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f1_sub'], eventIds: ['f1_ev', 'f1_ev2'] }, + { customerId: 'f2', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f2_sub'], eventIds: [] }, + ], + takeover: [], + } + const result = await applyDeletions(plan) + assert.equal(result.failed.length, 1) + assert.equal(result.failed[0].id, 'f1_ev') + assert.match(result.failed[0].reason, /kaputt/) + assert.equal(result.deleted.contractEvents, 1) + assert.ok(!aw.docs('subscriptions').has('f2_sub'), 'zweiter Kunde wurde trotzdem bearbeitet') + assert.match(formatReport(plan, result), /Fehlgeschlagen[\s\S]*f1_ev/) +}) + +test('incomplete_expired zählt als beendet, incomplete hält auf und steht im Bericht', async () => { + sub('i1_sub', 'i1', { status: 'incomplete_expired', endedAt: null, canceledAt: null, startedAt: '2020-01-01T00:00:00.000Z', $updatedAt: '2021-05-05T00:00:00.000Z' }) + sub('i2_sub', 'i2', { status: 'incomplete_expired', endedAt: null, canceledAt: '2020-02-02T00:00:00.000Z' }) + sub('i3_old', 'i3', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' }) + sub('i3_inc', 'i3', { status: 'incomplete', endedAt: null }) + const plan = await findDueDeletions(JAN_2026) + assert.ok(dueIds(plan).includes('i1')) + assert.ok(dueIds(plan).includes('i2')) + assert.equal(plan.customers.find((c) => c.customerId === 'i2').lastEndAt, '2020-02-02T00:00:00.000Z') + assert.ok(!dueIds(plan).includes('i3')) + assert.ok(plan.pending.some((p) => p.customerId === 'i3' && p.subscriptionIds.includes('i3_inc'))) + assert.match(formatReport(plan), /Hält Löschung auf:[\s\S]*i3_inc/) +}) + +test('aktives Abo mit cancelAtPeriodEnd und altem canceledAt zählt nicht als beendet', async () => { + sub('c1_sub', 'c1', { status: 'active', cancelAtPeriodEnd: true, canceledAt: '2010-01-01T00:00:00.000Z', endedAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' }) + assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('c1')) +}) + +test('genehmigter Antrag ohne decidedAt: übersprungen und gemeldet, kein Rückgriff auf appliedAt', async () => { + aw.seed('takeoverCredits', { $id: 'nd_tk', customerId: 'nd', status: 'genehmigt', appliedAt: '2010-01-01T00:00:00.000Z', files: '[]' }) + const plan = await findDueDeletions(JAN_2026) + assert.ok(!plan.takeover.some((t) => t.id === 'nd_tk')) + assert.ok(plan.skipped.some((t) => t.id === 'nd_tk')) + assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/) +})