From 9a2a70b491d090d475b097f8b44397363ec02c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:03:54 +0000 Subject: [PATCH] Retention: abhaengige Daten vor dem Abo loeschen, Fehlschlag haelt den Kunden fuer den naechsten Lauf Co-Authored-By: Claude Sonnet 5.5 --- server/services/retention.js | 83 +++++++++++++++--------- test/retention.test.js | 118 ++++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 29 deletions(-) diff --git a/server/services/retention.js b/server/services/retention.js index bb968b6..9835814 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -169,18 +169,23 @@ async function deleteQuietly(collection, id) { } /** - * 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; + * 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 }, * manual: { customerId: string, subscriptionId: string, reason: string }[], - * failed: { what: string, id: 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 } const manual = [] const failed = [] + const held = [] const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) const attempt = async (what, id, fn) => { try { @@ -191,30 +196,10 @@ export async function applyDeletions(plan) { } } - 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 attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1 - } - for (const id of customer.subscriptionIds) { - if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1 - } - } - - for (const entry of plan.news || []) { + // 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 und wird beim naechsten Lauf erneut gefunden), dann der Beitrag + // 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) @@ -233,12 +218,51 @@ export async function applyDeletions(plan) { }) if (off) deleted.features += 1 } - // Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht, der naechste Lauf findet den Eintrag erneut + // Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } } + 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 }) @@ -247,7 +271,7 @@ export async function applyDeletions(plan) { 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 } + return { deleted, manual, failed, held } } const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) @@ -289,6 +313,9 @@ export function formatReport(plan, result = null) { 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}`)) } diff --git a/test/retention.test.js b/test/retention.test.js index d042e90..4788418 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -389,7 +389,8 @@ test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweis assert.match(result.failed[0].reason, /kaputt/) assert.equal(result.deleted.featureConsents, 1) assert.ok(aw.docs('featureConsents').has('fcx_c1') && !aw.docs('featureConsents').has('fcx_c2')) - // der nächste Lauf holt den Rest nach; ein Eintrag, der inzwischen weg ist (404), zählt nicht + // ein zweiter Aufruf mit demselben Plan holt den Rest nach (in einem Lauf mit Abo-Dokument plant ihn findDueDeletions erneut, siehe die Tests + // „Fehlschlag … hält den Kunden“); ein Eintrag, der inzwischen weg ist (404), zählt nicht const nochmal = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] }) assert.deepEqual(nochmal.failed, []) assert.equal(nochmal.deleted.featureConsents, 1) @@ -398,3 +399,118 @@ test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweis assert.equal(alt.deleted.featureConsents, 0) assert.deepEqual(alt.failed, []) }) + +// ---------------------------------------------------------------- Reihenfolge: abhängige Daten vor dem Abo (Fix Runde 1, R119) + +/** Kunde mit beendetem Abo (2022), einem Vertragsereignis und einem Zustimmungsnachweis, ohne Beiträge und Funktionen. */ +function seedFaelligMitNachweis(id) { + sub(`${id}_sub`, id, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + event(`${id}_ev`, id, `${id}_sub`) + stripeSub(`${id}_sub`, id) + aw.seed('featureConsents', { + $id: `${id}_c1`, customerId: id, projectId: `${id}p`, featureKey: 'termine', stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00', + fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com', + }) +} +const planVon = (plan, customerId) => ({ + ...plan, + customers: plan.customers.filter((c) => c.customerId === customerId), + news: plan.news.filter((n) => n.customerId === customerId), + takeover: [], +}) + +test('Fehlschlag beim Löschen eines Zustimmungsnachweises hält den Kunden: Abo und Vertragsereignisse bleiben, der nächste Lauf plant den Nachweis erneut', async () => { + seedFaelligMitNachweis('ro') + const plan = await findDueDeletions(JAN_2026) + assert.ok(dueIds(plan).includes('ro')) + aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' }) + const result = await applyDeletions(planVon(plan, 'ro')) + assert.equal(result.failed.length, 1) + assert.deepEqual([result.failed[0].what, result.failed[0].id], ['featureConsents', 'ro_c1']) + assert.match(result.failed[0].reason, /kaputt/) + assert.deepEqual(result.held, ['ro'], 'der Kunde wird als gehalten gemeldet') + assert.equal(result.deleted.featureConsents, 0) + assert.equal(result.deleted.subscriptions, 0) + assert.equal(result.deleted.contractEvents, 0) + assert.ok(aw.docs('subscriptions').has('ro_sub') && aw.docs('contractEvents').has('ro_ev') && aw.docs('featureConsents').has('ro_c1'), 'nichts davon ist weg') + assert.match(formatReport(planVon(plan, 'ro'), result), /Abo-Daten bleiben[\s\S]*ro/) + // der nächste Lauf findet den Kunden und den Nachweis wieder (das Abo-Dokument ist ja noch da) ... + const zweiter = await findDueDeletions(JAN_2026) + assert.ok(dueIds(zweiter).includes('ro')) + assert.deepEqual(zweiter.news.find((n) => n.customerId === 'ro').consentIds, ['ro_c1']) + // ... und löscht jetzt alles + const fertig = await applyDeletions(planVon(zweiter, 'ro')) + assert.deepEqual(fertig.failed, []) + assert.deepEqual(fertig.held, []) + assert.equal(fertig.deleted.featureConsents, 1) + assert.equal(fertig.deleted.subscriptions, 1) + assert.ok(!aw.docs('subscriptions').has('ro_sub') && !aw.docs('contractEvents').has('ro_ev') && !aw.docs('featureConsents').has('ro_c1')) + assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('ro')) +}) + +test('Fehlschlag beim Löschen eines News-Beitrags hält den Kunden ebenso (dasselbe Muster wie bei den Nachweisen)', async () => { + sub('rp_sub', 'rp', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + event('rp_ev', 'rp', 'rp_sub') + stripeSub('rp_sub', 'rp') + aw.seed('newsPosts', { $id: 'rp_post', customerId: 'rp', projectId: 'rpp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' }) + const plan = await findDueDeletions(JAN_2026) + assert.deepEqual(plan.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post']) + aw.failNext({ collection: 'newsPosts', method: 'DELETE', status: 500, message: 'kaputt' }) + const result = await applyDeletions(planVon(plan, 'rp')) + assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['newsPosts', 'rp_post']]) + assert.deepEqual(result.held, ['rp']) + assert.ok(aw.docs('subscriptions').has('rp_sub') && aw.docs('contractEvents').has('rp_ev') && aw.docs('newsPosts').has('rp_post')) + const zweiter = await findDueDeletions(JAN_2026) + assert.ok(dueIds(zweiter).includes('rp')) + assert.deepEqual(zweiter.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post']) + const fertig = await applyDeletions(planVon(zweiter, 'rp')) + assert.deepEqual(fertig.failed, []) + assert.ok(!aw.docs('subscriptions').has('rp_sub') && !aw.docs('newsPosts').has('rp_post')) +}) + +test('Fehlschlag beim Löschen eines Vertragsereignisses hält das Abo ebenfalls (sonst bliebe das Ereignis als Waise liegen)', async () => { + sub('rq_sub', 'rq', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + event('rq_ev', 'rq', 'rq_sub') + stripeSub('rq_sub', 'rq') + const plan = await findDueDeletions(JAN_2026) + aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' }) + const result = await applyDeletions(planVon(plan, 'rq')) + assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['contractEvents', 'rq_ev']]) + assert.deepEqual(result.held, ['rq']) + assert.ok(aw.docs('subscriptions').has('rq_sub'), 'das Abo-Dokument bleibt, damit der nächste Lauf den Kunden wieder findet') + assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('rq')) + const fertig = await applyDeletions(planVon(await findDueDeletions(JAN_2026), 'rq')) + assert.deepEqual(fertig.failed, []) + assert.ok(!aw.docs('subscriptions').has('rq_sub') && !aw.docs('contractEvents').has('rq_ev')) +}) + +test('Reihenfolge: Beiträge, Funktionen und Nachweise gehen vor den Vertragsereignissen und Abo-Dokumenten', async () => { + seedFaelligMitNachweis('rr') + const plan = await findDueDeletions(JAN_2026) + const reihenfolge = [] + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1]) + return echt(url, optionen) + } + try { + await applyDeletions(planVon(plan, 'rr')) + } finally { + globalThis.fetch = echt + } + assert.deepEqual(reihenfolge, ['featureConsents', 'contractEvents', 'subscriptions']) +}) + +test('Nachweis-Liste an der Grenze: findDueDeletions bricht ab, bevor etwas geplant wird (fail-closed)', async () => { + sub('rs_sub', 'rs', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + stripeSub('rs_sub', 'rs') + const grenze = Math.max(...['subscriptions', 'contractEvents', 'newsPosts', 'portalFeatures', 'takeoverCredits'].map((c) => aw.docs(c).size)) + 1 + for (let i = 0; i < grenze; i += 1) { + aw.seed('featureConsents', { + $id: `rs_c${i}`, customerId: 'rs', projectId: 'rsp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2022-02-01T10:00:00.000+00:00', + fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'k', + }) + } + await assert.rejects(findDueDeletions(JAN_2026, { limit: grenze }), /Liste abgeschnitten, Paginierung nötig \(featureConsents/) + assert.equal([...aw.docs('featureConsents').values()].filter((c) => c.customerId === 'rs').length, grenze, 'nichts gelöscht') +})