From 92ac61dffd95f49626ad3b4eaf63ef38b552594d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:42:06 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20P6:=20Abo-Ende-Abgleich=20f?= =?UTF-8?q?=C3=BCr=20News,=20Terminplaner,=20Webshop=20und=20Buchungskalen?= =?UTF-8?q?der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/aboFunktionenAbgleich.js | 226 +++++++++++++++++++++++ server/services/aboFunktionenMeldung.js | 46 ++++- test/aboFunktionenAbgleich.test.js | 103 +++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 server/services/aboFunktionenAbgleich.js create mode 100644 test/aboFunktionenAbgleich.test.js diff --git a/server/services/aboFunktionenAbgleich.js b/server/services/aboFunktionenAbgleich.js new file mode 100644 index 0000000..16f37ea --- /dev/null +++ b/server/services/aboFunktionenAbgleich.js @@ -0,0 +1,226 @@ +import { config } from '../config.js' +import { ENTHALTEN_IN, aboProdukt } from './aboFunktionen.js' +import { ohneAboBisher } from './aboAufbauphase.js' +import { istAboModell } from './aboModell.js' +import { meldeBeendet } from './aboFunktionenMeldung.js' +import { Query, getDocument, listAllDocuments, updateDocument } from './appwriteAdmin.js' +import { withLocks } from './keyedLock.js' +import { activate, disable, stateFields } from './siteFeatures.js' +import { pruefeNow } from './termine/util.js' +import { ladeEinstellung } from './termine/kontext.js' +import { leereCache } from './unterkunft/kontext.js' + +/** + * Abo-Ende-Abgleich der Funktionen des Abo-Modells (Kundenansicht neu, P6, V11, N31): News-Pinnwand, Terminplaner, Webshop und + * Buchungskalender gehoeren zum Abo und enden mit ihm, spaetestens am Folgetag (Tagesjob, angeschlossen in Task 11). Die Oeffnungszeiten + * live bleiben bei zeiten/aboAbgleich.js (30 Tage Frist, Task 10). Nur Dokumente des Abo-Modells (istAboModell: unlockedBy UND + * einrichtungAbrechnung aus abo oder projekt) fasst dieser Abgleich an; Dokumente ohne Marker (Kauf-Bestand, Team, Altbestand) gehoeren dem + * alten Terminplaner-Abgleich (termine/aboAbgleich.js) und bleiben unberuehrt. + * + * Abschalten setup|active und das Abo deckt die Funktion nicht (aboProdukt/ENTHALTEN_IN; gekuendigt und past_due laufen noch, nichts + * abschalten) => disable (Grund abo_ende), danach K39 (meldeBeendet) und der Marker aboEndeMailAt. + * Aufbauphase (E9, E21): ein Dokument mit unlockedBy 'projekt' bleibt, solange der Kunde noch nie ein Abo hatte + * (ohneAboBisher, abgebrochene Bestellungen zaehlen nicht): die Website ist noch im Bau. Ab dem ersten nicht deckenden Abo + * gilt fuer 'projekt' dieselbe Regel wie fuer 'abo'. ohneAboBisher wird nur gelesen, wenn ein projekt-Dokument vom Abo + * nicht gedeckt ist (je Kunde hoechstens einmal je Lauf) und unter der Sperre noch einmal frisch. + * Nachholen disabled mit Grund abo_ende ohne Marker => K39 erneut (Mailfehler, Kunde ohne Adresse); disabledAt bleibt, die Loeschfrist waechst nie. + * Rueckkehr disabled mit Grund abo_ende und das Abo deckt wieder, solange die Daten da sind (News: das Dokument ist noch disabled; + * Terminplaner: die Einstellung ist gespeichert; Webshop und Buchungskalender: die Notiz nennt keine Loeschung "gelöscht am") + * => wieder active (activate), ohne activatedAt wieder setup. Still, ohne Mail (K39 hat es angekuendigt). + * + * Regeln wie termine/aboAbgleich.js: eine Listenabfrage fuer alle Kunden; das Abo je Kunde einmal fuer die Vorauswahl lesen. Geschrieben + * wird nur unter der Sperre feature-admin: nach frischem Lesen von Dokument UND Abo (nie aus der Vorauswahl); die Mail und der + * Marker folgen nach der Sperre. Fail-closed: ist etwas nicht lesbar (Liste, Abo, Dokument, Aufbauphase), wird nichts abgeschaltet; Kunde + * bzw. Dokument werden uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen. + */ + +/** Funktionen dieses Abgleichs; zeiten gehoert zeiten/aboAbgleich.js (Task 10). */ +export const ABGLEICH_KEYS = Object.freeze(['news', 'termine', 'shop', 'unterkunft']) + +const GRUND = 'abo_ende' +const NOTIZ = 'System: Abgeschaltet: Abo beendet' +const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Abo läuft wieder' + +const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g + +/** Fehlertext fuers Log: Name und gekuerzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */ +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}` + +const portalFeatures = () => config.collections.portalFeatures + +/** Deckt das Abo (Ergebnis von aboProdukt) diese Funktion? Gekuendigt und past_due laufen noch und zaehlen. */ +const deckt = (abo, key) => abo.laeuft === true && ENTHALTEN_IN[key].includes(abo.produkt) + +/** Grund fuer K39: das Abo ist ein Hosting-Abo, sonst Abo beendet. */ +const artVon = (abo) => (abo.produkt === 'hosting' ? 'hosting' : GRUND) + +/** + * Sind die Daten der abgeschalteten Funktion noch da? News: das Dokument ist noch disabled (der Loeschschritt setzt es auf off); + * Terminplaner: die Einstellung ist gespeichert (ladeEinstellung wirft bei Lesefehlern, dann bleibt alles); Webshop und Buchungskalender: + * die Notiz nennt keine Loeschung ("Shopdaten gelöscht am", "Buchungsdaten gelöscht am"). + */ +async function datenDa(doc) { + if (doc.featureKey === 'termine') return (await ladeEinstellung(doc.$id)) !== null + if (doc.featureKey === 'shop' || doc.featureKey === 'unterkunft') return !/gelöscht am/.test(String(doc.adminNote ?? '')) + return true +} + +/** K39 nach der Sperre; ein Misserfolg zaehlt als Fehler, der naechste Lauf wiederholt (Marker leer). */ +async function melde(doc, art, ctx) { + const fertig = await meldeBeendet(doc, { art, now: ctx.now, onGesendet: () => (ctx.erg.mails += 1) }) + if (!fertig) ctx.erg.fehler += 1 +} + +/** + * Abschalten, ein Dokument: unter der Sperre frisch lesen (das Team kann inzwischen abgeschaltet haben), frisch das Abo lesen (es kann + * zurueckgekommen sein) und fuer ein projekt-Dokument frisch die Aufbauphase pruefen, erst dann disable. K39 folgt nach der Sperre. + */ +async function schalteAb(doc, ctx) { + const { erg } = ctx + const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istAboModell(frisch) || (frisch.state !== 'setup' && frisch.state !== 'active')) return null + const abo = await aboProdukt(frisch.customerId, { now: ctx.now }) // wirft bei Lesefehler: dann bleibt alles, wie es ist + if (deckt(abo, frisch.featureKey)) return null // das Abo ist zurueck + if (frisch.unlockedBy === 'projekt' && (await ohneAboBisher(frisch.customerId))) return null // Aufbauphase + const zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ }) + if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller + if (zuMelden.featureKey === 'unterkunft' && zuMelden.siteKey) leereCache(zuMelden.siteKey) + erg.abgeschaltet += 1 + return { zuMelden, art: artVon(abo) } + }) + if (gefunden) await melde(gefunden.zuMelden, gefunden.art, ctx) +} + +/** Nachholen, ein Dokument: unter der Sperre frisch lesen (noch abo_ende-abgeschaltet und ohne Marker, das Abo deckt weiter nicht), K39 danach. */ +async function holeMeldungNach(doc, ctx) { + const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istAboModell(frisch) || frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) return null + const abo = await aboProdukt(frisch.customerId, { now: ctx.now }) + if (deckt(abo, frisch.featureKey)) return null // das Abo ist zurueck: Rueckkehr, nicht melden + return { zuMelden: frisch, art: artVon(abo) } + }) + if (gefunden) await melde(gefunden.zuMelden, gefunden.art, ctx) +} + +/** + * Rueckkehr, ein Dokument: unter der Sperre frisch lesen (noch disabled/abo_ende), frisch das Abo lesen, die Daten pruefen, dann activate + * (mit activatedAt) oder zurueck auf setup (nie aktiv gewesen: die Abnahme laeuft wie sonst ueber das Ticket). Keine Mail. + */ +async function schalteWiederEin(doc, ctx) { + const { erg } = ctx + await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istAboModell(frisch) || frisch.state !== 'disabled' || frisch.disabledReason !== GRUND) return + const abo = await aboProdukt(frisch.customerId, { now: ctx.now }) + if (!deckt(abo, frisch.featureKey)) return // das Abo endete zwischen Vorauswahl und Sperre wieder + if (!(await datenDa(frisch))) return // Daten geloescht: Rueckkehr nur, solange sie existieren + if (frisch.activatedAt) { + await activate(frisch.$id, { adminNote: NOTIZ_WIEDER }) + } else { + await updateDocument(portalFeatures(), frisch.$id, { + ...stateFields('setup'), + disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, + adminNote: NOTIZ_WIEDER, + }) + } + erg.wiederEin += 1 + }) +} + +async function bearbeiteKunde(customerId, dokumente, ctx) { + const { erg } = ctx + let abo + try { + abo = await aboProdukt(customerId, { now: ctx.now }) + } catch (err) { + erg.fehler += 1 + console.error(`[abo-funktionen] Abo eines Kunden nicht lesbar, ${dokumente.length} Funktion(en) übersprungen: ${fehlerText(err)}`) + return + } + erg.geprueft += dokumente.length + + let aufbauphase = null // Promise-frei: einmal je Kunde und Lauf, nur gelesen, wenn ein projekt-Dokument nicht gedeckt ist + let aufbauphaseFehler = false + const inAufbauphase = async () => { + if (aufbauphaseFehler) return true // nicht lesbar: nichts abschalten (der Fehler ist gezaehlt) + if (aufbauphase === null) { + try { + aufbauphase = await ohneAboBisher(customerId) + } catch (err) { + aufbauphaseFehler = true + erg.fehler += 1 + console.error(`[abo-funktionen] Abo-Verlauf eines Kunden nicht lesbar, Projekt-Funktionen übersprungen: ${fehlerText(err)}`) + return true + } + } + return aufbauphase + } + + for (const doc of dokumente) { + try { + const gedeckt = deckt(abo, doc.featureKey) + if (doc.state === 'setup' || doc.state === 'active') { + if (gedeckt) continue + if (doc.unlockedBy === 'projekt' && (await inAufbauphase())) continue + await schalteAb(doc, ctx) + } else if (doc.state === 'disabled' && doc.disabledReason === GRUND) { + if (gedeckt) await schalteWiederEin(doc, ctx) + else if (!doc.aboEndeMailAt) await holeMeldungNach(doc, ctx) + } + } catch (err) { + erg.fehler += 1 + console.error(`[abo-funktionen] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) + } + } +} + +/** + * Taeglicher Abgleich der Funktionen des Abo-Modells mit dem Abo (siehe Kopf der Datei). Wirft nie; jedes Problem steht im Log und in `fehler`. + * @param {Date} [now] + * @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>} + * geprueft = bewertete Dokumente des Abo-Modells, deren Kunde lesbar war; abgeschaltet und wiederEin = in diesem Lauf; geloescht = Daten, die + * der Loeschschritt in diesem Lauf entfernt hat (Task 9); mails = versendete K39; fehler = Kunden und Dokumente, die nicht bearbeitet werden + * konnten, sowie ein gescheiterter K39-Versand + */ +export async function gleicheAboFunktionenAb(now = new Date()) { + const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 } + try { + pruefeNow(now, 'gleicheAboFunktionenAb') + let dokumente + try { + dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', [...ABGLEICH_KEYS]), Query.notEqual('state', 'off')]) + } catch (err) { + erg.fehler += 1 + console.error(`[abo-funktionen] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`) + return erg + } + const nachKunde = new Map() + for (const doc of dokumente.filter(istAboModell)) { + if (!doc.customerId || !doc.projectId) { + // ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst + erg.fehler += 1 + console.error(`[abo-funktionen] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`) + continue + } + if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, []) + nachKunde.get(doc.customerId).push(doc) + } + const ctx = { erg, now } + for (const [customerId, liste] of nachKunde) { + try { + await bearbeiteKunde(customerId, liste, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[abo-funktionen] Kunde nicht bearbeitet: ${fehlerText(err)}`) + } + } + } catch (err) { + erg.fehler += 1 + console.error(`[abo-funktionen] Abgleich abgebrochen: ${fehlerText(err)}`) + } + return erg +} diff --git a/server/services/aboFunktionenMeldung.js b/server/services/aboFunktionenMeldung.js index 6ae0235..46f8b2f 100644 --- a/server/services/aboFunktionenMeldung.js +++ b/server/services/aboFunktionenMeldung.js @@ -1,6 +1,8 @@ +import { updateDocument } from './appwriteAdmin.js' +import { config } from '../config.js' import { aboProdukt } from './aboFunktionen.js' import { unterlagenFuer } from './aboUnterlagen.js' -import { k38Mail, kontostandText, t24Mail } from './aboFunktionenMails.js' +import { k38Mail, k39Mail, kontostandText, loeschFristTage, t24Mail } from './aboFunktionenMails.js' import { featureConfig } from './featureRegistry.js' import { getLedger } from './hourLedger.js' import { featureLegalAttachments } from './legalDocs.js' @@ -13,6 +15,7 @@ import { customerOf, projectName } from './siteFeatures.js' * nicht fuer zeiten, dort sendet holeZeitenNach die K27. Mit den Vertragsunterlagen als PDF (ohne Widerrufsbelehrung), wenn * die Funktion welche hat und sie lesbar sind. * T24 ans Team, nur wenn der Kunde selbst eingeschaltet hat (by 'kunde'), fuer alle fuenf Funktionen. + * K39 an den Kunden, wenn der Tagesabgleich (aboFunktionenAbgleich.js) eine Funktion mit dem Abo beendet hat (meldeBeendet). * meldeEingeschaltet wirft nie: jeder Schritt steht in einem eigenen try, ein Fehler ist eine Logzeile mit Art und Status (weder * Name noch Adresse des Kunden) und der Rest laeuft weiter. Aufrufer ist einschalten() (funktionEinschalten.js). */ @@ -106,3 +109,44 @@ export async function meldeEingeschaltet({ doc, ticketWoid = null, by, abrechnun } return ergebnis } + +const TAG_MS = 24 * 60 * 60 * 1000 + +/** + * K39 (Deine Funktion wurde beendet) an den Kunden, danach der Marker aboEndeMailAt (erst nach erfolgreichem Versand): die Funktion + * wurde mit dem Abo beendet (disabled, Grund abo_ende). loeschdatum ist disabledAt plus die Loeschfrist der Funktion (loeschFristTage; + * disabledAt aendert dieser Pfad nie, eine Wiederholung nach einem Mailfehler nennt dasselbe Datum). Kunde nicht mehr vorhanden oder ohne + * Adresse: nichts zu senden, der Marker wird gesetzt, damit es keine Wiederholung gibt. Ein Dokument, das nicht abo_ende-abgeschaltet + * ist oder den Marker schon traegt, ist erledigt (true, nichts geschrieben). Wirft nie; das Log nennt weder Namen noch Adressen. + * @param {object} doc portalFeatures-Dokument (frisch gelesen, disabled) + * @param {{ art: string, now?: Date, onGesendet?: (() => void) | null }} options art 'hosting' (das Abo ist ein Hosting-Abo), sonst Abo beendet; + * onGesendet wird nach dem versendeten K39 gerufen (Zaehler des Abgleichs) + * @returns {Promise} true = nichts mehr offen (gesendet, nichts zu senden oder Marker gesetzt), false = Versand oder Marker gescheitert + * (Marker leer: der naechste Lauf wiederholt) + */ +export async function meldeBeendet(doc, { art, now = new Date(), onGesendet = null } = {}) { + let gesendet = false + try { + if (doc?.state !== 'disabled' || doc.disabledReason !== 'abo_ende' || doc.aboEndeMailAt) return true + const zeitpunkt = now instanceof Date ? now : new Date(now) + const setzeMarker = () => updateDocument(config.collections.portalFeatures, doc.$id, { aboEndeMailAt: zeitpunkt.toISOString() }) + const customer = await customerOf(doc.customerId) + const to = String(customer?.email ?? '').trim() + if (!to) { + console.warn(`[funktionen-mail] K39 (${doc.featureKey}) entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt`) + await setzeMarker() + return true + } + const projekt = (await projectName(doc.projectId)) || 'Dein Projekt' + const beginn = Number.isFinite(Date.parse(doc.disabledAt)) ? Date.parse(doc.disabledAt) : zeitpunkt.getTime() // disabledAt fehlt nie, ausser bei Altdaten + const loeschdatum = new Date(beginn + loeschFristTage(doc.featureKey) * TAG_MS) + await sendCustomerMail(to, k39Mail({ kunde: customer, projekt, key: doc.featureKey, loeschdatum, art })) + gesendet = true + onGesendet?.() + await setzeMarker() + return true + } catch (err) { + console.error(`[funktionen-mail] K39 (${doc?.featureKey}) ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerArt(err)}`) + return false + } +} diff --git a/test/aboFunktionenAbgleich.test.js b/test/aboFunktionenAbgleich.test.js new file mode 100644 index 0000000..094a99e --- /dev/null +++ b/test/aboFunktionenAbgleich.test.js @@ -0,0 +1,103 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' + +// Abo-Ende-Abgleich der Funktionen des Abo-Modells (P6 Task 8): News, Terminplaner, Webshop und Buchungskalender enden mit dem Abo +// (K39), kehren mit einem neuen Abo zurück, solange die Daten da sind. Aufbauphase (E9, N28): projekt ohne jedes Abo bleibt. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + termineConfig: schemaKeys('termineConfig'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { datumKurz } = await import('../server/services/termine/zeit.js') +const { gleicheAboFunktionenAb } = await import('../server/services/aboFunktionenAbgleich.js') +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([aw.close(), st.close()]) +}) + +const JETZT = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000, KEYS = ['news', 'termine', 'shop', 'unterkunft'] +const NAMEN = { news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender' } +const seed = (key, over = {}) => aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over }) +const f = (key) => aw.docs('portalFeatures').get(`f_p1_${key}`) +const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) +const k39 = (key) => mails.sent.find((m) => m.subject === `Deine Funktion wurde beendet: ${NAMEN[key]}`) +// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht +const leere = async (...collections) => { + for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) +} + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' }) +beforeEach(async () => { + await leere('portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig') + mails.sent.length = 0 + setMailTransportForTests(mails) + aw.seed('customers', customers.c1) +}) + +for (const [name, doc] of [['aktiv', wartungDoc], ['zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }], ['past_due', { ...wartungDoc, status: 'past_due' }]]) test(`Wartung ${name}: nichts wird abgeschaltet`, async () => { + abo(doc); KEYS.forEach((k) => seed(k)); const r = await gleicheAboFunktionenAb(JETZT) + assert.deepEqual([r.geprueft, r.abgeschaltet, r.mails, r.fehler, mails.sent.length], [4, 0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active')) +}) +test('Abo beendet: alle vier abgeschaltet (setup zählt wie active), K39 je Funktion mit eigener Frist, Marker; zweiter Lauf tut nichts', async () => { + abo({ ...wartungDoc, status: 'canceled' }); KEYS.forEach((k) => seed(k)); seed('news', { state: 'setup', enabled: false, activatedAt: null }) + const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, r.mails, r.fehler], [4, 4, 0]) + for (const k of KEYS) { + const d = f(k); assert.deepEqual([d.state, d.enabled, d.disabledReason], ['disabled', false, 'abo_ende']); assert.ok(d.aboEndeMailAt && d.disabledAt) + const tag = datumKurz(new Date(Date.parse(d.disabledAt) + (k === 'shop' || k === 'unterkunft' ? 90 : 30) * TAG)); assert.ok(k39(k).text.includes(tag), k) + assert.equal(/CSV/.test(k39(k).text), k === 'termine'); assert.equal(/Offene Bestellungen/.test(k39(k).text), k === 'shop' || k === 'unterkunft') + } + const vorher = f('shop').disabledAt; const noch = await gleicheAboFunktionenAb(new Date(JETZT.getTime() + TAG)); assert.deepEqual([noch.abgeschaltet, noch.mails, mails.sent.length, f('shop').disabledAt], [0, 0, 4, vorher]) +}) +test('Hosting statt Wartung: K39 mit Hosting-Satz', async () => { abo(hostingDoc); seed('shop'); await gleicheAboFunktionenAb(JETZT); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/) }) +test('Abo nicht lesbar: nichts abgeschaltet, fehler gezählt, Log ohne Adresse', async (t) => { + abo({ ...wartungDoc, status: 'canceled' }); seed('news'); const log = t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'k@example.invalid kaputt' }) + const r = await gleicheAboFunktionenAb(JETZT); assert.ok(r.fehler >= 1); assert.equal(f('news').state, 'active'); assert.equal(mails.sent.length, 0); assert.ok(log.mock.calls.every((c) => !String(c.arguments[0]).includes('@'))) +}) +test('Rückkehr: nur solange die Daten da sind; ohne activatedAt zurück auf setup; ohne Mail', async () => { + abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-10T09:00:00.000Z', aboEndeMailAt: '2026-10-10T09:05:00.000Z' } + seed('news', aus); seed('termine', { ...aus, activatedAt: null }); seed('shop', aus); seed('unterkunft', aus) + const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.wiederEin, mails.sent.length], [3, 0]) // termine: keine Einstellung gespeichert → bleibt + assert.deepEqual([f('news').state, f('news').disabledReason, f('news').aboEndeMailAt, f('shop').state, f('unterkunft').state, f('termine').state], ['active', null, null, 'active', 'active', 'disabled']) + aw.seed('termineConfig', { $id: 'f_p1_termine', einstellung: '{}', version: 1 }); await gleicheAboFunktionenAb(JETZT); assert.equal(f('termine').state, 'setup') // ohne activatedAt: setup, nicht active +}) +test('Rückkehr des Webshops und des Buchungskalenders nicht nach der Löschung der Daten', async () => { + abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-07-10T09:00:00.000Z', aboEndeMailAt: '2026-07-10T09:05:00.000Z' } + seed('shop', { ...aus, adminNote: '2026-10-10T09:00:00.000Z System: Shopdaten gelöscht am 2026-10-10' }); seed('unterkunft', { ...aus, adminNote: 'Buchungsdaten gelöscht am 2026-10-10' }) + const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.wiederEin, f('shop').state, f('unterkunft').state], [0, 'disabled', 'disabled']) +}) +test('Alte Dokumente bleiben unberührt; Kunde ohne Adresse setzt nur den Marker; Mailfehler: Marker leer, nächster Lauf sendet, disabledAt bleibt', async (t) => { + t.mock.method(console, 'error', () => {}); t.mock.method(console, 'warn', () => {}) + abo({ ...wartungDoc, status: 'canceled' }); seed('news', { unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: undefined }); seed('termine', { einrichtungAbrechnung: '' }); seed('shop', { unlockedBy: 'team' }) + assert.deepEqual([(await gleicheAboFunktionenAb(JETZT)).geprueft, f('news').state, f('termine').state, f('shop').state], [0, 'active', 'active', 'active']) + seed('unterkunft'); aw.seed('customers', { ...customers.c1, email: '' }); await gleicheAboFunktionenAb(JETZT); assert.deepEqual([f('unterkunft').state, Boolean(f('unterkunft').aboEndeMailAt), mails.sent.length], ['disabled', true, 0]) + aw.seed('customers', customers.c1); seed('news'); setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } }) + const bad = await gleicheAboFunktionenAb(JETZT); const am = f('news').disabledAt; assert.deepEqual([bad.fehler, Boolean(f('news').aboEndeMailAt)], [1, false]) + setMailTransportForTests(mails); const ok = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([ok.mails, f('news').disabledAt, Boolean(f('news').aboEndeMailAt)], [1, am, true]) +}) +test('Projektstart-Funktionen (projekt): ohne jedes Abo unberührt (Aufbauphase), mit deckendem Abo auch, mit dem ersten nicht deckenden Abo gilt die Abo-Regel', async () => { + KEYS.forEach((k) => seed(k, { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' })) + const leer = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([leer.abgeschaltet, leer.mails, leer.fehler, mails.sent.length], [0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active')) // kein Abo-Dokument + abo({ ...wartungDoc, status: 'incomplete' }); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // eine abgebrochene Bestellung ist kein Abo + await leere('subscriptions'); abo(wartungDoc); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // deckendes Abo + await leere('subscriptions'); abo(hostingDoc); const h = await gleicheAboFunktionenAb(JETZT) // das erste Abo deckt nicht: Abo-Regel + assert.deepEqual([h.abgeschaltet, h.mails], [4, 4]); KEYS.forEach((k) => assert.equal(f(k).disabledReason, 'abo_ende')); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/) +}) +test('abo-Dokumente kennen keine Aufbauphase: ohne jedes Abo-Dokument werden sie wie bisher abgeschaltet', async () => { + seed('news'); const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, f('news').state], [1, 'disabled']) +})