diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js new file mode 100644 index 0000000..f232bfa --- /dev/null +++ b/server/services/termine/aboAbgleich.js @@ -0,0 +1,205 @@ +import { config } from '../../config.js' +import { aboLage, aboMemo } from '../aboStatus.js' +import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { sendCustomerMail } from '../mailer.js' +import { customerOf, disable, projectName } from '../siteFeatures.js' +import { loeschzeit } from './frist.js' +import { pruefeNow, zeitMs } from './util.js' +import { datumKurz } from './zeit.js' + +/** + * Abo-Abgleich des Terminplaners (Terminplaner TP2, Block E): der Terminplaner gehoert zum Wartungs-Abo und endet mit ihm. + * gleicheAboAb(now) ist der Baustein fuer den Tagesjob (angeschlossen in E7); er kennt weder Schalter noch Ueberlappungssperre. + * + * Schritt 1 aktive, bezahlte Terminplaner (state active, unlockedByPurchase true) ohne laufendes Wartungs-Abo => abschalten + * (disabledReason 'abo_ende'; ab disabledAt laeuft die 30-Tage-Frist bis zur Loeschung, jobs.js) + * Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' ohne aboEndeMailAt die Mail K22 nachholen (auch fuer die + * eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem Versand gesetzt + * + * Regeln (Block E): + * - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird + * fuer die Vorauswahl nur einmal gelesen. Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz: unlockedByPurchase + * nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage. + * - Geschrieben wird nur unter der Sperre feature-admin: (wie die Team-Route) und nach frischem Lesen von Dokument UND + * Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten. + * - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden + * uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen. + * - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt K22 fehl, wiederholt der naechste Tag den Versand mit + * denselben Daten (Enddatum, Loeschdatum), die Loeschfrist waechst nicht. + * - Nur die Gruende aus siteFeatures.DISABLED_REASONS gelten. 'team', 'kunde_geloescht' und Altbestand ohne Grund fasst der + * Abgleich nie an; 'abo_ende_vor_einrichtung' gehoert zu E5, ordered/setup ebenfalls (sie zaehlen hier nur als geprueft). + */ + +const GRUND = 'abo_ende' +const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet' + +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 istAusgenommen = (doc) => doc.state === 'active' && doc.unlockedByPurchase !== true +const istBewertet = (doc) => + (doc.state === 'active' && doc.unlockedByPurchase === true) || + doc.state === 'ordered' || + doc.state === 'setup' || + (doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende')) + +const portalFeatures = () => config.collections.portalFeatures + +/** + * Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie K22 einzeln): + * K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now. + * Grund 'abo_ende': 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. + * Der Aufrufer liest `doc` frisch und haelt die Sperre feature-admin: (diese Funktion sperrt nicht, damit der Sofortpfad + * sie aus seiner eigenen Sperre rufen kann). Ohne Kunde oder ohne E-Mail gibt es nichts zu senden: der Marker wird gesetzt, damit + * es kein Wiederholen gibt. Wirft nie. + * @param {object} doc portalFeatures-Dokument (disabled) + * @param {{ lage: { art: string }, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen + * @returns {Promise} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker + * gescheitert (der naechste Tag wiederholt) + */ +export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { + const nowMs = pruefeNow(now, 'meldeAboEnde') + if (doc.disabledReason !== GRUND || doc.aboEndeMailAt) return true // E5 ergaenzt abo_ende_vor_einrichtung; mit Marker ist alles gesagt + const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() }) + let gesendet = false + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) { + console.warn('[termine-abo] K22 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 = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten + await sendCustomerMail(customer.email, { + template: 'K22', + values: { + kunde_name: customer.name || '', + projekt, + enddatum: datumKurz(beginn), + loeschdatum: datumKurz(loeschzeit(beginn)), + preis: config.features.termine.priceLabel, + portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, + }, + blocks: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true }, + }) + gesendet = true + onGesendet?.() + await setzeMarker() + return true + } catch (err) { + console.error(`[termine-abo] K22 für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`) + return false + } +} + +/** + * Ein Dokument unter seiner Sperre: frisch lesen, frisch das Abo lesen, erst dann schreiben. + * Dokument war in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten, dann K22. + * Dokument war in der Liste abgeschaltet (abo_ende) => noch so und ohne Marker, Abo laeuft weiter nicht => K22 nachholen. + */ +async function bearbeiteDokument(doc, ctx) { + const { erg, now } = ctx + const warAktiv = doc.state === 'active' + await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (warAktiv) { + if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return // das Team war schneller + } else if (frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) { + return + } + const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist + if (lage.laeuft) return // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6) + + let zuMelden = frisch + if (warAktiv) { + zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ }) + if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return // jemand war zwischen Lesen und Schreiben schneller + erg.abgeschaltet += 1 + } + const fertig = await meldeAboEnde(zuMelden, { lage, now, onGesendet: () => (erg.mails += 1) }) + if (!fertig) erg.fehler += 1 + }) +} + +async function bearbeiteKunde(customerId, dokumente, ctx) { + const { erg } = ctx + erg.ausgenommen += dokumente.filter(istAusgenommen).length + const bewertet = dokumente.filter(istBewertet) + if (bewertet.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen + + let lage + try { + lage = await ctx.memo.lage(customerId) + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${bewertet.length} Funktion(en) übersprungen: ${fehlerText(err)}`) + return + } + erg.geprueft += bewertet.length + if (lage.laeuft) return // laeuft weiter (E6 schaltet abgeschaltete wieder ein) + + for (const doc of bewertet) { + const aktiv = doc.state === 'active' + const nachholen = doc.state === 'disabled' && doc.disabledReason === GRUND && !doc.aboEndeMailAt + if (!aktiv && !nachholen) continue + try { + await bearbeiteDokument(doc, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) + } + } +} + +/** + * Taeglicher Abgleich des Terminplaners mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie + * alle Termine-Jobs); jedes Problem steht im Log und in `fehler`. + * @param {Date} now + * @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>} + * geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde + * lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = wieder eingeschaltet (E6; hier immer 0), mails = versendete + * K22, ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz), fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten + */ +export async function gleicheAboAb(now) { + pruefeNow(now, 'gleicheAboAb') + const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } + try { + let dokumente + try { + dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')]) + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`) + return erg + } + const nachKunde = new Map() + for (const doc of dokumente) { + if (!doc.customerId || !doc.projectId) { + // ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst + erg.fehler += 1 + console.error(`[termine-abo] 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, memo: aboMemo() } + for (const [customerId, liste] of nachKunde) { + try { + await bearbeiteKunde(customerId, liste, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`) + } + } + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Abgleich abgebrochen: ${fehlerText(err)}`) + } + return erg +} diff --git a/test/termineAboAbgleich.test.js b/test/termineAboAbgleich.test.js new file mode 100644 index 0000000..21917e8 --- /dev/null +++ b/test/termineAboAbgleich.test.js @@ -0,0 +1,575 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' + +// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4): aktive Terminplaner ohne laufendes +// Wartungs-Abo abschalten (Schritt 1) und die Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von +// Appwrite mit Schema und Standard-Limit 25, Mails in der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + termineConfig: schemaKeys('termineConfig'), + }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { disable } = await import('../server/services/siteFeatures.js') +const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js') +after(() => setMailTransportForTests(null)) + +const TAG = 24 * 60 * 60 * 1000 +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const KUNDE = 'c_kunde01' +const F1 = 'f_p_projekt01_termine' +const BASIS_VORHER = config.publicBaseUrl + +const plus = (von, ms) => new Date(von.getTime() + ms) +const ms = (wert) => new Date(wert).getTime() + +let sink +let log + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + aw.resetListCalls() + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +}) +afterEach(() => { + // Logs enthalten nie Adresse oder Namen des Kunden (und keine Gaeste: der Abgleich kennt keine) + const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') + mock.timers.reset() + mock.restoreAll() + config.publicBaseUrl = BASIS_VORHER + assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log') +}) + +// ---------------------------------------------------------------- Hilfen + +const projektId = (nr) => `p_projekt0${nr}` +const funktionsId = (nr) => `f_p_projekt0${nr}_termine` + +/** Aktiver, bezahlter Terminplaner von KUNDE fuer Projekt nr (samt Projekt "Praxis Muster" bzw. eigenem Namen). */ +function legeFunktion(nr = 1, over = {}, { projekt = 'Praxis Muster', kunde = KUNDE } = {}) { + aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt }) + aw.seed('portalFeatures', { + $id: funktionsId(nr), + customerId: kunde, + projectId: projektId(nr), + featureKey: 'termine', + state: 'active', + enabled: true, + unlockedByPurchase: true, + purchaseStatus: 'paid', + siteKey: `abcdefghijklmn0${nr}`, + ...over, + }) + return funktionsId(nr) +} +const funktion = (id = F1) => aw.docs('portalFeatures').get(id) + +/** Wartungs-Abo von KUNDE (ersetzt nichts: ein einziges Abo-Dokument je Test). */ +const wartung = (status, over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status, ...over })) +const hosting = (status = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, customerId: KUNDE, status, ...over })) + +/** Der Abgleich laeuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() (new Date()) denselben Zeitpunkt schreibt. */ +async function laufe(now = JETZT) { + mock.timers.enable({ apis: ['Date'], now: now.getTime() }) + try { + return await gleicheAboAb(now) + } finally { + mock.timers.reset() + } +} + +const ERGEBNIS_LEER = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } +const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) + +const mails = () => sink.sent +const abfragenAufAbos = () => aw.listCalls('subscriptions').length + +/** + * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende, dann + * erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen. + */ +async function mitEinwurf(passt, dazwischen, fn) { + const echt = globalThis.fetch + let offen = true + globalThis.fetch = async (url, optionen) => { + const antwort = await echt(url, optionen) + if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) { + offen = false + globalThis.fetch = echt + await dazwischen() + } + return antwort + } + try { + return await fn() + } finally { + globalThis.fetch = echt + } +} +const liste = (collection) => (url, methode) => methode === 'GET' && new RegExp(`/collections/${collection}/documents(\\?|$)`).test(url) + +// ---------------------------------------------------------------- Schritt 1 und 2: Abo beendet + +test('Abo beendet: Terminplaner abgeschaltet, K22 an den Kunden, am Folgetag nichts mehr (die Frist wird nie verschoben)', async () => { + legeFunktion(1) + wartung('canceled', { endedAt: JETZT.toISOString(), canceledAt: JETZT.toISOString() }) + + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.enabled, false) + assert.equal(doc.disabledReason, 'abo_ende') + assert.equal(ms(doc.disabledAt), JETZT.getTime()) + assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet') + assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach dem Versand, mit now') + + assert.equal(mails().length, 1) + const mail = mails()[0] + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.equal(mail.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') + assert.match(mail.text, /Silke Müller/) + assert.match(mail.text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Block abo_ende, Enddatum = Berliner Tag von disabledAt') + assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt + 30 Tage') + assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/) + assert.match(mail.text, /149 €/, 'Preis aus dem Register (priceLabel)') + assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/) + assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht der Hosting-Block') + assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') + + // Folgetag: nichts mehr, disabledAt und Marker bleiben, keine zweite Mail + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 1) +}) + +test('Kein Abo-Dokument: abgeschaltet, K22 mit dem Abo-Ende-Block', async () => { + legeFunktion(1) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(funktion().disabledReason, 'abo_ende') + assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/) +}) + +test('Nur ein Hosting-Abo aktiv: abgeschaltet, K22 mit dem Block hosting ("Hosting-Abo")', async () => { + legeFunktion(1) + hosting('active') + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + const text = mails()[0].text + assert.match(text, /weil Dein Abo ein Hosting-Abo ist/) + assert.doesNotMatch(text, /Wartungs-Abo am/, 'nicht der Block abo_ende') + assert.match(text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'der Kasten "Was jetzt gilt" gehoert auch zum Hosting-Fall') +}) + +test('Widerrufener Wechsel ins Wartungs-Abo (Spiegel sagt "Wartung"): abgeschaltet, K22 mit dem Block hosting', async () => { + legeFunktion(1) + wartung('active') + aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: 'sub_w', type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + assert.match(mails()[0].text, /weil Dein Abo ein Hosting-Abo ist/) +}) + +// ---------------------------------------------------------------- Laeuft weiter / endet: die Status-Tabelle + +const LAEUFT_WEITER = [ + ['past_due', {}], + ['trialing', {}], + ['active', { cancelAtPeriodEnd: true, cancelAt: new Date(Date.now() + 10 * TAG).toISOString() }], +] +for (const [status, zusatz] of LAEUFT_WEITER) { + test(`Abo ${status}${zusatz.cancelAtPeriodEnd ? ' mit vorgemerkter Kuendigung in 10 Tagen' : ''}: laeuft weiter, Terminplaner bleibt aktiv, keine Mail`, async () => { + legeFunktion(1) + wartung(status, zusatz) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().enabled, true) + assert.equal(funktion().disabledAt, undefined) + assert.equal(mails().length, 0) + }) +} +for (const status of ['unpaid', 'incomplete_expired', 'canceled']) { + test(`Abo ${status}: Terminplaner abgeschaltet`, async () => { + legeFunktion(1) + wartung(status) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(funktion().disabledReason, 'abo_ende') + }) +} + +// ---------------------------------------------------------------- Lesefehler: nie abschalten + +test('Lesefehler beim Abo: der Terminplaner bleibt aktiv, fehler 1, keine Mail; der naechste Lauf schaltet ab', async () => { + legeFunktion(1) + wartung('canceled') + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }), 'nicht lesbar heisst nicht bewertet: geprueft 0') + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') +}) + +test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt aktiv; der folgende Lauf schaltet ab', async () => { + legeFunktion(1) + wartung('canceled') + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledAt, undefined) + assert.equal(mails().length, 0) + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') +}) + +test('Fehler beim Lesen der Ereignisse (Wechsel-Regel): bleibt aktiv, fehler 1', async () => { + legeFunktion(1) + wartung('active') + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) +}) + +test('Fehler beim Lesen der Dokumentliste: kein Wurf, Ergebnis mit fehler, nichts geaendert', async () => { + legeFunktion(1) + wartung('canceled') + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) + assert.deepEqual(await gleicheAboAb(JETZT), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(abfragenAufAbos(), 0, 'ohne Liste wird kein Abo gelesen') + assert.equal(mails().length, 0) +}) + +test('Ein fehlgeschlagenes Abschalten stoppt den Lauf nicht: das Dokument bleibt aktiv, die anderen Kunden kommen dran', async () => { + aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) + legeFunktion(1) + legeFunktion(2, {}, { projekt: 'Zweites Projekt', kunde: 'c_kunde02' }) + aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'canceled' })) + wartung('canceled') + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) + const erg = await laufe() + assert.deepEqual(erg, ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1, fehler: 1 })) + assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state].sort(), ['active', 'disabled']) + assert.equal(mails().length, 1) + // der naechste Lauf holt das Fehlgeschlagene nach + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion(funktionsId(1)).state, 'disabled') + assert.equal(funktion(funktionsId(2)).state, 'disabled') +}) + +// ---------------------------------------------------------------- Pilot/Kulanz, Entwuerfe, andere Funktionen + +test('Pilot und Kulanz (unlockedByPurchase false oder fehlend): unveraendert aktiv, nicht geprueft, als ausgenommen gezaehlt, ohne Abo-Abfrage und Mail', async () => { + aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) + legeFunktion(1, { unlockedByPurchase: false }) + const ohneFeld = legeFunktion(2, { unlockedByPurchase: undefined }, { kunde: 'c_kunde02' }) // das Feld fehlt (Altbestand, Staff-Freischaltung) + assert.notEqual(funktion(ohneFeld).unlockedByPurchase, true) + + assert.deepEqual(await laufe(), ergebnis({ ausgenommen: 2 })) + assert.equal(funktion(funktionsId(1)).state, 'active') + assert.equal(funktion(ohneFeld).state, 'active') + assert.equal(abfragenAufAbos(), 0, 'fuer ausgenommene Dokumente wird das Abo nicht gelesen') + assert.equal(mails().length, 0) +}) + +test('Entwuerfe (off) werden nicht gelesen: eine Abfrage mit featureKey termine und notEqual state off; andere Funktionen bleiben unberuehrt', async () => { + legeFunktion(1, { state: 'off', enabled: false }) + aw.seed('portalFeatures', { $id: 'f_p_projekt09_news', customerId: KUNDE, projectId: 'p_projekt09', featureKey: 'news', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid' }) + assert.deepEqual(await laufe(), ergebnis()) + assert.equal(funktion().state, 'off') + assert.equal(funktion('f_p_projekt09_news').state, 'active', 'News fasst der Abgleich nie an') + const listen = aw.listCalls('portalFeatures') + assert.equal(listen.length, 1, 'eine einzige Listenabfrage') + const abfragen = listen[0].queries + assert.ok(abfragen.some((q) => q.method === 'equal' && q.attribute === 'featureKey' && q.values[0] === 'termine')) + assert.ok(abfragen.some((q) => q.method === 'notEqual' && q.attribute === 'state' && q.values[0] === 'off'), JSON.stringify(abfragen)) + assert.equal(abfragenAufAbos(), 0) +}) + +test('ordered, setup und abo_ende_vor_einrichtung zaehlen als geprueft, E4 aendert daran nichts (E5 erweitert)', async () => { + legeFunktion(1, { state: 'ordered', enabled: false }) + legeFunktion(2, { state: 'setup', enabled: false }) + legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) + const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 })) + assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) + assert.equal(mails().length, 0) +}) + +test('Alt-disabled ohne Grund, team und kunde_geloescht: nie angefasst, nie K22', async () => { + const tag = plus(JETZT, -3 * TAG).toISOString() + legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: tag }) // Altbestand ohne Grund + legeFunktion(2, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'team' }) + legeFunktion(3, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'kunde_geloescht' }) + const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) + assert.deepEqual(await laufe(), ergebnis()) + assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) + assert.equal(mails().length, 0) + assert.equal(abfragenAufAbos(), 0) +}) + +test('Dokument ohne Kunden-ID wird nie als "kein Abo" gedeutet: bleibt aktiv, fehler 1', async () => { + legeFunktion(1, { customerId: '' }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(abfragenAufAbos(), 0) +}) + +// ---------------------------------------------------------------- Rennen + +test('Rennen: zwischen Vorauswahl und Sperre kommt ein aktives Abo - die frische Pruefung laesst den Terminplaner aktiv', async () => { + legeFunktion(1) + wartung('canceled') + const erg = await mitEinwurf( + liste('subscriptions'), + async () => wartung('active'), // das Abo kommt zurueck, nachdem die Vorauswahl "beendet" gelesen hat + () => laufe(), + ) + assert.deepEqual(erg, ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) + assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung') +}) + +test('Rennen: zwischen Liste und Sperre schaltet das Team ab - Grund team und disabledAt bleiben, kein K22', async () => { + legeFunktion(1) + wartung('canceled') + await mitEinwurf( + liste('portalFeatures'), + () => disable(F1, { reason: 'team', adminNote: 'Abgeschaltet von Kenso' }), + () => gleicheAboAb(JETZT), + ).then((erg) => assert.deepEqual(erg, ergebnis({ geprueft: 1 }))) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.disabledReason, 'team') + assert.equal(doc.adminNote, 'Abgeschaltet von Kenso') + assert.ok(Math.abs(ms(doc.disabledAt) - Date.now()) < 60_000, 'disabledAt vom Team, nicht vom Abgleich') + assert.equal(doc.aboEndeMailAt, undefined) + assert.equal(mails().length, 0) + const vorher = { ...doc } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis(), 'auch am Folgetag nichts') + assert.deepEqual(funktion(), vorher) +}) + +test('Rennen: zwischen Liste und Sperre wird das Dokument zum Kulanz-Dokument (nicht vom Kauf freigeschaltet) - bleibt aktiv, nichts passiert', async () => { + legeFunktion(1) + wartung('canceled') + const erg = await mitEinwurf( + liste('portalFeatures'), + async () => aw.seed('portalFeatures', { ...funktion(), unlockedByPurchase: false }), + () => laufe(), + ) + assert.deepEqual(erg, ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledAt, undefined) + assert.equal(mails().length, 0) +}) + +test('Sperre: haelt jemand feature-admin:, bleibt der Abgleich offen, bis sie frei ist', async () => { + legeFunktion(1) + wartung('canceled') + let fertig = false + let lauf + await withLocks([`feature-admin:${'p_projekt01'}`], async () => { + lauf = gleicheAboAb(JETZT).then((erg) => { + fertig = true + return erg + }) + await new Promise((resolve) => setTimeout(resolve, 250)) + assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) + }) + assert.deepEqual(await lauf, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') +}) + +test('Sperre: ein anderes Projekt blockiert nicht', async () => { + legeFunktion(1) + wartung('canceled') + const erg = await withLocks(['feature-admin:p_fremd'], () => gleicheAboAb(JETZT)) + assert.deepEqual(erg, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) +}) + +// ---------------------------------------------------------------- Memo + +test('Memo: ein Kunde mit drei Projekten - die Vorauswahl liest das Abo einmal, drei Dokumente abgeschaltet, je Projekt eine K22', async () => { + legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) + legeFunktion(2, {}, { projekt: 'Projekt Beta' }) + legeFunktion(3, {}, { projekt: 'Projekt Gamma' }) + wartung('canceled') + assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 3 })) + for (const nr of [1, 2, 3]) { + assert.equal(funktion(funktionsId(nr)).state, 'disabled') + assert.equal(funktion(funktionsId(nr)).disabledReason, 'abo_ende') + } + assert.deepEqual(mails().map((m) => m.subject).sort(), [ + 'Dein Terminplaner wurde beendet – Projekt Alpha', + 'Dein Terminplaner wurde beendet – Projekt Beta', + 'Dein Terminplaner wurde beendet – Projekt Gamma', + ]) + // eine Abfrage fuer die Vorauswahl (nicht eine je Projekt) plus je abzuschaltendem Dokument die frische Pruefung unter seiner Sperre + assert.equal(abfragenAufAbos(), 1 + 3) +}) + +test('Memo: mehrere Kunden - je Kunde eine Vorauswahl, die Ausnahmen kosten keine Abfrage', async () => { + aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) + legeFunktion(1) + legeFunktion(2, {}, { kunde: 'c_kunde02' }) + legeFunktion(3, { unlockedByPurchase: false }, { kunde: 'c_kunde03' }) + wartung('active') + aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'active' })) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, ausgenommen: 1 })) + assert.equal(abfragenAufAbos(), 2) +}) + +// ---------------------------------------------------------------- K22-Nachlauf + +test('K22-Nachlauf: der Transport wirft - disabled, Marker leer, fehler 1; der naechste Tag sendet K22 mit unveraenderten Daten', async () => { + legeFunktion(1) + wartung('canceled') + setMailTransportForTests({ + sendMail: async () => { + throw new Error('550 5.1.1 Recipient address rejected') + }, + }) + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.disabledReason, 'abo_ende') + assert.ok(!doc.aboEndeMailAt, 'Marker erst nach Erfolg') + assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)') + + setMailTransportForTests(sink) + const disabledAt = doc.disabledAt + const naechster = plus(JETZT, TAG) + assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mails().length, 1) + assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Enddatum aus disabledAt, nicht vom Folgetag') + assert.match(mails()[0].text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum bleibt: die Frist wuchs nicht') + assert.equal(funktion().disabledAt, disabledAt) + assert.equal(ms(funktion().aboEndeMailAt), naechster.getTime()) + + assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'am dritten Tag nichts mehr') + assert.equal(mails().length, 1) +}) + +test('K22-Nachlauf: Marker-Schreiben scheitert nach dem Versand - fehler 1, die Mail ist raus, der naechste Tag meldet erneut und setzt den Marker', async () => { + legeFunktion(1) + wartung('canceled') + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, after: 1 }) // 1. PATCH = abschalten, 2. = Marker + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.ok(!funktion().aboEndeMailAt) + assert.equal(mails().length, 1) + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }), 'lieber doppelt als gar nicht') + assert.equal(ms(funktion().aboEndeMailAt), plus(JETZT, TAG).getTime()) + assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) +}) + +for (const [fall, vorbereitung] of [ + ['Kunde geloescht', () => deleteDocument('customers', KUNDE)], + ['Kunde ohne E-Mail', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })], +]) { + test(`K22-Nachlauf: ${fall} - kein Wurf, keine Mail, Marker gesetzt, am Folgetag nichts`, async () => { + legeFunktion(1) + wartung('canceled') + await vorbereitung() + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) + assert.equal(funktion().state, 'disabled') + assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'kein Wiederholen') + assert.equal(mails().length, 0) + assert.ok(log.warn.mock.calls.length >= 1, 'ein Hinweis im Log, ohne Adresse (das prueft afterEach)') + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 0) + }) +} + +test('K22-Nachlauf: das Abo laeuft wieder - Schritt 2 uebergeben (E6), keine Mail, Marker bleibt leer', async () => { + legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) + wartung('active') + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'disabled') + assert.ok(!funktion().aboEndeMailAt) + assert.equal(mails().length, 0) +}) + +test('K22-Nachlauf: mit Marker (aboEndeMailAt) nie eine zweite Mail', async () => { + legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -2 * TAG).toISOString() }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 0) +}) + +test('K22-Nachlauf: Fehler beim frischen Lesen des Abos - keine Mail, fehler 1, der naechste Tag holt sie nach', async () => { + legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -TAG).toISOString() }) + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frische Pruefung scheitert + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(mails().length, 0) + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) + assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 04\.10\.2026 beendet wurde/, 'Enddatum = Tag von disabledAt (gestern)') + assert.match(mails()[0].text, /Bis 03\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt (04.10.) + 30 Tage') +}) + +// ---------------------------------------------------------------- meldeAboEnde (E5/E9 rufen nur ihn) + +test('meldeAboEnde: sendet K22 und setzt den Marker (true), mit Marker nichts mehr (true, keine Mail), Fehler im Versand false', async () => { + const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) + const lage = { laeuft: false, art: 'beendet', endedAt: null } + const spaeter = plus(JETZT, 2 * TAG) + + setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter }), false) + assert.ok(!funktion(id).aboEndeMailAt) + + setMailTransportForTests(sink) + let gesendet = 0 + assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) + assert.equal(gesendet, 1) + assert.equal(mails().length, 1) + assert.equal(ms(funktion(id).aboEndeMailAt), spaeter.getTime()) + + assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) + assert.equal(gesendet, 1) + assert.equal(mails().length, 1, 'der Marker verhindert die zweite Mail') +}) + +test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, ein Lesefehler beim Projekt wiederholt am naechsten Tag', async () => { + const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) + await deleteDocument('websiteProjects', 'p_projekt01') + const lage = { laeuft: false, art: 'beendet', endedAt: null } + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) + assert.equal(mails()[0].subject, 'Dein Terminplaner wurde beendet – Dein Projekt') + + const id2 = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) + aw.failNext({ collection: 'websiteProjects', method: 'GET', status: 500 }) + assert.equal(await meldeAboEnde(funktion(id2), { lage, now: JETZT }), false) + assert.ok(!funktion(id2).aboEndeMailAt) + assert.equal(mails().length, 1) +})