From 2fc48df91566498ba764c14c7da63f072c2e6710 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:26:33 +0000 Subject: [PATCH] =?UTF-8?q?TP2=20Abschlusspr=C3=BCfung:=20L=C3=B6schung=20?= =?UTF-8?q?(IP/Browser=20auf=20portalFeatures,=20Erstattungsspur,=20K22-Da?= =?UTF-8?q?tum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markOrdered speichert IP und Browser der Bestellung nicht mehr am Funktionsdokument (nur im Nachweis featureConsents, dort mit Löschfrist); Altwerte werden bei einer neuen Bestellung geleert - Aufbewahrungslauf (retention): consentIp, consentUserAgent, consentText, consentAt werden an allen Funktionsdokumenten eines fälligen Kunden geleert, auch an abgeschalteten; Fehlschlag hält den Kunden wie die übrigen abhängigen Daten - Einmal-Skript scripts/feature-consent-felder-leeren.mjs (Standard Probelauf, --apply leert nur, wo featureConsents die Angabe trägt) - 30-Tage-Reset (jobs.js): auch eine bezahlte Einrichtung mit Grund abo_ende_vor_einrichtung und noch nicht gemeldetem T19 wird nicht zurückgesetzt, solange zahlungMeldung nicht gemeldet ist (die Spur der Erstattung bleibt) - K22: enddatum ist das Abo-Ende (lage.endedAt), sonst der Tag des Abschaltens; loeschdatum bleibt 30 Tage nach dem Abschalten Co-Authored-By: Claude Sonnet 5.5 --- scripts/feature-consent-felder-leeren.mjs | 92 ++++++++++++++++++++ server/routes/funktionen.js | 2 +- server/services/retention.js | 44 ++++++++-- server/services/siteFeatures.js | 11 ++- server/services/termine/aboAbgleich.js | 11 ++- server/services/termine/jobs.js | 10 ++- test/featureConsentFelder.test.js | 100 ++++++++++++++++++++++ test/funktionenCheckout.test.js | 5 +- test/funktionenRoutes.test.js | 3 +- test/retention.test.js | 66 ++++++++++++++ test/siteFeatures.test.js | 17 +++- test/termineAboAbgleich.test.js | 38 ++++++++ test/termineJobs.test.js | 48 +++++++++++ 13 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 scripts/feature-consent-felder-leeren.mjs create mode 100644 test/featureConsentFelder.test.js diff --git a/scripts/feature-consent-felder-leeren.mjs b/scripts/feature-consent-felder-leeren.mjs new file mode 100644 index 0000000..e3ccad8 --- /dev/null +++ b/scripts/feature-consent-felder-leeren.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Einmalig (TP2 Abschlusspruefung): IP und Browser der Bestellung von den Funktionsdokumenten (portalFeatures.consentIp, + * consentUserAgent) leeren. Neue Bestellungen speichern sie dort nicht mehr; der Nachweis (featureConsents) traegt sie und hat die + * Loeschfrist der Datenschutzerklaerung Abschnitt 7 (drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet). Altbestand am + * Funktionsdokument hatte keine Loeschung. + * + * Geleert wird nur dort, wo der Nachweis die Angabe wirklich traegt: ein featureConsents-Eintrag zur Stripe-Sitzung des Dokuments, dessen + * ip bzw. userAgent gesetzt ist, wo das Dokument sie hat. Ohne solchen Nachweis (Bestellung vor dem Nachweis, keine Sitzung) ist die Angabe + * am Dokument der einzige Beleg; sie bleibt stehen und wird im Bericht genannt (die Loeschfrist im Aufbewahrungslauf, services/retention.js, + * leert sie spaeter). consentText und consentAt bleiben: sie sind kein Personenbezug, der Aufbewahrungslauf leert sie mit. + * + * Standard ist der Probelauf: zaehlt und listet, schreibt nichts. + * node scripts/feature-consent-felder-leeren.mjs (= --dry-run) + * node scripts/feature-consent-felder-leeren.mjs --dry-run + * node scripts/feature-consent-felder-leeren.mjs --apply (leert consentIp und consentUserAgent; danach Bericht) + * Im Container als Datei: docker exec -w /app node scripts/feature-consent-felder-leeren.mjs + * NICHT gegen Produktion ausfuehren, ohne dass der Probelauf geprueft und die Freigabe da ist. + */ +import 'dotenv/config' +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { listAllDocuments, updateDocument } from '../server/services/appwriteAdmin.js' +import { zustimmungFuerSitzung } from '../server/services/featureConsents.js' + +/** Aufrufargumente: keine oder --dry-run => Probelauf, --apply => schreiben; alles andere (auch beide zusammen) ist ein Fehler. */ +export function parseArgs(argv = []) { + const flags = new Set(argv) + const unknown = argv.filter((a) => a !== '--apply' && a !== '--dry-run') + if (unknown.length) throw new Error(`Unbekanntes Argument: ${unknown.join(' ')} (erlaubt: --dry-run, --apply)`) + if (flags.has('--apply') && flags.has('--dry-run')) throw new Error('--apply und --dry-run schliessen sich aus') + return { apply: flags.has('--apply') } +} + +/** Traegt der Nachweis jede Angabe, die am Funktionsdokument steht? */ +const nachweisTraegt = (doc, nachweis) => (!doc.consentIp || Boolean(nachweis.ip)) && (!doc.consentUserAgent || Boolean(nachweis.userAgent)) + +/** + * @param {{ apply?: boolean, log?: (line: string) => void }} [options] + * @returns {Promise<{ applied: boolean, total: number, withData: number, clearable: string[], kept: string[], unreadable: string[], + * cleared: number, failed: { id: string, reason: string }[] }>} + */ +export async function run({ apply = false, log = console.log } = {}) { + const docs = await listAllDocuments(config.collections.portalFeatures) + const withData = docs.filter((d) => d.consentIp || d.consentUserAgent) + const clearable = [] + const kept = [] + const unreadable = [] + for (const doc of withData) { + try { + const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId) + if (nachweis && nachweisTraegt(doc, nachweis)) clearable.push(doc.$id) + else kept.push(doc.$id) + } catch (err) { + unreadable.push(doc.$id) + log(`Nachweis zu Dokument ${doc.$id} nicht lesbar: ${err.message}`) + } + } + log(`Funktionsdokumente: ${docs.length}, davon mit IP/Browser der Bestellung: ${withData.length}`) + log(` zu leeren (der Nachweis in featureConsents traegt die Angabe): ${clearable.length}`) + log(` bleiben (kein tragender Nachweis, die Angabe am Dokument ist der einzige Beleg): ${kept.length}${kept.length ? ` (${kept.join(', ')})` : ''}`) + if (unreadable.length) log(` nicht geprueft (Nachweis nicht lesbar, bleiben): ${unreadable.length} (${unreadable.join(', ')})`) + + const failed = [] + let cleared = 0 + if (!apply) { + log('Probelauf (--dry-run): es wird nichts geschrieben. Mit --apply werden die oben genannten Dokumente geleert.') + return { applied: false, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed } + } + for (const id of clearable) { + try { + await updateDocument(config.collections.portalFeatures, id, { consentIp: '', consentUserAgent: '' }) + cleared += 1 + } catch (err) { + failed.push({ id, reason: err.message }) + log(`Dokument ${id} nicht geleert: ${err.message}`) + } + } + log(`Geleert: ${cleared} von ${clearable.length}${failed.length ? `, fehlgeschlagen: ${failed.length} (Skript erneut ausfuehren)` : ''}`) + return { applied: true, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + try { + const { apply } = parseArgs(process.argv.slice(2)) + const result = await run({ apply }) + if (result.failed.length || result.unreadable.length) process.exitCode = 1 + } catch (err) { + console.error('Fehler:', err.message) + process.exit(1) + } +} diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 5376100..5533d57 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -323,7 +323,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { projectId, key, sessionId: created.id, - consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') }, + consent: { text: hakenTexte(feature), at: klickAt }, }) return created }) diff --git a/server/services/retention.js b/server/services/retention.js index 9835814..303c943 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -1,7 +1,7 @@ import { rm } from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' -import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js' +import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { disable } from './siteFeatures.js' import { getStripe } from './stripeBilling.js' @@ -25,6 +25,9 @@ import { filesOf, STATUS } from './takeoverCredits.js' * "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). * 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 @@ -38,6 +41,11 @@ const NEWS_POSTS = () => config.collections.newsPosts const FEATURES = () => config.collections.portalFeatures const CONSENTS = () => config.collections.featureConsents +/** 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 @@ -73,7 +81,8 @@ function endOf(doc) { /** * @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[] }[], + * 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 }[] }>} */ @@ -117,14 +126,18 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } const news = [] for (const { customerId } of customers) { const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)]) - const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled') + 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)]) - if (!posts.length && !features.length && !consents.length) continue + if (!posts.length && !features.length && !consents.length && !withFields.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), }) } @@ -177,12 +190,12 @@ async function deleteQuietly(collection, id) { * 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 }, + * newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: 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 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0 } const manual = [] const failed = [] const held = [] @@ -218,6 +231,19 @@ export async function applyDeletions(plan) { }) 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 zuletzt: ein Fehler stoppt den Rest nicht for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 @@ -288,8 +314,9 @@ export function formatReport(plan, result = null) { 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 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` + `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` ) } const del = plan.takeover.filter((t) => t.action === 'loeschen') @@ -308,7 +335,8 @@ export function formatReport(plan, result = null) { '', `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.` + `Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`, + `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.` ) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 26fc88b..88e3cec 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -255,7 +255,12 @@ export function hakenTexte(feature) { .join('\n\n') } -/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */ +/** + * Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. + * consent: { text, at } - Wortlaut (Kopie fuer das Team) und Zeit des Klicks. IP und Browser der Bestellung stehen NUR im Nachweis + * (featureConsents, Loeschfrist wie dort, Datenschutzerklaerung Abschnitt 7) und werden am Funktionsdokument nie gespeichert: die Felder + * consentIp und consentUserAgent werden leer geschrieben, das leert auch einen Wert aus einer frueheren Bestellung desselben Dokuments. + */ export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) const feature = featureConfig(key) @@ -270,8 +275,8 @@ export async function markOrdered({ customerId, projectId, key, sessionId, conse orderedAt: new Date().toISOString(), consentText: String(consent.text || '').slice(0, 1000), consentAt: consent.at || null, - consentIp: String(consent.ip || '').slice(0, 64), - consentUserAgent: String(consent.userAgent || '').slice(0, 300), + consentIp: '', + consentUserAgent: '', }) } diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js index d7f3941..97ce504 100644 --- a/server/services/termine/aboAbgleich.js +++ b/server/services/termine/aboAbgleich.js @@ -81,7 +81,7 @@ const istBewertet = (doc) => * Schritt 2b: bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist. * Das Abo spielt keine Rolle: die Erstattung bleibt geschuldet, auch wenn es zurueckkommt. */ -const istErstattungOffen = (doc) => +export const istErstattungOffen = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true && doc.zahlungMeldung !== 'gemeldet' /** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */ @@ -93,7 +93,8 @@ const portalFeatures = () => config.collections.portalFeatures * Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2, Schritt 1b und der Sofortpfad E9 rufen nur ihn, nie T19 oder * K22 einzeln). Reihenfolge: (T19,) K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now. * - Grund 'abo_ende': K22 mit Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen': - * das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach. + * das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag des Abo-Endes (lage.endedAt), sonst von disabledAt; + * loeschdatum = 30 Tage nach disabledAt. * - Grund 'abo_ende_vor_einrichtung': ein unbezahltes Dokument (unlockedByPurchase nicht true, die Bestellung wartet noch auf die * Zahlung) hat nichts zu erstatten: weder T19 noch K22 noch Marker, kein Schreibzugriff (kommt die Zahlung spaeter, meldet * siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team @@ -136,6 +137,10 @@ export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null }) } const projekt = (await projectName(doc.projectId)) || 'Dein Projekt' const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten + // enddatum ist der Tag des Abo-Endes (lage.endedAt: endedAt || canceledAt des Abos), wenn bekannt - das Abschalten kann Tage danach liegen; + // sonst (Sofortpfad, Nachlauf ohne Abo-Lesen, kein lesbares Datum) der Tag des Abschaltens. loeschdatum bleibt 30 Tage nach dem Abschalten. + const aboEnde = zeitMs(lage?.endedAt) + const ende = Number.isFinite(aboEnde) ? new Date(aboEnde) : beginn const grundBlock = vorEinrichtung ? { vor_einrichtung: true } : { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true } @@ -144,7 +149,7 @@ export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null }) values: { kunde_name: customer.name || '', projekt, - enddatum: datumKurz(beginn), + enddatum: datumKurz(ende), loeschdatum: datumKurz(loeschzeit(beginn)), preis: config.features.termine.priceLabel, portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 4bc634b..fc43e76 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -4,7 +4,7 @@ import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, up import { withLocks } from '../keyedLock.js' import { stateFields } from '../siteFeatures.js' import { gibFreiSicher, iso, vertragAm } from './ablauf.js' -import { gleicheAboAb } from './aboAbgleich.js' +import { gleicheAboAb, istErstattungOffen } from './aboAbgleich.js' import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js' import { abgelaufeneHaltungen, @@ -603,7 +603,8 @@ function loescheEinstellung(lauf, funktionId, grenze) { * Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren * feature-admin: (wie Aktivieren und Abschalten im WOMS) und termine-cfg: frisch lesen und nochmals pruefen * (leseAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es. - * R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet'), wird NICHT + * R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet', oder C2: bezahlte + * Einrichtung mit Grund abo_ende_vor_einrichtung und T19 nicht gemeldet, auch bei leerer zahlungMeldung), wird NICHT * zurueckgesetzt (Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf): das Dokument bleibt disabled samt unlockedByPurchase, ticketId * und zahlungMeldung, der taegliche Abo-Abgleich (aboAbgleich, Nachholen der Erstattungsmeldung T19) bleibt zustaendig und ein * spaeterer Lauf setzt zurueck, sobald 'gemeldet' steht. Buchungen und Einstellung sind dann schon geloescht (bleibt so). @@ -619,7 +620,10 @@ function setzeFunktionZurueck(lauf, funktion, grenze) { // R108: ein offener Zahlungsfall (zahlungMeldung gesetzt, nicht 'gemeldet') bleibt sichtbar. Das Dokument bleibt disabled samt Markern // (unlockedByPurchase, ticketId, zahlungMeldung), damit der Abo-Abgleich die Meldung (T19) weiter nachholt; ein spaeterer Lauf setzt zurueck, // sobald 'gemeldet' steht. Ohne PII im Log, zaehlt in lauf.fehler (Tagesbericht), kein Wurf. - if (frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') { + // C2: dazu die bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist, auch + // wenn zahlungMeldung noch leer ist (T19 scheiterte seit dem Abschalten): das Zuruecksetzen wuerde unlockedByPurchase, ticketId und + // zahlungMeldung loeschen, und mit ihnen die Spur der geschuldeten Erstattung. Der Abo-Abgleich (Schritt 2b) meldet T19 weiter. + if ((frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') || istErstattungOffen(frisch)) { lauf.fehler += 1 console.warn(`[termine-jobs] ${lauf.job}: eine Funktion hat einen offenen Zahlungsfall, sie wird nicht zurückgesetzt (der Abo-Abgleich meldet ihn weiter)`) return false diff --git a/test/featureConsentFelder.test.js b/test/featureConsentFelder.test.js new file mode 100644 index 0000000..fa5b8fc --- /dev/null +++ b/test/featureConsentFelder.test.js @@ -0,0 +1,100 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Einmal-Skript scripts/feature-consent-felder-leeren.mjs (C1): Probelauf als Standard, --apply leert IP und Browser nur dort, wo +// featureConsents sie traegt. Nur Nachbauten, nie Produktion. +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), featureConsents: schemaKeys('featureConsents') } }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { run, parseArgs } = await import('../scripts/feature-consent-felder-leeren.mjs') + +const feature = (id, over = {}) => + aw.seed('portalFeatures', { + $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state: 'active', enabled: true, siteKey: `k_${id}`, + stripeSessionId: `cs_${id}`, consentText: 'Text', consentAt: '2026-10-01T10:00:00.000Z', consentIp: '203.0.113.7', consentUserAgent: 'UA', ...over, + }) +const nachweis = (id, over = {}) => + aw.seed('featureConsents', { + $id: `n_${id}`, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', stripeSessionId: `cs_${id}`, acceptedAt: '2026-10-01T10:00:00.000Z', + fassungen: '{}', texte: '[]', textHash: 'x', ip: '203.0.113.7', userAgent: 'UA', konto: 'k@example.invalid', ...over, + }) +const angaben = (id) => { + const d = aw.docs('portalFeatures').get(id) + return [d.consentIp, d.consentUserAgent, d.consentText, d.consentAt, d.siteKey] +} +const stumm = () => { + const zeilen = [] + return { log: (z) => zeilen.push(z), zeilen } +} + +feature('f_ok1'); nachweis('f_ok1') // IP und Browser im Nachweis +feature('f_ok2', { consentUserAgent: '' }); nachweis('f_ok2') // nur IP am Dokument, im Nachweis vorhanden +feature('f_alt'); // Altbestand: kein Nachweis +feature('f_ohne_ip_im_nachweis'); nachweis('f_ohne_ip_im_nachweis', { ip: '' }) // Nachweis ohne IP: traegt sie nicht +feature('f_keine_sitzung', { stripeSessionId: '' }) // ohne Sitzung kein Nachweis +feature('f_leer', { consentIp: '', consentUserAgent: '' }); nachweis('f_leer') // nichts zu leeren +aw.seed('portalFeatures', { $id: 'f_n', customerId: 'c2', projectId: 'p_n', featureKey: 'news', state: 'off', enabled: false }) // Feld nie gesetzt + +test('parseArgs: ohne Argument und mit --dry-run ist es der Probelauf, nur --apply schreibt, Unbekanntes und beides zusammen ist ein Fehler', () => { + assert.deepEqual(parseArgs([]), { apply: false }) + assert.deepEqual(parseArgs(['--dry-run']), { apply: false }) + assert.deepEqual(parseArgs(['--apply']), { apply: true }) + assert.throws(() => parseArgs(['--apply', '--dry-run']), /schliessen sich aus/) + assert.throws(() => parseArgs(['--force']), /Unbekanntes Argument: --force/) +}) + +test('Probelauf (Standard) zaehlt und listet, schreibt aber nichts', async () => { + const vorher = JSON.stringify([...aw.docs('portalFeatures')]) + const out = stumm() + const erg = await run({ log: out.log }) + assert.equal(erg.applied, false) + assert.equal(erg.total, 7) + assert.equal(erg.withData, 5, 'f_ok1, f_ok2, f_alt, f_ohne_ip_im_nachweis, f_keine_sitzung') + assert.deepEqual(erg.clearable.sort(), ['f_ok1', 'f_ok2']) + assert.deepEqual(erg.kept.sort(), ['f_alt', 'f_keine_sitzung', 'f_ohne_ip_im_nachweis']) + assert.equal(erg.cleared, 0) + assert.equal(JSON.stringify([...aw.docs('portalFeatures')]), vorher, 'nichts geschrieben') + assert.match(out.zeilen.join('\n'), /Probelauf \(--dry-run\): es wird nichts geschrieben/) + assert.match(out.zeilen.join('\n'), /zu leeren .*: 2/) +}) + +test('--apply leert consentIp und consentUserAgent nur dort, wo der Nachweis sie traegt; Text, Zeit und alles andere bleiben', async () => { + const out = stumm() + const erg = await run({ apply: true, log: out.log }) + assert.equal(erg.applied, true) + assert.equal(erg.cleared, 2) + assert.deepEqual(erg.failed, []) + assert.deepEqual(angaben('f_ok1').filter((_, i) => i !== 3), ['', '', 'Text', 'k_f_ok1']) + assert.ok(aw.docs('portalFeatures').get('f_ok1').consentAt, 'consentAt bleibt') + assert.deepEqual(angaben('f_ok2').slice(0, 2), ['', '']) + for (const id of ['f_alt', 'f_ohne_ip_im_nachweis', 'f_keine_sitzung']) assert.equal(aw.docs('portalFeatures').get(id).consentIp, '203.0.113.7', `${id} bleibt`) + // ein zweiter Lauf findet nichts mehr zu leeren + const nochmal = await run({ apply: true, log: () => {} }) + assert.deepEqual(nochmal.clearable, []) + assert.equal(nochmal.cleared, 0) +}) + +test('--apply: ein Schreibfehler steht im Ergebnis, die uebrigen Dokumente werden trotzdem geleert; ein nicht lesbarer Nachweis bleibt unberuehrt', async () => { + feature('f_x1'); nachweis('f_x1') + feature('f_x2'); nachweis('f_x2') + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + const erg = await run({ apply: true, log: () => {} }) + assert.equal(erg.failed.length, 1) + assert.match(erg.failed[0].reason, /kaputt/) + assert.equal(erg.cleared, 1) + const offen = ['f_x1', 'f_x2'].filter((id) => aw.docs('portalFeatures').get(id).consentIp) + assert.deepEqual(offen, [erg.failed[0].id]) + + assert.equal((await run({ apply: true, log: () => {} })).cleared, 1, 'der naechste Lauf holt das fehlgeschlagene Dokument nach') + + feature('f_a_y'); nachweis('f_a_y') + aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'Nachweis kaputt' }) + const out = stumm() + const lesefehler = await run({ apply: true, log: out.log }) + assert.deepEqual(lesefehler.unreadable, ['f_a_y']) + assert.equal(lesefehler.cleared, 0, 'nicht lesbar heisst nicht leeren') + assert.equal(aw.docs('portalFeatures').get('f_a_y').consentIp, '203.0.113.7') +}) diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js index dfc1252..ddf8cdd 100644 --- a/test/funktionenCheckout.test.js +++ b/test/funktionenCheckout.test.js @@ -656,8 +656,9 @@ test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihen assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`) assert.match(n.acceptedAt, /Z$/, 'UTC-Zeit mit Z') assert.equal(doc.consentText, hakenTexte(T).slice(0, 1000), 'consentText aus den Haken-Texten') - assert.equal(doc.consentIp, n.ip) - assert.equal(doc.consentUserAgent, n.userAgent) + // C1: IP und Browser stehen nur im Nachweis (featureConsents), nicht mehr am Funktionsdokument + assert.equal(doc.consentIp, '') + assert.equal(doc.consentUserAgent, '') }) test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => { diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index a7cd1eb..f91c131 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -188,7 +188,8 @@ test('checkout_legt_session_an', async () => { assert.equal(doc.stripeSessionId, session.id) assert.equal(doc.consentText, config.funktionen.consentText) assert.ok(doc.consentAt) - assert.ok(doc.consentUserAgent) + assert.equal(doc.consentUserAgent, '', 'C1: der Browser steht nur im Nachweis, nicht am Funktionsdokument') + assert.equal(doc.consentIp, '') // zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung const again = await checkout('p1') assert.equal(again.status, 200) diff --git a/test/retention.test.js b/test/retention.test.js index 4788418..a81ff57 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -400,6 +400,72 @@ test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweis assert.deepEqual(alt.failed, []) }) +// ------------------------------------------------------- C1: IP, Browser, Text und Zeit der Bestellung an portalFeatures + +const KLICK = { consentIp: '203.0.113.7', consentUserAgent: 'Mozilla/5.0 Test', consentText: 'Ich stimme zu.', consentAt: '2022-02-01T10:00:00.000Z' } +const LEER = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null } +const feld = (id) => { + const d = aw.docs('portalFeatures').get(id) + return { consentIp: d.consentIp, consentUserAgent: d.consentUserAgent, consentText: d.consentText, consentAt: d.consentAt } +} + +test('C1 retention: IP, Browser, Text und Zeit werden auf ALLEN Funktionsdokumenten eines fälligen Kunden geleert, auch auf abgeschalteten; andere Kunden und Felder bleiben', async () => { + sub('cf_sub', 'cf', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + stripeSub('cf_sub', 'cf') + aw.seed('portalFeatures', { $id: 'cf_aktiv', customerId: 'cf', projectId: 'cfp1', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k1', ...KLICK }) + aw.seed('portalFeatures', { $id: 'cf_aus', customerId: 'cf', projectId: 'cfp2', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k2', disabledAt: '2022-03-15T10:00:00.000Z', ...KLICK }) + aw.seed('portalFeatures', { $id: 'cf_leer', customerId: 'cf', projectId: 'cfp3', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k3', consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }) + sub('cf2_sub', 'cf2', { status: 'active' }) + aw.seed('portalFeatures', { $id: 'cf2_aktiv', customerId: 'cf2', projectId: 'cf2p', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k4', ...KLICK }) + + const plan = await findDueDeletions(JAN_2026) + const eintrag = plan.news.find((n) => n.customerId === 'cf') + assert.ok(eintrag, 'ein fälliger Kunde mit gespeicherten Bestellangaben steht im Plan') + assert.deepEqual(eintrag.featureIds, ['cf_aktiv'], 'abschalten: nur das noch nicht abgeschaltete') + assert.deepEqual([...eintrag.featureFieldIds].sort(), ['cf_aktiv', 'cf_aus'], 'leeren: alle mit Angaben, auch das abgeschaltete; das leere Dokument nicht') + assert.ok(!plan.news.some((n) => n.customerId === 'cf2')) + assert.match(formatReport(plan), /Kunde cf: .*2 Funktionsdokument\(e\) mit Zustimmungsangaben zu leeren/) + + // Probelauf ändert nichts + await run({ mode: '', now: JAN_2026, log: () => {} }) + assert.deepEqual(feld('cf_aus'), { ...KLICK }) + + const result = await applyDeletions(planVon(plan, 'cf')) + assert.deepEqual(result.failed, []) + assert.equal(result.deleted.featureFields, 2) + assert.deepEqual(feld('cf_aktiv'), LEER) + assert.deepEqual(feld('cf_aus'), LEER) + assert.equal(aw.docs('portalFeatures').get('cf_aktiv').state, 'disabled', 'abgeschaltet wie bisher') + assert.equal(aw.docs('portalFeatures').get('cf_aus').siteKey, 'k2', 'übrige Felder unberührt') + assert.deepEqual(feld('cf2_aktiv'), { ...KLICK }, 'laufender Kunde bleibt') + assert.match(formatReport(plan, result), /Zustimmungsangaben an Funktionsdokumenten \(IP, Browser, Text, Zeit\): 2 geleert\./) + // ein zweiter Lauf findet nichts mehr für diesen Kunden + assert.ok(!(await findDueDeletions(JAN_2026)).news.some((n) => n.customerId === 'cf')) +}) + +test('C1 retention: scheitert das Leeren, hält es den Kunden (Abo bleibt), der nächste Lauf plant es erneut; ältere Pläne ohne featureFieldIds laufen weiter', async () => { + sub('cg_sub', 'cg', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) + stripeSub('cg_sub', 'cg') + aw.seed('portalFeatures', { $id: 'cg_aus', customerId: 'cg', projectId: 'cgp', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k5', ...KLICK }) + const plan = await findDueDeletions(JAN_2026) + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + const result = await applyDeletions(planVon(plan, 'cg')) + assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['portalFeatures', 'cg_aus']]) + assert.deepEqual(result.held, ['cg']) + assert.ok(aw.docs('subscriptions').has('cg_sub'), 'das Abo-Dokument bleibt, sonst fände der nächste Lauf den Kunden nicht') + assert.deepEqual(feld('cg_aus'), { ...KLICK }) + const nochmal = await findDueDeletions(JAN_2026) + assert.deepEqual(nochmal.news.find((n) => n.customerId === 'cg').featureFieldIds, ['cg_aus']) + const zweiter = await applyDeletions(planVon(nochmal, 'cg')) + assert.deepEqual(zweiter.failed, []) + assert.deepEqual(feld('cg_aus'), LEER) + assert.ok(!aw.docs('subscriptions').has('cg_sub')) + + const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'cgx', posts: [], featureIds: [], consentIds: [] }] }) + assert.equal(alt.deleted.featureFields, 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. */ diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index c27c133..7bbad48 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -30,11 +30,26 @@ test('ohne_dokument_ist_off', async () => { assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de') }) +test('C1: eine neue Bestellung leert IP und Browser aus einer frueheren Bestellung desselben Dokuments', async () => { + const first = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_a', consent }) + // Altbestand: eine frueher gespeicherte IP am Dokument (vor dieser Aenderung wurde sie geschrieben) + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + await updateDocument('portalFeatures', first.$id, { consentIp: '9.9.9.9', consentUserAgent: 'alt', state: 'off', enabled: false }) + const again = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_b', consent }) + assert.deepEqual([again.consentIp, again.consentUserAgent], ['', '']) + assert.equal(aw.docs('portalFeatures').get(first.$id).consentIp, '') + aw.docs('portalFeatures').delete(first.$id) // die uebrigen Faelle dieser Datei zaehlen Dokumente +}) + test('bezahlt_legt_ticket_an', async () => { const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent }) assert.equal(ordered.state, 'ordered') assert.equal(ordered.enabled, false) - assert.equal(ordered.consentIp, '1.2.3.4') + // C1: IP und Browser der Bestellung stehen nur im Nachweis (featureConsents), nie am Funktionsdokument, auch wenn der Aufrufer sie mitgibt + assert.equal(ordered.consentIp, '') + assert.equal(ordered.consentUserAgent, '') + assert.equal(ordered.consentText, 'Ich stimme zu') + assert.equal(ordered.consentAt, '2026-09-30T10:00:00.000Z') assert.match(ordered.siteKey, /^[a-z2-7]{16}$/) const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' }) assert.equal(created, true) diff --git a/test/termineAboAbgleich.test.js b/test/termineAboAbgleich.test.js index d0de644..2975a01 100644 --- a/test/termineAboAbgleich.test.js +++ b/test/termineAboAbgleich.test.js @@ -602,6 +602,44 @@ test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, e assert.equal(mails().length, 1) }) +// ---------------------------------------------------------------- C3: das Datum in K22 ist das Abo-Ende, nicht der Tag des Abschaltens + +const AM_ABO_ENDE = new Date('2026-10-02T09:30:00.000Z') // drei Tage vor dem Lauf (JETZT = 05.10.2026) + +test('C3: K22 nennt das Abo-Ende (endedAt des Abos), wenn es frueher war als das Abschalten; das Loeschdatum bleibt disabledAt + 30 Tage', async () => { + legeFunktion(1) + wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString(), canceledAt: '2026-09-20T08:00:00.000Z' }) + + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(ms(funktion().disabledAt), JETZT.getTime()) + const mail = mails()[0] + assert.match(mail.text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'Enddatum = Abo-Ende, nicht der 05.10.') + assert.match(mail.text, /Beendet am: 02\.10\.2026/, 'auch die Zeile "Beendet am"') + assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = Abschalten (05.10.) + 30 Tage') +}) + +test('C3: ohne endedAt gilt canceledAt, ohne beides der Tag des Abschaltens (disabledAt)', async () => { + legeFunktion(1) + wartung('canceled', { endedAt: null, canceledAt: AM_ABO_ENDE.toISOString() }) + await laufe(JETZT) + assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'canceledAt als Abo-Ende') + + const id = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Zweites' }) + assert.equal(await meldeAboEnde(funktion(id), { lage: { laeuft: false, art: 'beendet', endedAt: null }, now: JETZT }), true) + assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'ohne Abo-Ende: disabledAt') + const id3 = legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Drittes' }) + assert.equal(await meldeAboEnde(funktion(id3), { lage: { laeuft: false, art: 'beendet', endedAt: 'kein Datum' }, now: JETZT }), true) + assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'unlesbares endedAt: disabledAt') +}) + +test('C3: K22 vor_einrichtung nennt ebenfalls das Abo-Ende', async () => { + legeAufbau(1) + wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString() }) + await laufe(JETZT) + assert.equal(funktion().disabledReason, 'abo_ende_vor_einrichtung') + assert.match(k22()[0].text, /Dein Wartungs-Abo wurde am 02\.10\.2026 beendet, noch vor der Einrichtung/) +}) + // ---------------------------------------------------------------- Schritt 1b: Abo endet vor der Einrichtung (E5) test('Bezahlt im Aufbau (setup, Ticket) ohne Abo: abgeschaltet, ein T19 an das Team, K22 vor_einrichtung, Ticket bleibt offen, am Folgetag nichts', async () => { diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index f9a70c3..4e84318 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -1921,6 +1921,54 @@ test('Löschung nach 30 Tagen, R108: der übersprungene Reset zählt im Tagesjob assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') }) +// C2: bezahlte Einrichtung, abgeschaltet wegen des Abo-Endes vor der Einrichtung, T19 (Erstattung) noch nicht beim Team: der Reset nach +// 30 Tagen wuerde zahlungMeldung, unlockedByPurchase und ticketId loeschen und damit die Spur der geschuldeten Erstattung. +const ERSTATTUNG_OFFEN = ['', 'abo_ende', 'bereits_aktiv'] +for (const meldung of ERSTATTUNG_OFFEN) { + test(`Löschung nach 30 Tagen, C2: bezahlt + abo_ende_vor_einrichtung + zahlungMeldung "${meldung}" (T19 offen) ⇒ Daten weg, Dokument bleibt disabled mit allen Markern; Warnung ohne Kennungen`, async () => { + await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: meldung, aboEndeMailAt: null }) }) + const vorher = structuredClone(aw.docs('portalFeatures').get(F1)) + + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 2, gedeckelt: 0 }, 'gelöscht wird wie bisher') + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') + const doc = aw.docs('portalFeatures').get(F1) + assert.deepEqual({ ...doc, $updatedAt: undefined }, { ...vorher, $updatedAt: undefined }, 'das Dokument ist unverändert (nicht zurückgesetzt)') + assert.equal(warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile)).length, 1) + assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|p_projekt|c_kunde|Müller|@/, 'keine Kennung, keine Personendaten im Log') + assert.equal(fehlerLog().length, 0, 'kein Wurf') + }) +} + +test('Löschung nach 30 Tagen, C2: unbezahlt (unlockedByPurchase false) oder T19 gemeldet ⇒ zurückgesetzt wie bisher', async () => { + await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: 'gemeldet' }) }) + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'T19 gemeldet') + + await abgeschaltetMitBuchungen(1, { zellen: false, praefix: 'b_un', funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: '', unlockedByPurchase: false, purchaseStatus: 'ordered', aboEndeMailAt: null }) }) + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'unbezahlt: es gibt nichts zu erstatten') + assert.equal(warnLog().length, 0) +}) + +test('Tagesjob, C2: T19 scheitert 31 Tage lang ⇒ der Reset wird nicht ausgeführt (Fehler gezählt); sobald T19 beim Team ist, setzt ein Lauf zurück und T19 ging genau einmal', async () => { + config.chatNotify.emails = ['team@example.com'] + await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende_vor_einrichtung', zahlungMeldung: '', aboEndeMailAt: null }) }) + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP nicht erreichbar') } }) + + let erg = await taeglicherJob(JETZT) + assert.ok(erg.fehler >= 1, `Fehler gezählt: ${erg.fehler}`) + let doc = aw.docs('portalFeatures').get(F1) + assert.deepEqual([doc.state, doc.unlockedByPurchase, doc.zahlungMeldung, doc.ticketId, doc.disabledReason], ['disabled', true, '', 't_einrichtung', 'abo_ende_vor_einrichtung'], 'die Spur der Erstattung bleibt') + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'die Daten sind trotzdem gelöscht') + + setMailTransportForTests(sink) + for (const tage of [1, 2]) await taeglicherJob(plus(JETZT, tage * TAG)) + doc = aw.docs('portalFeatures').get(F1) + assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedByPurchase, doc.zahlungMeldung], ['off', 'zurueckgesetzt', false, ''], 'nach der Meldung wird zurückgesetzt') + assert.equal(sink.sent.filter((m) => /Erstattung fällig/.test(m.subject)).length, 1, 'T19 genau einmal') +}) + for (const meldung of ['', 'gemeldet']) { test(`Löschung nach 30 Tagen, R108: zahlungMeldung "${meldung}" ist kein offener Fall ⇒ zurückgesetzt wie bisher`, async () => { await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende', zahlungMeldung: meldung }) })