Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off

Bei Grund abo_ende prüft die Löschung vor jedem unumkehrbaren Schritt frisch,
ob das Wartungs-Abo wieder läuft (Lesefehler: nichts löschen). Sind alle
Buchungen und die Einstellung weg, wird das Funktionsdokument unter
feature-admin und termine-cfg auf off zurückgesetzt (siteKey bleibt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 17:57:41 +00:00
1 parent 91e7f7d8a3
commit 30701ba277
2 files changed
+408 -47

No files matched your search

+118 -37
View File
@@ -1,6 +1,8 @@
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments } from '../appwriteAdmin.js'
import { aboLage } from '../aboStatus.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { stateFields } from '../siteFeatures.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import { gleicheAboAb } from './aboAbgleich.js'
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
@@ -16,6 +18,7 @@ import {
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
@@ -39,6 +42,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt,
* nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler,
* 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)
*
* Gemeinsame Regeln:
* - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige
@@ -64,6 +70,7 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt;
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
* Das Funktionsdokument wird danach auf `off` gesetzt und fehlt damit in der Liste des naechsten Tages (loescheAbgeschaltete).
* Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens
* config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`.
*/
@@ -73,7 +80,8 @@ const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Frist zwischen dem Abschalten (disabledAt) und der Loeschung: eine Zahl fuer Anzeige, Mails, Rechtstexte und diesen Job (frist.js). */
const ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
@@ -522,35 +530,109 @@ async function loeschfristen(lauf, now) {
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate leert disabledAt) und ein Wartungs-Abo zurueckkommen
* 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
* Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht.
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
async function nochAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) {
let frisch
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
frisch = await getDocument(config.collections.portalFeatures, funktionId)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
return false
}
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
}
if (!mitAbo || frisch.disabledReason !== 'abo_ende') return true
try {
if (!frisch.customerId) throw new Error('Funktion ohne Kunde')
if (!(await aboLage(frisch.customerId)).laeuft) return true
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
}
/**
* Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:<id>. 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),
* ist nichts Unumkehrbares mehr offen und die Abo-Pruefung entfaellt. Ein Lesefehler wirft (der Aufrufer meldet ihn).
* => 'geloescht' (in diesem Lauf) | 'weg' (war schon weg) | 'bleibt' (die Funktion gilt nicht mehr als abgeschaltet)
*/
function loescheEinstellung(lauf, funktionId, grenze) {
const schonWeg = (err) => err?.status === 404 && !fehlendeCollection(err)
return withLocks([`termine-cfg:${funktionId}`], async () => {
try {
await getDocument(config.collections.termineConfig, funktionId)
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return 'bleibt'
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return 'geloescht'
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
})
}
/**
* Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren
* feature-admin:<projectId> (wie Aktivieren und Abschalten im WOMS) und termine-cfg:<id> frisch lesen und nochmals pruefen
* (nochAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es.
* 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.
* => true, wenn zurueckgesetzt
*/
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
await updateDocument(config.collections.portalFeatures, funktion.$id, {
...stateFields('off'),
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
activatedAt: null,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
})
return true
})
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie nicht mehr als abgeschaltet, endet die
* Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der Lauf mit Warnung (der Rest folgt am
* naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht, mit letzter Pruefung unter der Sperre
* termine-cfg:<id>; sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter.
* => { geloescht, gedeckelt, einstellungWeg }
* (nochAbgeschaltet, bei Grund abo_ende samt frischem Abo) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie
* nicht mehr als abgeschaltet, endet die Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der
* Lauf mit Warnung (der Rest folgt am naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht
* (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:<id>); 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).
* => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht)
*/
async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) {
const funktionId = funktion.$id
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
@@ -587,32 +669,31 @@ async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktionId}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
const stand = await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => loescheEinstellung(lauf, funktionId, grenze), 'bleibt')
einstellungWeg = stand === 'geloescht'
if (stand !== 'bleibt') {
await schritt(
lauf,
'Funktion zurücksetzen',
async () => {
if (!funktion.projectId) throw new Error('Funktion ohne Projekt') // ohne Projekt gibt es die Sperre feature-admin:<projectId> nicht: im Zweifel nichts schreiben
return setzeFunktionZurueck(lauf, funktion, grenze)
},
false,
)
}
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion). 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), gedeckelt die Funktionen, die am
* Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
* 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
* 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),
* gedeckelt die Funktionen, die am Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 0 }
@@ -620,7 +701,7 @@ async function loescheAbgeschaltete(lauf) {
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const weg = await loescheAbgeschalteteFunktion(lauf, funktion.$id, grenze)
const weg = await loescheAbgeschalteteFunktion(lauf, funktion, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1
+290 -10
View File
@@ -37,6 +37,7 @@ const { speichereEinstellung, resetKontextForTests } = await import('../server/s
const { belegeZellen } = await import('../server/services/termine/zellen.js')
const { bestaetige } = await import('../server/services/termine/ablauf.js')
const { disable } = await import('../server/services/siteFeatures.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const J = await import('../server/services/termine/jobs.js')
const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J
after(() => setMailTransportForTests(null))
@@ -1439,7 +1440,7 @@ test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei
assert.equal(mit, ohne.replace(', 9 Fehler', ', Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, 9 Fehler'))
})
test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg; alles andere bleibt', async () => {
test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg, das Funktionsdokument danach auf off; alles andere bleibt', async () => {
const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz))
await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) })
await richteFunktionEin(F2, { state: 'disabled', disabledAt: seit(30, 1) }) // noch knapp unter 30 Tagen
@@ -1447,6 +1448,8 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen
await richteFunktionEin('f_p_projekt04_termine', { state: 'disabled', disabledAt: seit(90), featureKey: 'news' }) // andere Funktion
await richteFunktionEin('f_p_projekt05_termine', { state: 'disabled' }) // kein disabledAt
const funktionen = [F1, F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine']
const andere = funktionen.slice(1)
const vorher = Object.fromEntries(andere.map((id) => [id, structuredClone(aw.docs('portalFeatures').get(id))]))
for (const [i, featureId] of funktionen.entries()) {
await legeBuchung(`b_fest_${i}`, { featureId, status: 'bestaetigt' })
await legeBuchung(`b_alt_${i}`, { featureId, status: 'erledigt', ende: plus(JETZT, -2 * TAG) })
@@ -1462,8 +1465,33 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
assert.equal(buchungen().length, 8, 'die Buchungen der anderen vier Funktionen bleiben')
for (const featureId of [F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine']) assert.ok(aw.docs('termineConfig').get(featureId), `${featureId}: Einstellung bleibt`)
assert.ok(aw.docs('portalFeatures').get(F1), 'das Funktionsdokument selbst bleibt')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal')
// das Funktionsdokument selbst bleibt, steht danach aber auf off (der Entwurf einer Neubestellung); der siteKey und damit das eingebettete Snippet bleiben gültig
const doc = aw.docs('portalFeatures').get(F1)
assert.ok(doc, 'das Funktionsdokument selbst bleibt')
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
assert.equal(doc.zahlungMeldung, '')
assert.equal(doc.ticketId, '')
assert.equal(doc.activatedAt, null)
assert.equal(doc.disabledAt, null)
assert.equal(doc.disabledReason, null)
assert.equal(doc.aboEndeMailAt, null)
assert.equal(doc.adminNote, 'Daten gelöscht (Löschfrist abgelaufen)')
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')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(fehlerLog().length, 0)
})
test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => {
@@ -1473,9 +1501,12 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei
await loescheAbgeschalteteFunktionen(JETZT)
assert.ok(buchung('b_eins'))
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Dokument bleibt disabled, solange Buchungen übrig sind')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(buchung('b_eins'), undefined)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'der nächste Lauf löscht und setzt zurück')
assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt')
})
test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOMS, Kundenlöschung) verschiebt die 30 Tage nicht', async () => {
@@ -1493,8 +1524,8 @@ test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOM
})
/** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `<praefix>000…`; mit zellen bekommt jede ihre Zellen. */
async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab' } = {}) {
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab', funktion = {} } = {}) {
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)), ...funktion })
for (let i = 0; i < n; i++) await legeBuchung(`${praefix}${String(i).padStart(3, '0')}`, { featureId, start: plus(JETZT, TAG + i * 2 * STUNDE), zellen })
}
/** Das Personal schaltet die Funktion wieder ein: siteFeatures.activate sperrt nicht und lässt disabledAt stehen. */
@@ -1544,6 +1575,7 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion nach 50 Buchungen wieder
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt')
})
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung wieder eingeschaltet, bleibt die Einstellung', async () => {
@@ -1553,6 +1585,9 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(erg.funktionen, 1)
assert.equal(erg.buchungen, 2, 'beide Buchungen waren in einem Block von 50, vor der nächsten Prüfung gelöscht')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt')
assert.notEqual(doc.purchaseStatus, 'zurueckgesetzt')
})
test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht gelesen werden, wird nichts gelöscht (Fehler im Log)', async () => {
@@ -1574,9 +1609,10 @@ test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen,
assert.ok(zellenProBuchung > 0)
const funktion = () => aw.docs('portalFeatures').get(F1)
const vorher = { state: funktion().state, disabledAt: funktion().disabledAt }
const stimmt = (uebrig) => {
const stimmt = (uebrig, { zurueckgesetzt = false } = {}) => {
assert.equal(buchungen().length, uebrig, 'Rest')
assert.equal(aw.docs('termineClaims').size, uebrig * zellenProBuchung, 'Zellen passen zum Rest')
if (zurueckgesetzt) return assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, { state: 'off', disabledAt: null }, 'erst der letzte Lauf setzt das Dokument zurück')
assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, vorher, 'Zustand und disabledAt bleiben, die nächste Frist beginnt nicht neu')
}
@@ -1597,7 +1633,7 @@ test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen,
log.warn.mock.resetCalls()
erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 30, gedeckelt: 0 }, 'Lauf 3: die letzten 30')
stimmt(0)
stimmt(0, { zurueckgesetzt: true })
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung erst jetzt gelöscht')
assert.equal(deckelWarnungen().length, 0)
assert.equal(fehlerLog().length, 0)
@@ -1745,6 +1781,251 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion unmittelbar vor dem Lös
assert.equal(fehlerLog().length, 0)
})
// Löschung nach 30 Tagen (E8): frische Abo-Prüfung vor jedem unumkehrbaren Schritt, danach das Funktionsdokument auf off
/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */
const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over }))
/** Alle Anfragen an die Abo-Collections (Spiegel und Ereignisse), die der Abo-Baustein stellt. */
const ABO_ANFRAGEN = /\/collections\/(subscriptions|contractEvents)\//
/** Die Anfrage, die die Einstellung (termineConfig) löscht. */
const einstellungLoeschen = (url, methode) => methode === 'DELETE' && /\/collections\/termineConfig\/documents\//.test(url)
/** Terminplaner seit 31 Tagen abgeschaltet, mit allem, was der Kauf und die Einrichtung am Dokument hinterlassen haben. */
const eingerichtet = (over = {}) => ({
...BEZAHLT,
disabledAt: iso(plus(JETZT, -31 * TAG)),
activatedAt: iso(plus(JETZT, -200 * TAG)),
ticketId: 't_einrichtung',
zahlungMeldung: 'gemeldet',
aboEndeMailAt: iso(plus(JETZT, -31 * TAG)),
adminNote: 'Abgeschaltet: Wartungs-Abo beendet',
stripeSessionId: 'cs_test_alt',
invoiceUrl: 'https://invoice.stripe.example/alt',
...over,
})
test('Löschung nach 30 Tagen, Grund abo_ende: läuft das Wartungs-Abo wieder, wird nichts gelöscht (Warnung im Log, Dokument bleibt disabled)', async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
wartung('active')
const erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(buchungen().length, 3, 'Buchungen bleiben')
for (const b of buchungen()) assert.ok(claims(b.$id).length > 0, `${b.$id}: Zellen bleiben`)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)), 'Warnung im Log')
assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|c_kunde|Müller/, 'keine Kennung im Log')
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
})
test('Löschung nach 30 Tagen, Grund abo_ende: endet das Abo wirklich (beendet), wird gelöscht', async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
wartung('canceled', { endedAt: iso(plus(JETZT, -31 * TAG)) })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 3, gedeckelt: 0 })
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(fehlerLog().length, 0)
})
for (const [name, grund] of [
['team', { disabledReason: 'team' }],
['kunde_geloescht', { disabledReason: 'kunde_geloescht' }],
['abo_ende_vor_einrichtung', { disabledReason: 'abo_ende_vor_einrichtung' }],
['Altbestand ohne Grund', {}],
]) {
test(`Löschung nach 30 Tagen, ${name}: gelöscht und zurückgesetzt, ohne eine einzige Abfrage auf das Abo (auch wenn es läuft)`, async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet(grund) })
wartung('active')
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.equal(aboAnfragen, 0, 'keine Abfrage auf subscriptions oder contractEvents')
assert.deepEqual(erg, { funktionen: 1, buchungen: 3, gedeckelt: 0 })
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'off', 'das Zurücksetzen gilt für jeden Grund')
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
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))
await loescheAbgeschalteteFunktionen(JETZT)
const doc = aw.docs('portalFeatures').get(F1)
assert.deepEqual(
{ ...doc, $updatedAt: undefined },
{
...vorher,
$updatedAt: undefined,
state: 'off',
enabled: false,
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
activatedAt: null,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
},
'genau diese Felder ändern sich; siteKey, stripeSessionId, invoiceUrl und alles andere bleiben',
)
assert.equal(doc.siteKey, 'abcdefghijklmnop')
assert.equal(doc.stripeSessionId, 'cs_test_alt')
})
test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo erst nach der 50. Löschung zurück, endet die Löschung dort und die Einstellung bleibt', async () => {
await abgeschaltetMitBuchungen(60, { zellen: false, funktion: { disabledReason: 'abo_ende' } })
const echt = globalThis.fetch
let geloescht = 0
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (buchungLoeschen(String(url), optionen?.method ?? 'GET')) {
geloescht += 1
if (geloescht === 50) wartung('active')
}
return antwort
}
let erg
try {
erg = await loescheAbgeschalteteFunktionen(JETZT)
} finally {
globalThis.fetch = echt
}
assert.equal(geloescht, 50, 'nach der Rückkehr des Abos kommt keine Löschung mehr')
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)))
assert.equal(fehlerLog().length, 0)
})
test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo zurück, nachdem die letzte Buchung weg ist, bleibt die Einstellung (Prüfung vor dem Löschen der Einstellung)', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: { disabledReason: 'abo_ende' } })
let listen = 0
const leereSeite = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
const erg = await mitEinwurf(leereSeite, async () => wartung('active'), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 })
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'kein Zurücksetzen, solange die Einstellung da ist')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)))
assert.equal(fehlerLog().length, 0)
})
test('Löschung nach 30 Tagen, Grund abo_ende: Lesefehler auf subscriptions ⇒ nichts gelöscht, fehler gezählt; der nächste Lauf löscht', async () => {
await abgeschaltetMitBuchungen(3, { funktion: abgeschaltetBezahlt(40) })
// 1. Lesezugriff auf subscriptions = Abo-Abgleich (Vorauswahl), 2. = Prüfung der Löschung
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 })
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1 })
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 })
assert.equal(erg.fehler, 1, 'der Lesefehler wird gezählt')
assert.equal(fehlerLog().filter((zeile) => /Abo vor dem Löschen nicht gelesen/.test(zeile)).length, 1)
assert.equal(buchungen().length, 3, 'im Zweifel wird nichts gelöscht')
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 0)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 3 })
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
test('Zurücksetzen scheitert (PATCH auf portalFeatures) ⇒ Einstellung ist weg, Dokument noch disabled; der nächste Lauf setzt zurück, auch wenn das Abo inzwischen läuft', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'Nachbau kaputt' })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 2, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung ist weg')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen ist gescheitert')
assert.equal(fehlerLog().length, 1)
wartung('active') // jetzt ist nichts Unumkehrbares mehr offen: das Abo spielt keine Rolle
log.error.mock.resetCalls()
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'ein reines Zurücksetzen zählt nicht')
assert.equal(aboAnfragen, 0, 'keine Abo-Prüfung')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'off')
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
assert.equal(fehlerLog().length, 0)
})
test('Zurücksetzen: war die Einstellung schon weg (404) und gibt es keine Buchungen, setzt der Lauf nur das Dokument zurück – ohne Abo-Prüfung, auch bei laufendem Abo', async () => {
await abgeschaltetMitBuchungen(0, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
await deleteDocument('termineConfig', F1)
wartung('active')
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(aboAnfragen, 0, 'nichts Unumkehrbares mehr offen: keine Abo-Abfrage')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt')
assert.equal(fehlerLog().length, 0)
})
test('Zurücksetzen: läuft unter der Sperre feature-admin:<projectId> – solange das Team sie hält, bleibt das Dokument disabled', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) })
let lauf
await withLocks([`feature-admin:${PROJEKT}`], async () => {
lauf = loescheAbgeschalteteFunktionen(JETZT)
for (let i = 0; i < 200 && aw.docs('termineConfig').get(F1); i += 1) await new Promise((fertig) => setTimeout(fertig, 10)) // bis die Einstellung weg ist
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Buchungen und Einstellung sind gelöscht')
await new Promise((fertig) => setTimeout(fertig, 100))
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen wartet auf die Sperre')
})
assert.deepEqual(await lauf, { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
for (const [was, einwurf] of [
['vom Team aktiviert', () => schalteEin()],
['frisch neu abgeschaltet (disabledAt jünger als 30 Tage)', () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), disabledAt: iso(plus(JETZT, -TAG)) })],
]) {
test(`Zurücksetzen: wird das Dokument zwischen dem Löschen der Einstellung und dem Zurücksetzen ${was}, bleibt es so (frisch gelesen, nicht überschrieben)`, async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
const erg = await mitEinwurf(einstellungLoeschen, async () => einwurf(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'die Einstellung war schon gelöscht')
const doc = aw.docs('portalFeatures').get(F1)
assert.notEqual(doc.state, 'off', 'nicht zurückgesetzt')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.ticketId, 't_einrichtung')
assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet')
assert.equal(fehlerLog().length, 0)
})
}
test('Zurücksetzen: nach dem Zurücksetzen steht das Dokument auf off und ist wieder bestellbar (markOrdered: Test in Block B)', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) })
await loescheAbgeschalteteFunktionen(JETZT)
const { state, enabled } = aw.docs('portalFeatures').get(F1)
assert.deepEqual({ state, enabled }, { state: 'off', enabled: false })
})
// ---------------------------------------------------------------- Tagesjob
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
@@ -2051,8 +2332,6 @@ test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später
const K22_BETREFF = /^Dein Terminplaner wurde beendet/
const K23_BETREFF = /^Dein Terminplaner läuft wieder/
const kundenMails = (betreff) => sink.sent.filter((mail) => betreff.test(mail.subject))
/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */
const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over }))
/** Bezahlter, seit `tage` Tagen wegen des Abo-Endes abgeschalteter Terminplaner; K22 ging schon (Marker), der Abgleich holt nichts nach. */
const abgeschaltetBezahlt = (tage, over = {}) => ({
state: 'disabled',
@@ -2182,7 +2461,8 @@ test('Modus off, bezahlter Terminplaner abgeschaltet seit 31 Tagen: Buchungen, Z
assert.equal(buchung('b_fest'), undefined)
assert.equal(claims('b_fest').length, 0, 'Zellen frei')
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
// (Dokument selbst auf `off` setzen: E8)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'das Dokument steht danach auf off: der Guard findet keinen bezahlten Terminplaner mehr')
assert.equal(aw.docs('portalFeatures').get(F1).unlockedByPurchase, false)
// die Tagesjob-Meldung und die Statusroute lesen daraus Zahlen, nie undefined
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 })