From 6bd6aed7d584c6dc52b72690aeef90e4e47a36a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:08:54 +0000 Subject: [PATCH] Terminplaner TP2 E8: Nachbesserung 1 (offener Zahlungsfall bleibt sichtbar, R108) setzeFunktionZurueck setzt ein Funktionsdokument mit offenem Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet') nicht auf off zurueck: Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf. Das Dokument bleibt disabled mit seinen Markern, der Abo-Abgleich holt T19 weiter nach, nach 'gemeldet' setzt der naechste Lauf zurueck. Buchungen und Einstellung werden wie bisher geloescht. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/jobs.js | 43 ++++++++++----- test/termineJobs.test.js | 92 +++++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 21 deletions(-) diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index a9651da..9a884af 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -44,7 +44,8 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js) * Abgeschaltete Funktionen: nach LOESCHFRIST_TAGE (30) Tagen Buchungen, Zellen, Einstellung loeschen * (Grund abo_ende: vor jedem unumkehrbaren Schritt frisch pruefen, ob das Wartungs-Abo wieder laeuft), - * danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt) + * danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt; mit offenem Zahlungsfall, + * zahlungMeldung nicht leer und nicht 'gemeldet', bleibt es disabled bis zur Meldung, R108) * * Gemeinsame Regeln: * - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige @@ -535,10 +536,11 @@ const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50 * kann: featureKey termine, state disabled und disabledAt vor der Grenze. Bei Grund `abo_ende` (nur dort haengt die Loeschung am Abo) * zusaetzlich frisch aboLage (nie der Memo): laeuft das Wartungs-Abo wieder, gilt sie nicht als abgeschaltet. Fuer `team`, * `kunde_geloescht`, `abo_ende_vor_einrichtung` und Altbestand ohne Grund gibt es keine Abo-Abfrage. mitAbo false: nichts - * Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst false mit Log, ohne Wurf – auch bei einem + * Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst null mit Log, ohne Wurf – auch bei einem * Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht. + * => das frisch gelesene Funktionsdokument, wenn es noch als abgeschaltet gilt, sonst null */ -async function nochAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) { +async function leseAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) { let frisch try { frisch = await getDocument(config.collections.portalFeatures, funktionId) @@ -548,23 +550,26 @@ async function nochAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {} } else { melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err) } - return false + return null } if (!(frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze)) { console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`) - return false + return null } - if (!mitAbo || frisch.disabledReason !== 'abo_ende') return true + if (!mitAbo || frisch.disabledReason !== 'abo_ende') return frisch try { if (!frisch.customerId) throw new Error('Funktion ohne Kunde') - if (!(await aboLage(frisch.customerId)).laeuft) return true + if (!(await aboLage(frisch.customerId)).laeuft) return frisch console.warn(`[termine-jobs] ${lauf.job}: Abo läuft wieder, Löschung abgebrochen`) } catch (err) { melde(lauf, 'Abo vor dem Löschen nicht gelesen', err) } - return false + return null } +/** Wie leseAbgeschaltet, nur als Ja/Nein: gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? */ +const nochAbgeschaltet = async (lauf, funktionId, grenze, optionen) => (await leseAbgeschaltet(lauf, funktionId, grenze, optionen)) !== null + /** * Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:. Ist sie noch da, kommt vor * dem unumkehrbaren Loeschen die letzte Pruefung (nochAbgeschaltet, bei Grund abo_ende auch das frische Abo); ist sie schon weg (404), @@ -594,7 +599,11 @@ 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 - * (nochAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es. + * (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 + * 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). * purchaseStatus 'zurueckgesetzt' ist die dauerhafte Marke "hier wurde nach dem Abo-Ende geloescht" (weder paid noch Meldezustand; * Block C6 loescht ein so markiertes Funktionsdokument nie, damit der siteKey besteht). siteKey sowie Bestell- und Rechnungsfelder * bleiben stehen bis zur naechsten Bestellung. @@ -602,7 +611,16 @@ function loescheEinstellung(lauf, funktionId, grenze) { */ function setzeFunktionZurueck(lauf, funktion, grenze) { return withLocks([`feature-admin:${funktion.projectId}`, `termine-cfg:${funktion.$id}`], async () => { - if (!(await nochAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false }))) return false + const frisch = await leseAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false }) + if (!frisch) return false + // 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') { + 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 + } await updateDocument(config.collections.portalFeatures, funktion.$id, { ...stateFields('off'), unlockedByPurchase: false, @@ -628,7 +646,8 @@ function setzeFunktionZurueck(lauf, funktion, grenze) { * (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:); sind alle Buchungen weg und die Einstellung geloescht * oder schon nicht mehr da, setzt der Lauf das Funktionsdokument auf `off` zurueck (setzeFunktionZurueck, fuer jeden Grund, ohne * Abo-Pruefung). Sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter; scheitert nur das - * Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr). + * Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr); bei einem offenen + * Zahlungsfall (R108) bleibt das Dokument disabled, bis zahlungMeldung 'gemeldet' ist. * => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht) */ async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) { @@ -689,7 +708,7 @@ async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) { /** * Funktionen im Zustand `disabled` seit mehr als LOESCHFRIST_TAGE (30) Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die * Einstellung loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion), danach das Funktionsdokument auf `off` - * zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch + * zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs; bei einem offenen Zahlungsfall erst nach der Meldung, R108). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch * geprueft, ob das Wartungs-Abo wieder laeuft (dann bleibt alles). Endet eine Funktion am Deckel oder an einem Fehler, bleibt sie * `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran. * funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung; ein reines Zuruecksetzen zaehlt nicht), diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 694f6da..68a743a 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -27,7 +27,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') -const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { Query, deleteDocument, listAllDocuments } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') @@ -1482,14 +1482,11 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen assert.equal(doc.siteKey, 'abcdefghijklmnop', 'siteKey unverändert') for (const id of andere) assert.deepEqual(aw.docs('portalFeatures').get(id), vorher[id], `${id}: Dokument unverändert`) - // zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal, das zurückgesetzte Dokument wird nicht mehr gelistet und nicht mehr gelesen - const leseF1 = new RegExp(`/collections/portalFeatures/documents/${F1}(\\?|$)`) - let zweiter - const zugriffeAufF1 = await zaehleAnfragen(async () => { - zweiter = await loescheAbgeschalteteFunktionen(JETZT) - }, leseF1) - assert.deepEqual(zweiter, { funktionen: 0, buchungen: 0, gedeckelt: 0 }) - assert.equal(zugriffeAufF1, 0, 'keine tägliche Neuprüfung des zurückgesetzten Dokuments') + // zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal; die Liste der abgeschalteten Terminplaner (dieselbe Abfrage wie im Job) führt F1 nicht mehr + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + const abgeschalteteTermine = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')]) + assert.ok(abgeschalteteTermine.length > 0, 'die Abfrage liefert die übrigen abgeschalteten Terminplaner (F2, f_p_projekt05_termine)') + assert.ok(!abgeschalteteTermine.some((d) => d.$id === F1), 'F1 steht nicht mehr in der Liste der abgeschalteten Terminplaner') assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') assert.equal(fehlerLog().length, 0) }) @@ -1857,6 +1854,83 @@ for (const [name, grund] of [ }) } +/** Offener Zahlungsfall am abgeschalteten Dokument (siteFeatures.bucheSpaeteZahlung): bezahlt, Fall vermerkt, noch nicht ans Team gemeldet. */ +const offenerFall = (over = {}) => eingerichtet({ disabledReason: 'abo_ende', zahlungMeldung: 'abo_ende', ...over }) +const OFFENER_FALL_MARKER = ['unlockedByPurchase', 'purchaseStatus', 'ticketId', 'zahlungMeldung', 'disabledAt', 'disabledReason', 'aboEndeMailAt', 'activatedAt', 'adminNote'] + +test('Löschung nach 30 Tagen, R108: offener Zahlungsfall (zahlungMeldung abo_ende) ⇒ Buchungen und Einstellung weg, das Dokument bleibt disabled mit allen Markern; Warnung ohne Kennungen', async () => { + await abgeschaltetMitBuchungen(3, { funktion: offenerFall() }) + const vorher = structuredClone(aw.docs('portalFeatures').get(F1)) + + const erg = await loescheAbgeschalteteFunktionen(JETZT) + assert.deepEqual(erg, { funktionen: 1, buchungen: 3, gedeckelt: 0 }, 'gelöscht wird wie bisher') + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineClaims').size, 0) + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'disabled', 'nicht zurückgesetzt') + assert.equal(doc.unlockedByPurchase, true) + assert.equal(doc.zahlungMeldung, 'abo_ende') + assert.equal(doc.ticketId, 't_einrichtung') + for (const feld of OFFENER_FALL_MARKER) assert.deepEqual(doc[feld], vorher[feld], `${feld} unberührt`) + assert.deepEqual({ ...doc, $updatedAt: undefined }, { ...vorher, $updatedAt: undefined }, 'das Dokument ist unverändert') + const warnungen = warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile)) + assert.equal(warnungen.length, 1, 'Warnung im Log') + 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, kein Fehlereintrag') + + // der nächste Tag: nichts mehr zu löschen, das Dokument bleibt sichtbar (weiter in der Liste), wieder nur die Warnung + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled') + assert.equal(warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile)).length, 2) + const abgeschalteteTermine = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')]) + assert.ok(abgeschalteteTermine.some((d) => d.$id === F1), 'F1 steht weiter in der Liste der abgeschalteten Terminplaner') + + // R108: ist der Fall gemeldet, setzt der nächste Lauf wie bisher auf off zurück (ein reines Zurücksetzen zählt nicht) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), zahlungMeldung: 'gemeldet' }) + log.warn.mock.resetCalls() + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + const zurueck = aw.docs('portalFeatures').get(F1) + assert.equal(zurueck.state, 'off') + assert.equal(zurueck.enabled, false) + assert.equal(zurueck.unlockedByPurchase, false) + assert.equal(zurueck.purchaseStatus, 'zurueckgesetzt') + assert.equal(zurueck.zahlungMeldung, '') + assert.equal(zurueck.ticketId, '') + assert.equal(zurueck.disabledAt, null) + assert.equal(zurueck.disabledReason, null) + assert.equal(zurueck.siteKey, 'abcdefghijklmnop') + assert.equal(warnLog().length, 0) + assert.equal(fehlerLog().length, 0) + const danach = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')]) + assert.ok(!danach.some((d) => d.$id === F1), 'danach nicht mehr gelistet') +}) + +test('Löschung nach 30 Tagen, R108: der übersprungene Reset zählt im Tagesjob als Fehler, der nächste Lauf nach der Meldung nicht mehr', async () => { + await abgeschaltetMitBuchungen(2, { zellen: false, funktion: offenerFall() }) + + let erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }) + assert.equal(erg.fehler, 1, 'der offene Fall zählt im Lauf') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled') + assert.equal(aw.docs('portalFeatures').get(F1).zahlungMeldung, 'abo_ende') + + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), zahlungMeldung: 'gemeldet' }) + erg = await taeglicherJob(plus(JETZT, TAG)) + assert.equal(erg.fehler, 0) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') +}) + +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 }) }) + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') + assert.equal(warnLog().length, 0) + assert.equal(fehlerLog().length, 0) + }) +} + test('Löschung nach 30 Tagen: das zurückgesetzte Dokument behält siteKey sowie Bestell- und Rechnungsfelder, alles Übrige ist leer', async () => { await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) const vorher = structuredClone(aw.docs('portalFeatures').get(F1))