diff --git a/server/mail-templates/K24 Zeiten eingeschaltet.html b/server/mail-templates/K24 Zeiten eingeschaltet.html index 4353e17..79c999c 100644 --- a/server/mail-templates/K24 Zeiten eingeschaltet.html +++ b/server/mail-templates/K24 Zeiten eingeschaltet.html @@ -8,9 +8,10 @@ Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern Platzhalter: {{kunde_name}}, {{projekt}}, {{ticket}}, {{portal_url}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angestossen, manuell, wieder + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angestossen, manuell, wieder, weiter Geht an Kunden, die Öffnungszeiten live im Wartungs-Abo selbst einschalten (Gratisweg), und, mit dem Block wieder, wenn die Funktion nach einem Abo-Ende wieder eingeschaltet wird. Genau einer der Blöcke angestossen (der Neubau der Website läuft, die Leisten erscheinen nach dem nächsten Veröffentlichen) oder manuell (das Team baut ein) ist an; wieder kommt dazu, wenn das Wartungs-Abo zurückgekehrt ist. + weiter umschließt den ganzen Kasten „Wie geht es weiter?“ und ist an, wenn angestossen oder manuell an ist. Kehrt das Abo zurück, bevor die Funktion abgeschaltet wurde, gab es keinen Neubau: dann sind angestossen und manuell aus, der Kasten entfällt und nur der Block wieder erklärt die Lage. ticket = Hinweis auf das Einbau-Ticket mit führendem Leerzeichen, zum Beispiel „ (Ticket #100123)“, oder leer. Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke. --> @@ -40,7 +41,9 @@ a{color:#111111;} Öffnungszeiten live ist eingeschaltet Hallo {{kunde_name}},
Öffnungszeiten live ist für Dein Projekt {{projekt}} eingeschaltet. Die Funktion ist in Deinem Wartungs-Abo ohne Aufpreis enthalten. Dein Wartungs-Abo läuft wieder, deshalb haben wir sie automatisch und ohne Gebühr wieder eingeschaltet. Deine Zeiten und Einstellungen sind unverändert da.
FunktionÖffnungszeiten live
Projekt{{projekt}}
+ +
Zeiten bearbeiten →
Viele Grüße
Dein WEBklar-Team diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index d7b03ee..c11446c 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -462,6 +462,10 @@ export async function activate(docId, { by, adminNote } = {}) { disabledAt: null, disabledReason: null, aboEndeMailAt: null, + // Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der + // Aktivierung (die Felder legt scripts/zeiten-setup.mjs an; News und Terminplaner teilen die Collection) + aboEndeAt: null, + behaltenSessionId: '', ...(note ? { adminNote: note } : {}), }) } diff --git a/server/services/zeiten/aboAbgleich.js b/server/services/zeiten/aboAbgleich.js new file mode 100644 index 0000000..3946818 --- /dev/null +++ b/server/services/zeiten/aboAbgleich.js @@ -0,0 +1,348 @@ +import { config } from '../../config.js' +import { aboLage, aboMemo } from '../aboStatus.js' +import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon } from '../siteFeatures.js' +import { LOESCHFRIST_TAGE } from '../termine/frist.js' +import { pruefeNow, zeitMs } from '../termine/util.js' +import { heute, plusTage, zuUtc } from '../termine/zeit.js' +import { nachAbschaltung } from './abschalten.js' +import { k24Mail, k26Mail, k27Mail, sendeZeitenMail } from './mails.js' +import { stosseNeubauAn } from './neubau.js' +import { ladeZeiten, loescheZeiten } from './speicher.js' + +/** + * Abo-Abgleich von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 5 "Abo-Abgleich" und Z11): + * Die Funktion gehört im Wartungs-Abo ohne Aufpreis dazu und endet mit ihm, aber nicht sofort: 30 Tage Frist zum Behalten (30 €), danach + * ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in + * Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen. + * + * Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo + * wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel): + * + * setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K26 (Variante nach art; Frist = Berliner Tag von + * aboEndeAt + 30 Tage) + * setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', K27 (Löschdatum = Abschalten + 30 Tage), danach + * das Ersatz-Ticket des Teams (nachAbschaltung) + * setup/active, Abo läuft, aboEndeAt gesetzt Schritt 3a: das Abo ist vor dem Abschalten zurück: aboEndeAt, Marker und offene + * Behalten-Sitzung leeren, Zustand bleibt, KEIN Neubau (die Leisten waren nie weg), + * K24 "wieder" ohne den Kasten "Wie geht es weiter?" (k24Mail einbau null, R17b) + * disabled/'abo_ende', Abo läuft Schritt 3b: zurück auf active (war die Funktion schon einmal freigeschaltet: + * activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K24 "wieder" + * mit dem echten einbau. Solange die Eingaben noch da sind: auch am Tag der Löschung + * geht die Rückkehr der Löschung vor (die Rückkehr wird je Lauf zuerst entschieden). + * disabled/'abo_ende', Abo läuft nicht, 30 Tage Schritt 4: loescheZeiten (daten, team, Merker) und state 'off'; ticketId, einbau, + * nach disabledAt k24At, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten + * Zeiten-Dokument). Grund 'team' (und jeder andere Grund) wird nie gelöscht. + * + * Tage zählen nach BERLINER KALENDERTAGEN (plusTage, nie Vielfache von 24 Stunden): "Tag 30" ist der 30. Berliner Tag nach dem Tag, an dem + * das Abo-Ende erkannt wurde, egal um welche Uhrzeit der Tagesjob an diesem Tag läuft (er läuft im 24-Stunden-Takt ab dem Start des Servers + * und kann je Tag ein paar Minuten früher oder später liegen; eine Zeitumstellung verschöbe die Frist um eine Stunde). Dieselben Tage stehen + * in K26 und K27. + * + * Regeln (wie termine/aboAbgleich.js): + * - Eine Listenabfrage für alle Kunden (Entwürfe 'off' bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird für die + * Vorauswahl nur einmal gelesen; Dokumente, bei denen nichts zu tun ist (Abo läuft, kein Abo-Ende), kosten weder Sperre noch Schreiben. + * - 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): der frische Stand entscheidet, nicht die Vorauswahl. Mails, Neubau und Ersatz-Ticket folgen NACH + * der Sperre (Netzwerk; Sperren sind nicht wiederbetretbar). + * - Fail-closed: ist das Abo nicht lesbar (Vorauswahl oder unter der Sperre), wird nichts abgeschaltet, nichts gelöscht und nichts + * eingeschaltet. Der Kunde wird übersprungen, `fehler` zählt, der nächste Lauf versucht es wieder. Ein Dokument mit unlesbarem Datum + * wird übersprungen (`fehler`), die übrigen laufen weiter. Das Log nennt weder Namen noch Adressen. + * - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K26, nach dem + * Abschalten der K27 (er wird beim Abschalten zurückgesetzt). Scheitert der Versand, bleibt der Marker leer, der nächste Lauf sendet nach + * (dieselben Daten; die Frist wächst nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Ein Kunde ohne Adresse bekommt den + * Marker (es gibt nichts zu senden). K24 "wieder" geht nur an Kunden, die K26 oder K27 bekommen haben (Marker, vor dem Leeren gelesen) + * und hat keinen Marker: höchstens einmal je Rückkehr (scheitert der Versand, steht es im Log und in `fehler`). + * - Ruling R25: die Rückkehr leert aboEndeAt und behaltenSessionId; eine noch offene Behalten-Zahlung bucht danach nicht mehr zum Kauf + * (bucheBehalten: Sitzung passt nicht, T19), und starteBehalten lehnt bei laufendem Abo ab. + */ + +const KEY = 'zeiten' +const GRUND = 'abo_ende' +/** Frist zum Behalten nach dem erkannten Abo-Ende (Z11); die Datenfrist nach dem Abschalten ist LOESCHFRIST_TAGE (30). */ +const FRIST_TAGE = 30 + +const NOTIZ_ABGESCHALTET = 'System: Abgeschaltet: Wartungs-Abo beendet, Frist abgelaufen' +const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Wartungs-Abo läuft wieder' +const NOTIZ_OHNE_NEUBAU = 'System: Wartungs-Abo läuft wieder, Abo-Ende zurückgenommen' +const NOTIZ_GELOESCHT = `System: Öffnungszeiten gelöscht, ${LOESCHFRIST_TAGE} Tage nach dem Abschalten` + +const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g + +/** Fehlertext fürs Log: Name und gekürzte 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 + +const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active' +const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND +/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */ +const istGekoppelt = (doc) => unlockedByVon(doc) === 'abo' && (istLaufend(doc) || istAbgeschaltet(doc)) + +/** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */ +function tagVon(wert, feld) { + const t = zeitMs(wert) + if (!Number.isFinite(t)) throw new Error(`${feld} nicht lesbar`) + return heute(new Date(t)) +} +/** Tag, an dem abgeschaltet wird: Berliner Tag von aboEndeAt + 30 Tage. */ +const fristTag = (doc) => plusTage(tagVon(doc.aboEndeAt, 'aboEndeAt'), FRIST_TAGE) +/** Tag, an dem die Eingaben gelöscht werden: Berliner Tag von disabledAt + 30 Tage. */ +const loeschTag = (doc) => plusTage(tagVon(doc.disabledAt, 'disabledAt'), LOESCHFRIST_TAGE) +/** Ein Date mitten an einem Berliner Tag: die Mail-Builder zeigen den Berliner Tag dieses Zeitpunkts (kein Springen über Mitternacht). */ +const mittags = (tag) => zuUtc(tag, '12:00') + +/** + * Was mit dem Dokument bei dieser Abo-Lage und diesem Tag zu tun ist (reine Entscheidung, null = nichts): + * 'ende' | 'k26' | 'abschalten' | 'k27' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum. + */ +function entscheide(doc, lage, now) { + const tag = heute(now) + if (istAbgeschaltet(doc)) { + if (lage.laeuft) return 'zurueck' + if (tag >= loeschTag(doc)) return 'loeschen' + return doc.aboEndeMailAt ? null : 'k27' + } + if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null + if (!doc.aboEndeAt) return 'ende' + if (tag >= fristTag(doc)) return 'abschalten' + return doc.aboEndeMailAt ? null : 'k26' +} + +/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */ +async function datenDa(featureId) { + return (await ladeZeiten(featureId))?.daten != null +} + +/** Daten löschen (unter der Sperre feature-admin:), danach das Dokument auf 'off' und leer. */ +async function loesche(frisch, ctx) { + await loescheZeiten(frisch.$id) + await updateDocument(portalFeatures(), frisch.$id, { + ...stateFields('off'), + ticketId: '', + einbau: '', + k24At: null, + aboEndeAt: null, + behaltenSessionId: '', + adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`), + }) + ctx.erg.geloescht += 1 +} + +/** + * Ein Dokument unter der Sperre feature-admin:: frisch lesen, frisch das Abo lesen, den frischen Stand entscheiden lassen und + * schreiben. Liefert, was nach der Sperre zu tun ist ({ aktion, doc, lage, hatteMail }), oder null (nichts mehr zu tun, das Team war + * schneller). Wirft bei Lesefehlern (Dokument oder Abo): dann ist nichts geschrieben. + */ +async function unterSperre(doc, ctx) { + const { erg, jetzt, now } = ctx + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo) + const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist + const aktion = entscheide(frisch, lage, now) + const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr + + switch (aktion) { + case 'ende': { + const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null }) + erg.aboEnde += 1 + return { aktion: 'k26', doc: neu, lage } + } + case 'k26': + case 'k27': + return { aktion, doc: frisch, lage } + case 'abschalten': { + const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) }) + if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller + erg.abgeschaltet += 1 + // ab jetzt gilt der Marker der K27, nicht mehr der der K26 + return { aktion: 'k27', doc: await updateDocument(portalFeatures(), frisch.$id, { aboEndeMailAt: null }), lage } + } + case 'loeschen': + await loesche(frisch, ctx) + return null + case 'zurueck': { + // Löschtag erreicht und die Daten sind schon weg (ein früherer Lauf wurde nach dem Löschen unterbrochen): nichts einzuschalten + if (heute(now) >= loeschTag(frisch) && !(await datenDa(frisch.$id))) { + await loesche(frisch, ctx) + return null + } + const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`) + const zurueck = frisch.activatedAt + ? await activate(frisch.$id, { adminNote: note }) + : await updateDocument(portalFeatures(), frisch.$id, { + ...stateFields('setup'), + disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, + aboEndeAt: null, + behaltenSessionId: '', + adminNote: note, + }) + erg.wiederEin += 1 + return { aktion, doc: zurueck, lage, hatteMail } + } + case 'zurueck_ohne_neubau': { + const zurueck = await updateDocument(portalFeatures(), frisch.$id, { + aboEndeAt: null, + aboEndeMailAt: null, + behaltenSessionId: '', + adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_OHNE_NEUBAU}`), + }) + erg.wiederEin += 1 + return { aktion, doc: zurueck, lage, hatteMail } + } + default: + return null + } +} + +/** Das Projekt für Neubau und Mailtext; nicht lesbar oder gelöscht ergibt null (der Neubau meldet dann 'manuell', der Name fehlt). */ +async function ladeProjekt(projectId) { + try { + return await getDocument(config.collections.websiteProjects, projectId) + } catch (err) { + if (err?.status !== 404) console.error(`[zeiten-abo] Projekt ${projectId} nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + return null + } +} +const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || '' +const preis = () => config.features.zeiten.priceLabel + +/** K26 oder K27 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt). */ +async function sendeMitMarker(doc, bauen, ctx) { + const customer = await customerOf(doc.customerId) + const projekt = projektName(await ladeProjekt(doc.projectId)) + const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt })) + if (sent) ctx.erg.mails += 1 + else if (reason !== 'keine_adresse') { + ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach + return + } + await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: ctx.jetzt }) +} + +/** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */ +async function sendeWiederMail(doc, einbau, ctx) { + const customer = await customerOf(doc.customerId) + const projekt = projektName(await ladeProjekt(doc.projectId)) + const { sent, reason } = await sendeZeitenMail(customer, k24Mail({ kunde: customer, projekt, einbau, wieder: true })) + if (sent) ctx.erg.mails += 1 + else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 +} + +/** + * Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K26 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23, + * nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K27 mit Marker; nach der Rückkehr aus 'disabled' der Neubau + * und K24 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K24 "wieder" mit einbau null. + */ +async function danach({ aktion, doc, lage, hatteMail }, ctx) { + switch (aktion) { + case 'k26': + await sendeMitMarker(doc, ({ kunde, projekt }) => k26Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx) + return + case 'k27': + if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') ctx.erg.fehler += 1 + await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx) + return + case 'zurueck': { + const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) + try { + await updateDocument(portalFeatures(), doc.$id, { einbau }) + } catch (err) { + console.error(`[zeiten-abo] einbau für Funktion ${doc.$id} nicht gespeichert: ${fehlerText(err)}`) + } + if (hatteMail) await sendeWiederMail(doc, einbau, ctx) + return + } + case 'zurueck_ohne_neubau': + if (hatteMail) await sendeWiederMail(doc, null, ctx) + return + default: + } +} + +/** Ein Dokument: unter der Sperre schreiben, danach die Folgen. Wirft bei Fehlern (der Aufrufer zählt fehler). */ +async function bearbeiteDokument(doc, ctx) { + const folge = await withLocks([`feature-admin:${doc.projectId}`], () => unterSperre(doc, ctx)) + if (folge) await danach(folge, ctx) +} + +async function bearbeiteKunde(customerId, dokumente, ctx) { + const { erg, now } = ctx + const gekoppelt = dokumente.filter(istGekoppelt) + if (gekoppelt.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(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`) + return + } + erg.geprueft += gekoppelt.length + + for (const doc of gekoppelt) { + // je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf + try { + if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage + await bearbeiteDokument(doc, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) + } + } +} + +/** + * Täglicher Abgleich von Öffnungszeiten live 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 Zeitpunkt des Laufs (die Tage zählen nach dem Berliner Kalendertag von now) + * @returns {Promise<{ geprueft: number, aboEnde: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>} + * geprueft = ans Abo gekoppelte Dokumente (unlockedBy abo, setup/active oder abgeschaltet mit Grund abo_ende), deren Kunde lesbar war; + * aboEnde = in diesem Lauf als beendet erkannt (Schritt 1); abgeschaltet = in diesem Lauf abgeschaltet (2); wiederEin = in diesem Lauf + * zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K24 "wieder", K26 und + * K27; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets + */ +export async function gleicheZeitenAboAb(now) { + pruefeNow(now, 'gleicheZeitenAboAb') + const erg = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 } + try { + let dokumente + try { + dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.notEqual('state', 'off')]) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-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 gälte "kein Abo": nie abschalten oder löschen, was sich nicht zuordnen lässt + if (unlockedByVon(doc) === 'abo') { + erg.fehler += 1 + console.error(`[zeiten-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, jetzt: now.toISOString(), memo: aboMemo() } + for (const [customerId, liste] of nachKunde) { + try { + await bearbeiteKunde(customerId, liste, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`) + } + } + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-abo] Abgleich abgebrochen: ${fehlerText(err)}`) + } + return erg +} diff --git a/server/services/zeiten/behalten.js b/server/services/zeiten/behalten.js index 219cfe1..4fc1fb0 100644 --- a/server/services/zeiten/behalten.js +++ b/server/services/zeiten/behalten.js @@ -1,4 +1,5 @@ import { config } from '../../config.js' +import { aboLage } from '../aboStatus.js' import { getDocument, updateDocument } from '../appwriteAdmin.js' import { featureConfig } from '../featureRegistry.js' import { withLocks } from '../keyedLock.js' @@ -29,6 +30,8 @@ const KEY = 'zeiten' const konflikt = (text) => Object.assign(new Error(text), { status: 409 }) const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.' +const ABO_LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.' +const ABO_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' const ZAHLUNG_LAEUFT = 'Deine Zahlung wird gerade bestätigt. Bitte schau in einigen Minuten noch einmal vorbei.' const STRIPE_NICHT_ERREICHBAR = 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' @@ -48,6 +51,22 @@ async function behaltenMoeglich(doc) { return false } +/** + * Läuft das Wartungs-Abo wieder, ist die Funktion darin enthalten und der Abo-Abgleich nimmt das Abo-Ende zurück (zeiten/aboAbgleich.js): + * in der Zeit dazwischen soll niemand 30 € für etwas bezahlen, das ohnehin enthalten ist (Ruling R25). Frisch gelesen (aboLage direkt, nie + * ein Zwischenspeicher); ein Lesefehler ist 503 (fail-closed: unklar, ob das Abo läuft), im Log stehen nur Art und Status. + */ +async function pruefeAboLaeuftNicht(customerId) { + let lage + try { + lage = await aboLage(customerId) + } catch (err) { + console.error(`[zeiten] Behalten: Wartungs-Abo nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + throw Object.assign(new Error(ABO_NICHT_PRUEFBAR), { status: 503 }) + } + if (lage.laeuft === true) throw konflikt(ABO_LAEUFT) +} + /** Die gespeicherte Behalten-Sitzung bei Stripe: offen => { url }; abgelaufen oder unbekannt => null (neue Sitzung); sonst 409 bzw. 503. */ async function offeneSitzung(sessionId) { let session @@ -73,7 +92,7 @@ async function offeneSitzung(sessionId) { * @param {{ customer: { $id: string }, projectId: string, nachweis: (sessionId: string) => Promise, now?: Date }} p * customer: Kundendokument; nachweis: speichert den Zustimmungsnachweis zur Stripe-Sitzung; now: Zeit des Klicks (Metadaten consentAt) * @returns {Promise<{ url: string }>} - * @throws {Error} 409 (nicht moeglich, Zahlung laeuft), 503 (Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis + * @throws {Error} 409 (nicht moeglich, Wartungs-Abo laeuft wieder, Zahlung laeuft), 503 (Abo oder Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis */ export async function starteBehalten({ customer, projectId, nachweis, now = new Date() } = {}) { if (typeof customer?.$id !== 'string' || customer.$id === '') throw new TypeError('starteBehalten: customer fehlt') @@ -84,6 +103,8 @@ export async function starteBehalten({ customer, projectId, nachweis, now = new return withLocks([`feature-order:${customerId}:${projectId}`], async () => { const doc = await getFeatureDoc(customerId, projectId, KEY) if (!(await behaltenMoeglich(doc))) throw konflikt(NICHT_MOEGLICH) + // läuft das Abo wieder (der Abgleich hat das Abo-Ende noch nicht zurückgenommen), gibt es weder eine neue noch die alte Zahlungsseite + await pruefeAboLaeuftNicht(customerId) // eine schon begonnene Zahlung: dieselbe Seite nochmal, nie eine zweite Sitzung daneben if (doc.behaltenSessionId) { diff --git a/server/services/zeiten/mails.js b/server/services/zeiten/mails.js index 94966fd..07b7f3f 100644 --- a/server/services/zeiten/mails.js +++ b/server/services/zeiten/mails.js @@ -39,12 +39,16 @@ function datum(wert, feld) { * @param {{ kunde: string | { name?: string }, projekt: string, einbau?: string | null, wieder?: boolean, ticketNr?: string | number | null }} p * einbau: 'angestossen' (Neubau läuft: Leisten erscheinen nach dem nächsten Veröffentlichen) oder 'manuell' (das Team baut ein); * alles andere, auch ein fehlender Wert, sagt das Vorsichtige: das Team baut ein. + * Ausnahme: einbau null zusammen mit wieder = es gab keinen Neubau (das Abo kehrte vor dem Abschalten zurück, die Leisten waren nie weg): + * beide Einbau-Blöcke und der ganze Kasten „Wie geht es weiter?“ (Block weiter) entfallen. * wieder: das Wartungs-Abo läuft wieder und die Funktion wurde automatisch wieder eingeschaltet. * ticketNr: Nummer des Einbau-Tickets; ohne sie steht kein Ticket in der Mail. */ export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = null }) { const nr = String(ticketNr ?? '').trim().replace(/^#/, '') + const keinNeubau = wieder === true && einbau === null const angestossen = einbau === 'angestossen' + const manuell = !keinNeubau && !angestossen return { template: 'K24', values: { @@ -54,7 +58,7 @@ export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = nul ticket: nr ? ` (Ticket #${nr})` : '', portal_url: portalUrl(), }, - blocks: { angestossen, manuell: !angestossen, wieder: Boolean(wieder) }, + blocks: { angestossen, manuell, wieder: Boolean(wieder), weiter: angestossen || manuell }, } } diff --git a/test/zeitenAboAbgleich.test.js b/test/zeitenAboAbgleich.test.js new file mode 100644 index 0000000..c516ea6 --- /dev/null +++ b/test/zeitenAboAbgleich.test.js @@ -0,0 +1,830 @@ +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' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Abo-Abgleich von Öffnungszeiten live (Aufgabe 21, Spec 5 "Abo-Abgleich" Schritte 1 bis 4 und Z11): gleicheZeitenAboAb(now) koppelt die im +// Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K26), nach 30 Tagen abschalten (K27, Ersatz-Ticket), +// bei Rückkehr des Abos wieder einschalten (K24 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen. +// Aufbau wie termineAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit JETZT; Tage zählen nach Berliner Kalendertagen. +// Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null) +const DATETIMES = { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], +} +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: DATETIMES, + 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 { activate } = await import('../server/services/siteFeatures.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js') +const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js') +const { heute, plusTage, uhrzeit, zuUtc } = await import('../server/services/termine/zeit.js') +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (Sommerzeit) +const KUNDE = 'c1' +const ZUVOR = '2026-09-20T10:00:00.000Z' +const BASIS_VORHER = config.publicBaseUrl +const originalRebuild = zeitenDeps.rebuild + +const ms = (wert) => new Date(wert).getTime() +/** Der Tag `tage` Kalendertage nach basis, zur selben Berliner Uhrzeit (nie Vielfache von 24 Stunden: am 25.10. endet die Sommerzeit). */ +const nach = (tage, basis = JETZT) => zuUtc(plusTage(heute(basis), tage), uhrzeit(basis)) + +let sink +let log +let rebuilds +let sperrenBeimVersand + +/** Ist die Sperre gerade frei? Eine gehaltene Sperre lässt die Probe warten: nach 60 ms gilt sie als gehalten. */ +const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))]) + +/** Alle Dokumente der Tests entfernen (auch mitten im Test, für eine zweite Runde). */ +async function raeumeAuf() { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects', 'workorders']) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) + } + resetZeitenCacheForTests() + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +} + +beforeEach(async () => { + await raeumeAuf() + aw.resetListCalls() + resetZeitenCacheForTests() + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + // Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (die Mails gehen erst nach der Sperre raus) + sperrenBeimVersand = [] + const roh = sink.sendMail.bind(sink) + sink.sendMail = async (mail) => { + sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1') }) + return roh(mail) + } + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (projekt) => { + rebuilds.push({ projektId: projekt.$id, state: aw.docs('portalFeatures').get(`f_${projekt.$id}_zeiten`)?.state, adminFrei: await sperreFrei(`feature-admin:${projekt.$id}`) }) + return { queued: true } + } + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) } +}) +afterEach(() => { + // Das Log enthält nie Adresse oder Namen des Kunden + const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') + mock.timers.reset() + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild + 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${nr}` +const funktionsId = (nr = 1) => `f_p${nr}_zeiten` +const funktion = (nr = 1) => aw.docs('portalFeatures').get(funktionsId(nr)) + +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } + return t +} +const EIN_PLAN = () => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) + +/** + * Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K24 gesendet, + * Zeiten gespeichert (daten, ersatzHash leer). Das Projekt "Praxis Muster" und das Einbau-Ticket legt die Hilfe mit an. + */ +async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxis Muster', zeiten = true } = {}) { + aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt, subdomain: `praxis${nr}`, repoFullName: `JUSN/praxis${nr}`, previewMode: 'auto', previewDetected: 'static' }) + aw.seed('workorders', { $id: `t_${nr}`, woid: `WO-${4710 + nr}`, status: 'Closed', topic: 'Öffnungszeiten live einbauen' }) + const doc = aw.seed('portalFeatures', { + $id: funktionsId(nr), + customerId: kunde, + projectId: projektId(nr), + featureKey: 'zeiten', + state: 'active', + enabled: true, + unlockedBy: 'abo', + unlockedByPurchase: false, + siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`, + ticketId: `t_${nr}`, + einbau: 'angestossen', + k24At: ZUVOR, + activatedAt: ZUVOR, + ...over, + }) + if (zeiten) await speichereZeiten({ feature: funktion(nr), eingabe: EIN_PLAN(), version: 0, now: new Date(ZUVOR) }) + return doc +} + +/** Wartungs-Abo (ein Abo-Dokument je Kunde; ein zweiter Aufruf ersetzt es). */ +const wartung = (status, over = {}, kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...wartungDoc, customerId: kunde, status, ...over })) +const hosting = (status = 'active', kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...hostingDoc, customerId: kunde, status })) +const abgelaufen = () => wartung('canceled', { endedAt: ZUVOR, canceledAt: ZUVOR }) +const laeuft = () => wartung('active') + +/** Der Abgleich läuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() und activate() (new Date()) denselben Zeitpunkt schreiben. */ +async function laufe(now = JETZT) { + mock.timers.enable({ apis: ['Date'], now: now.getTime() }) + try { + return await gleicheZeitenAboAb(now) + } finally { + mock.timers.reset() + } +} + +const ERGEBNIS_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 } +const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) + +const K26 = /^Dein Wartungs-Abo ist beendet/ +const K27 = 'Öffnungszeiten live ist abgeschaltet' +const K24 = 'Öffnungszeiten live ist eingeschaltet' +const mails = () => sink.sent +const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff)) +const tickets = () => [...aw.docs('workorders').values()] +const ersatzTickets = () => tickets().filter((t) => String(t.topic).startsWith('Ersatz-Tabelle aktualisieren')) +const abfragenAufAbos = () => aw.listCalls('subscriptions').length + +/** Die Zustände eines Dokuments, die der Abgleich bewegt (für Vergleiche "unverändert"). */ +const stand = (nr = 1) => structuredClone({ ...funktion(nr) }) + +/** + * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, läuft 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) + +/** Die volle Folge bis zum Abschalten: Tag 0 Abo-Ende erkannt (K26), Tag 30 abgeschaltet (K27). */ +async function bisAbgeschaltet() { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + await laufe(nach(30)) + assert.equal(funktion().state, 'disabled') +} + +// ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K26 + +test('Abo endet: aboEndeAt gesetzt, K26 einmal', async () => { + await legeFunktion(1) + abgelaufen() + + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'active', 'die Funktion läuft bis zur Frist weiter') + assert.equal(doc.enabled, true) + assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung') + assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K26 erst nach dem Versand') + assert.equal(doc.unlockedBy, 'abo') + + assert.equal(mails().length, 1) + const mail = mails()[0] + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.match(mail.subject, K26) + assert.match(mail.text, /Silke Müller/) + assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet') + assert.match(mail.text, /Läuft weiter bis: 04\.11\.2026/, 'Frist = Tag der Erkennung + 30 Tage') + assert.match(mail.text, /einmalig 30 €/) + assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Behalten-Link zum Editor') + assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') + assert.equal(rebuilds.length, 0) + + // am selben Tag und am Folgetag: nichts mehr, keine zweite Mail, aboEndeAt bleibt (die Frist wird nie verschoben) + const vorher = stand() + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 })) + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 })) + assert.deepEqual(stand(), vorher) + assert.equal(mails().length, 1) +}) + +test('Läuft das Abo, geschieht nichts: kein Schreiben, keine Mail', async () => { + await legeFunktion(1) + laeuft() + const vorher = stand() + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.deepEqual(stand(), vorher) + assert.equal(mails().length, 0) +}) + +test('K26-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => { + const varianten = [ + ['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/], + [ + 'Wechsel widerrufen (der Spiegel sagt noch "Wartung läuft")', + () => { + wartung('active') + aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: `sub_${KUNDE}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' }) + }, + /Du hast den Wechsel in das Wartungs-Abo widerrufen/, + /Dein Wartungs-Abo ist beendet\./, + ], + ] + for (const [name, abo, passt, passtNicht] of varianten) { + await raeumeAuf() + sink.sent.length = 0 + await legeFunktion(1) + abo() + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name) + assert.match(mails()[0].subject, K26, name) + assert.match(mails()[0].text, passt, name) + assert.doesNotMatch(mails()[0].text, passtNicht, name) + assert.equal(funktion().state, 'active', `${name}: die Frist läuft`) + } +}) + +test('K26 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => { + await legeFunktion(1) + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' }) + abgelaufen() + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1 })) + assert.equal(mails().length, 0) + assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'es gibt nichts zu senden, also auch nichts zu wiederholen') + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 })) +}) + +test('K26 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => { + await legeFunktion(1) + abgelaufen() + const roh = sink.sendMail + sink.sendMail = async () => { + throw new Error('SMTP down für silke@example.com') + } + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, fehler: 1 })) + assert.equal(ms(funktion().aboEndeAt), JETZT.getTime()) + assert.ok(!funktion().aboEndeMailAt, 'ohne Versand kein Marker') + assert.equal(mails().length, 0) + + sink.sendMail = roh + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mailsMit(K26).length, 1) + assert.equal(ms(funktion().aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt: die Frist wächst nicht') + assert.match(mails()[0].text, /Läuft weiter bis: 04\.11\.2026/, 'dieselbe Frist wie beim ersten Versuch') + assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 1) +}) + +// ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K27, Ersatz-Ticket + +test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K27, Ersatz-Ticket', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + mails().length = 0 + + const tag29 = await laufe(nach(29)) + assert.deepEqual(tag29, ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mails().length, 0) + + const tag30 = nach(30) + assert.equal(heute(tag30), '2026-11-04') + assert.deepEqual(await laufe(tag30), 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), tag30.getTime()) + assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen') + assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K27') + assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/) + + // K27: Datum der Löschung = Abschalten + 30 Tage + assert.equal(mailsMit(K27).length, 1) + const mail = mailsMit(K27)[0] + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.match(mail.text, /Eingaben gespeichert bis: 04\.12\.2026/) + assert.match(mail.text, /30 €/) + assert.ok(mail.bcc?.length) + + // Ersatz-Ticket (R23: ohne gemerkten Hash gilt der Stand als unbekannt, Zeiten sind gespeichert) + assert.equal(ersatzTickets().length, 1) + assert.match(ersatzTickets()[0].topic, /Praxis Muster/) + assert.ok((await ladeZeiten(funktionsId(1))).ersatzHash, 'der Hash wurde nach dem Ticket gemerkt') + assert.equal(rebuilds.length, 0) + + // Folgetag: nichts mehr (kein zweites Ticket, keine zweite Mail, disabledAt bleibt) + const vorher = stand() + assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 })) + assert.deepEqual(stand(), vorher) + assert.equal(ersatzTickets().length, 1) + assert.equal(mails().length, 1) +}) + +test('Frist nach Berliner Kalendertagen, nicht nach 24-Stunden-Schritten (Ende der Sommerzeit 25.10.)', async () => { + // Abo-Ende erkannt am 24.10. um 23:30 Berliner Zeit (21:30 UTC); Frist = 23.11. + const erkannt = new Date('2026-10-24T21:30:00.000Z') + await legeFunktion(1, { aboEndeAt: erkannt.toISOString(), aboEndeMailAt: erkannt.toISOString() }) + abgelaufen() + // 22.11., 23:59 Berliner Zeit: noch ein Tag + assert.deepEqual(await laufe(new Date('2026-11-22T22:59:00.000Z')), ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + // 23.11., 00:01 Berliner Zeit: der Tag der Frist ist da, auch vor 23:30 (24 x 30 Stunden später wäre erst 22:30 am 23.11.) + assert.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(funktion().state, 'disabled') + assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/) +}) + +test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + laeuft() + assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(ersatzTickets().length, 0) + assert.equal(mailsMit(K27).length, 0) +}) + +test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + const roh = sink.sendMail + sink.sendMail = async () => { + throw new Error('SMTP down') + } + assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.ok(!funktion().aboEndeMailAt, 'der Marker der K26 gilt nicht für die K27') + assert.equal(ersatzTickets().length, 1) + + sink.sendMail = roh + assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mailsMit(K27).length, 1) + assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute') + assert.equal(ersatzTickets().length, 1) + assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) +}) + +test('Ersatz-Ticket scheitert: abgeschaltet und K27 gehen trotzdem durch, fehler zählt', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) + const erg = await laufe(nach(30)) + assert.equal(funktion().state, 'disabled') + assert.equal(erg.abgeschaltet, 1) + assert.equal(erg.mails, 1) + assert.equal(erg.fehler, 1) + assert.equal(ersatzTickets().length, 0) +}) + +// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos + +test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', async () => { + await bisAbgeschaltet() + mails().length = 0 + laeuft() + const tag45 = nach(45) + + assert.deepEqual(await laufe(tag45), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'active', 'das Ticket war geschlossen (activatedAt)') + assert.equal(doc.enabled, true) + assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, '']) + assert.equal(doc.unlockedBy, 'abo') + assert.equal(doc.einbau, 'angestossen', 'Ergebnis des neuen Neubaus') + assert.match(doc.adminNote, /Wieder eingeschaltet: Wartungs-Abo läuft wieder/) + + // Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre + assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }]) + // K24 mit dem Block wieder und dem Neubau-Text + assert.equal(mailsMit(K24).length, 1) + const mail = mailsMit(K24)[0] + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) + assert.match(mail.text, /nach dem nächsten Veröffentlichen/) + assert.doesNotMatch(mail.text, /\(Ticket #/, 'kein neues Ticket, keine Ticketnummer') + // die Daten sind da (nie angefasst) und danach ist wieder Ruhe + assert.ok((await ladeZeiten(funktionsId(1))).daten) + assert.deepEqual(await laufe(nach(46)), ergebnis({ geprueft: 1 })) + assert.equal(rebuilds.length, 1) +}) + +test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedAt) kommt auf setup zurück; einbau manuell sagt K24 "das Team baut ein"', async () => { + await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null }) + abgelaufen() + await laufe(JETZT) + await laufe(nach(30)) + assert.equal(funktion().state, 'disabled') + mails().length = 0 + zeitenDeps.rebuild = async () => ({ queued: false }) + laeuft() + + assert.deepEqual(await laufe(nach(40)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'setup') + assert.equal(doc.enabled, false) + assert.equal(doc.einbau, 'manuell') + assert.equal(doc.ticketId, 't_1', 'das Einbau-Ticket bleibt, der 5-Minuten-Job gibt frei, sobald es zu ist') + assert.match(mails()[0].text, /baut unser Team/) +}) + +test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K24 wieder ohne "Wie geht es weiter"', async () => { + for (const [name, over] of [ + ['aktiv', {}], + ['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }], + ]) { + await raeumeAuf() + mails().length = 0 + rebuilds.length = 0 + await legeFunktion(1, over, { zeiten: false }) + abgelaufen() + await laufe(JETZT) + // eine offene Behalten-Zahlung (Sitzung) ist mit der Rückkehr hinfällig + aw.seed('portalFeatures', { ...funktion(1), behaltenSessionId: 'cs_test_offen' }) + assert.equal(funktion().behaltenSessionId, 'cs_test_offen', name) + mails().length = 0 + laeuft() + + assert.deepEqual(await laufe(nach(10)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), name) + const doc = funktion() + assert.equal(doc.state, over.state ?? 'active', `${name}: Zustand bleibt`) + assert.deepEqual([doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, ''], `${name}: aboEndeAt, Marker und Sitzung geleert`) + assert.equal(doc.einbau, 'angestossen', name) + assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`) + + assert.equal(mailsMit(K24).length, 1, name) + const text = mailsMit(K24)[0].text + assert.match(text, /Dein Wartungs-Abo läuft wieder/, name) + assert.doesNotMatch(text, /Wie geht es weiter/, `${name}: beide Blöcke aus, der Kasten entfällt`) + assert.doesNotMatch(text, /nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten/, name) + } +}) + +test('Rückkehr ohne vorherige K26 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => { + await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' }) + laeuft() + assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 })) + assert.equal(mails().length, 0) + assert.deepEqual([funktion().aboEndeAt ?? null, funktion().behaltenSessionId ?? ''], [null, '']) +}) + +test('Rückkehr: das Abo kommt zwischen Vorauswahl und Sperre zurück ⇒ der frisch gelesene Stand entscheidet, nicht abgeschaltet', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + mails().length = 0 + const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30))) + assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(mailsMit(K27).length, 0) + assert.equal(mailsMit(K24).length, 1) + assert.equal(ersatzTickets().length, 0) +}) + +test('Rückkehr am Tag der Löschung, Daten noch da: die Rückkehr gewinnt vor der Löschung', async () => { + await bisAbgeschaltet() + laeuft() + assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) + assert.equal(funktion().state, 'active') + assert.ok((await ladeZeiten(funktionsId(1))).daten, 'Daten erhalten') +}) + +test('Löschung fällig, aber die Daten sind schon weg (Lauf nach dem Löschen unterbrochen) und das Abo läuft: nicht wieder einschalten, sondern abschliessen', async () => { + await bisAbgeschaltet() + await deleteDocument('oeffnungszeiten', funktionsId(1)) + laeuft() + assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 })) + assert.equal(funktion().state, 'off') + assert.equal(rebuilds.length, 0) + assert.equal(mailsMit(K24).length, 0) +}) + +// ---------------------------------------------------------------- Schritt 4: Löschung + +test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht, state off', async () => { + await bisAbgeschaltet() + const feature = funktion() + aw.seed('zeitenErinnerungen', { $id: 'm1', featureId: feature.$id, datum: '2026-11-10', createdAt: JETZT.toISOString() }) + aw.seed('zeitenErinnerungen', { $id: 'm2', featureId: 'f_andere_zeiten', datum: '2026-11-10', createdAt: JETZT.toISOString() }) + mails().length = 0 + const tag59 = nach(59) + assert.equal(heute(tag59), '2026-12-03') + assert.deepEqual(await laufe(tag59), ergebnis({ geprueft: 1 }), 'Tag 59 nach dem Abo-Ende: noch nichts') + assert.ok(await ladeZeiten(feature.$id)) + + const tag60 = nach(60) + assert.equal(heute(tag60), '2026-12-04', '30 Tage nach der Abschaltung am 04.11.') + assert.deepEqual(await laufe(tag60), ergebnis({ geprueft: 1, geloescht: 1 })) + const doc = funktion() + assert.equal(doc.state, 'off') + assert.equal(doc.enabled, false) + assert.deepEqual( + [doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], + ['', '', null, null, ''], + 'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId geleert' + ) + assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/) + assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht') + assert.equal(aw.docs('oeffnungszeiten').size, 0) + assert.equal(aw.docs('zeitenErinnerungen').has('m1'), false, 'Erinnerungsmerker der Funktion gelöscht') + assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben') + assert.equal(mails().length, 0, 'die K27 hat die Löschung angekündigt, es gibt keine weitere Mail') + + // aus ist aus: der nächste Lauf liest das Dokument nicht mehr + assert.deepEqual(await laufe(nach(61)), ergebnis()) +}) + +test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => { + await bisAbgeschaltet() + await laufe(nach(60)) + assert.equal(funktion().state, 'off') + assert.equal(await ladeZeiten(funktionsId(1)), null, 'ersatzHash lebte im gelöschten Dokument') + const ticketsVorher = tickets().length + mails().length = 0 + rebuilds.length = 0 + + // Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K24 + laeuft() + const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) }) + assert.equal(changed, true) + assert.equal(doc.state, 'setup') + assert.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket') + assert.equal(tickets().length, ticketsVorher + 1) + assert.equal(doc.einbau, 'angestossen') + assert.ok(doc.k24At, 'K24 neu gesendet') + assert.equal(mailsMit(K24).length, 1) + assert.equal(rebuilds.length, 1) +}) + +test('Löschung scheitert (Zeiten-Dokument nicht löschbar): state bleibt disabled, fehler zählt, der nächste Lauf versucht es wieder', async () => { + await bisAbgeschaltet() + aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'Löschen kaputt' }) + assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.deepEqual(await laufe(nach(61)), ergebnis({ geprueft: 1, geloescht: 1 })) + assert.equal(funktion().state, 'off') +}) + +test('Abo-Lesefehler vor der Löschung: es wird nie gelöscht', async () => { + await bisAbgeschaltet() + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(nach(60)), ergebnis({ fehler: 1 })) + assert.equal(funktion().state, 'disabled') + assert.ok(await ladeZeiten(funktionsId(1))) +}) + +// ---------------------------------------------------------------- Nur unlockedBy abo + +test('gekaufte und Team-Freischaltungen bleiben unberührt (das Abo wird nicht einmal gelesen)', async () => { + await legeFunktion(1, { unlockedBy: 'kauf', unlockedByPurchase: true, purchaseStatus: 'paid' }) + await legeFunktion(2, { unlockedBy: 'team' }) + await legeFunktion(3, { unlockedBy: undefined, unlockedByPurchase: true, purchaseStatus: 'paid' }) // Altbestand ohne unlockedBy: Kauf + await legeFunktion(4, { unlockedBy: undefined }) // Altbestand ohne unlockedBy und ohne Kauf: Team + await legeFunktion(5, { state: 'disabled', enabled: false, unlockedBy: 'kauf', unlockedByPurchase: true, disabledReason: 'abo_ende', disabledAt: '2026-08-01T08:00:00.000Z' }) + await legeFunktion(6, { state: 'setup', enabled: false, unlockedBy: 'team', aboEndeAt: ZUVOR }) + abgelaufen() + const vorher = [1, 2, 3, 4, 5, 6].map((nr) => stand(nr)) + aw.resetListCalls() + + for (const now of [JETZT, nach(40), nach(100)]) assert.deepEqual(await laufe(now), ergebnis()) + assert.deepEqual([1, 2, 3, 4, 5, 6].map((nr) => stand(nr)), vorher) + assert.equal(abfragenAufAbos(), 0, 'kein gekoppeltes Dokument: keine Abo-Abfrage') + assert.equal(mails().length, 0) + assert.equal(ersatzTickets().length, 0) + assert.equal(rebuilds.length, 0) + assert.equal(aw.docs('oeffnungszeiten').size, 6) +}) + +test('News und Terminplaner (andere featureKey) und Entwürfe (off) bleiben unberührt', async () => { + await legeFunktion(1, { state: 'off', enabled: false }) + aw.seed('portalFeatures', { $id: 'f_p9_termine', customerId: KUNDE, projectId: 'p9', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoz' }) + aw.seed('portalFeatures', { $id: 'f_p9_news', customerId: KUNDE, projectId: 'p9', featureKey: 'news', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoy' }) + abgelaufen() + const vorher = [funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })) + assert.deepEqual(await laufe(nach(40)), ergebnis()) + assert.deepEqual([funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })), vorher) + assert.equal(abfragenAufAbos(), 0) +}) + +test('Grund team (und alle anderen Gründe ausser abo_ende) wird nie gelöscht', async () => { + const alt = '2026-06-01T08:00:00.000Z' + await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: alt, aboEndeAt: alt }) + await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: alt }) + await legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: alt }) + await legeFunktion(4, { state: 'disabled', enabled: false, disabledAt: alt }) // Altbestand ohne Grund + abgelaufen() + const vorher = [1, 2, 3, 4].map((nr) => stand(nr)) + assert.deepEqual(await laufe(nach(200)), ergebnis()) + assert.deepEqual([1, 2, 3, 4].map((nr) => stand(nr)), vorher) + assert.equal(aw.docs('oeffnungszeiten').size, 4, 'alle Daten bleiben') + // auch bei zurückgekehrtem Abo bleibt die Team-Abschaltung abgeschaltet + laeuft() + assert.deepEqual(await laufe(nach(201)), ergebnis()) + assert.equal(funktion(1).state, 'disabled') + assert.equal(mails().length, 0) +}) + +// ---------------------------------------------------------------- Fehler und Fail-closed + +test('Abo-Lesefehler: überspringen, nie abschalten (der andere Kunde ist trotzdem dran, der nächste Lauf holt es nach)', async () => { + // c1: Frist verstrichen, würde abgeschaltet; c2: Löschtag erreicht, würde gelöscht + await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) + await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }, { kunde: 'c2' }) + aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) + wartung('canceled', { endedAt: ZUVOR }) + wartung('canceled', { endedAt: ZUVOR }, 'c2') + const vorher = [stand(1), stand(2)] + + // das Lesen des Abos von c1 scheitert (Vorauswahl, der erste Abo-Abruf): nichts wird abgeschaltet und nichts gesendet + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' }) + assert.deepEqual(await laufe(nach(200)), ergebnis({ geprueft: 1, geloescht: 1, fehler: 1 })) + assert.deepEqual(stand(1), vorher[0], 'c1 unverändert') + assert.equal(funktion(2).state, 'off', 'c2 war lesbar und fällig') + assert.equal(mails().length, 0) + assert.equal(ersatzTickets().length, 0) + + // das Lesen des Abos von c2 scheitert, c1 ist lesbar: c2 bleibt, wie es ist (nie löschen, wenn unklar ist, ob das Abo zurück ist) + await raeumeAuf() + await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) + wartung('canceled', { endedAt: ZUVOR }) + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' }) + assert.deepEqual(await laufe(nach(200)), ergebnis({ fehler: 1 })) + assert.equal(funktion(1).state, 'disabled') + assert.ok(await ladeZeiten(funktionsId(1)), 'Daten erhalten') + + // der nächste Lauf mit lesbarem Abo holt es nach + assert.deepEqual(await laufe(nach(201)), ergebnis({ geprueft: 1, geloescht: 1 })) + assert.equal(funktion(1).state, 'off') +}) + +test('Abo-Lesefehler unter der Sperre (zweites Lesen): nichts wird geschrieben', async () => { + await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) + abgelaufen() + const vorher = stand() + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frisches Lesen scheitert + assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, fehler: 1 })) + assert.deepEqual(stand(), vorher) + assert.equal(mails().length, 0) + assert.equal(ersatzTickets().length, 0) +}) + +test('Liste nicht lesbar: der Abgleich entfällt, kein Wurf', async () => { + await legeFunktion(1) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Appwrite down' }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.equal(mails().length, 0) +}) + +test('ein unlesbares Datum bei einem Dokument stoppt die anderen nicht (je Dokument gefangen)', async () => { + await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) + await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { kunde: 'c2' }) + await legeFunktion(3, {}, { kunde: 'c3' }) + aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) + aw.seed('customers', { $id: 'c3', name: 'Eva Schmidt', email: 'eva@example.com' }) + for (const kunde of [KUNDE, 'c2', 'c3']) wartung('canceled', { endedAt: ZUVOR }, kunde) + // Datumsfelder, die Appwrite nie liefern würde: der Nachbau prüft sie hier nicht + const feldNamen = DATETIMES.portalFeatures + DATETIMES.portalFeatures = feldNamen.filter((f) => f !== 'aboEndeAt' && f !== 'disabledAt') + try { + aw.seed('portalFeatures', { ...funktion(1), aboEndeAt: 'kein-datum' }) + aw.seed('portalFeatures', { ...funktion(2), disabledAt: 'kein-datum' }) + } finally { + DATETIMES.portalFeatures = feldNamen + } + const vorher = [stand(1), stand(2)] + + const erg = await laufe(nach(30)) + assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K26') + assert.deepEqual([stand(1), stand(2)], vorher, 'die kaputten Dokumente bleiben, wie sie sind') + assert.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com']) + assert.equal(ersatzTickets().length, 0) + assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht') +}) + +test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K26 nach', async () => { + await legeFunktion(1) + await legeFunktion(2, {}, { kunde: 'c2' }) + aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) + for (const kunde of [KUNDE, 'c2']) wartung('canceled', { endedAt: ZUVOR }, kunde) + aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite down' }) + + assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, aboEnde: 2, mails: 1, fehler: 1 })) + assert.equal(ms(funktion(1).aboEndeAt), JETZT.getTime(), 'das Abo-Ende steht, die Frist läuft') + assert.ok(!funktion(1).aboEndeMailAt) + assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com']) + + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 2, mails: 1 })) + assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com', 'silke@example.com']) + assert.match(mails()[1].text, /Läuft weiter bis: 04\.11\.2026/, 'Frist vom ersten Lauf, nicht von heute') +}) + +test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) // K26 + await laufe(nach(30)) // K27 + laeuft() + await laufe(nach(45)) // Neubau und K24 + assert.deepEqual( + sperrenBeimVersand.map((s) => [s.subject === K27 ? 'K27' : s.subject === K24 ? 'K24' : 'K26', s.admin]), + [['K26', true], ['K27', true], ['K24', true]] + ) + assert.equal(rebuilds[0].adminFrei, true) +}) + +// ---------------------------------------------------------------- Zähler + +test('Abo-Abfragen: die Vorauswahl liest je Kunde einmal, dazu das frische Lesen je bearbeitetem Dokument; mehrere Projekte eines Kunden werden einzeln bearbeitet', async () => { + await legeFunktion(1) + await legeFunktion(2) + await legeFunktion(3, {}, { kunde: 'c2' }) + aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) + wartung('canceled', { endedAt: ZUVOR }, KUNDE) + wartung('active', {}, 'c2') + aw.resetListCalls() + assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 })) + assert.equal(abfragenAufAbos(), 2 + 2, 'zwei Kunden in der Vorauswahl; frisch nur für die zwei Dokumente von c1 (das von c2 braucht nichts)') + assert.equal(mailsMit(K26).length, 2) + assert.deepEqual(mails().map((m) => m.to[0].address), ['silke@example.com', 'silke@example.com']) + assert.ok(funktion(1).aboEndeAt && funktion(2).aboEndeAt) + assert.ok(!funktion(3).aboEndeAt) +}) + +test('now muss ein gültiges Datum sein (Programmierfehler, wie bei den Terminplaner-Jobs)', async () => { + await assert.rejects(() => gleicheZeitenAboAb(undefined), TypeError) + await assert.rejects(() => gleicheZeitenAboAb(new Date('x')), TypeError) +}) + +// ---------------------------------------------------------------- R25: activate() räumt das Abo-Ende ab + +test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Marker und disabledAt); zahlungMeldung bleibt', async () => { + await legeFunktion(1, { + state: 'disabled', + enabled: false, + disabledAt: ZUVOR, + disabledReason: 'abo_ende', + aboEndeMailAt: ZUVOR, + aboEndeAt: ZUVOR, + behaltenSessionId: 'cs_test_offen', + zahlungMeldung: 'abo_ende', + }) + const doc = await activate(funktionsId(1), { adminNote: 'Test' }) + assert.deepEqual([doc.state, doc.enabled], ['active', true]) + assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, '']) + assert.equal(doc.zahlungMeldung, 'abo_ende') + // Folge: ein abgeschalteter Lauf des Abgleichs sieht ein aktives Dokument ohne Abo-Ende + laeuft() + assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 })) +}) + +test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K27 und das Ersatz-Ticket nach', async () => { + await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR }) + abgelaufen() + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mailsMit(K27).length, 1) + assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage') + assert.equal(ersatzTickets().length, 1) + assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 1) +}) diff --git a/test/zeitenBehalten.test.js b/test/zeitenBehalten.test.js index e11dcb6..0ef1ec4 100644 --- a/test/zeitenBehalten.test.js +++ b/test/zeitenBehalten.test.js @@ -31,7 +31,7 @@ const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, post, postWebhook } = await import('./support/testApp.js') -const { customerCookie, kundenansichtCookie, seedFixtures } = await import('./support/fixtures.js') +const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { withLocks } = await import('../server/services/keyedLock.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') @@ -254,6 +254,48 @@ test('Behalten nur nach Abo-Ende, sonst 409', async () => { } }) +test('Läuft das Wartungs-Abo wieder, ist Behalten gesperrt (409, die Funktion ist enthalten); Lesefehler beim Abo ⇒ 503; beides ohne Spur (R25)', async () => { + const wartungLaeuft = (over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'active', ...over })) + const LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.' + + // aktiv mit Abo-Ende, die Zahlung hat schon begonnen (offene Sitzung): kehrt das Abo zurück, gibt es auch die alte Seite nicht mehr heraus + seedeZeiten() + const sessionId = await starteBehalten() + const davor = spuren() + wartungLaeuft() + const res = await behalten(koerper()) + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.error, LAEUFT) + assert.equal(res.body.url, undefined) + assert.deepEqual(spuren(), davor, 'keine neue Sitzung, kein Nachweis') + assert.equal(zeitenDoc().behaltenSessionId, sessionId) + + // eine vorgemerkte Kündigung zählt als laufend (bis zum Periodenende) + wartungLaeuft({ cancelAtPeriodEnd: true }) + assert.equal((await behalten(koerper())).status, 409) + + // abgeschaltet nach dem Abo-Ende, Eingaben noch da: ebenso + for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id) + seedeAbgeschaltet() + await mitZeiten() + const aus = await behalten(koerper()) + assert.equal(aus.status, 409) + assert.equal(aus.body.error, LAEUFT) + + // Abo-Lage nicht lesbar: fail-closed + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' }) + const kaputt = await behalten(koerper()) + assert.equal(kaputt.status, 503) + assert.match(kaputt.body.error, /nicht prüfbar/) + assert.equal(JSON.stringify(kaputt.body).includes('Appwrite'), false) + + // ohne laufendes Wartungs-Abo (Hosting, beendet) geht es weiter wie gehabt + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'canceled', endedAt: ABO_ENDE })) + const frei = await behalten(koerper()) + assert.equal(frei.status, 200, JSON.stringify(frei.body)) + assert.equal(sessionAufrufe().length, 2, 'die erste Sitzung und diese; die gesperrten Versuche legten keine an') +}) + test('Vorprüfungen wie bei der Bestellung: Kundenansicht 403, gleiche Zustimmung (beide Haken), Projekt, Schalter, Unterlagen; alles ohne Spur', async () => { seedeZeiten() // Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden) diff --git a/test/zeitenMails.test.js b/test/zeitenMails.test.js index ab24543..3dae0e5 100644 --- a/test/zeitenMails.test.js +++ b/test/zeitenMails.test.js @@ -43,6 +43,7 @@ const BEISPIELE = { 'K24 angestossen': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }), 'K24 manuell mit Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }), 'K24 wieder ohne Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }), + 'K24 wieder ohne Neubau': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: null, wieder: true }), 'K25 ein Tag': () => k25Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }), 'K25 mehrere mit prüfen': () => k25Mail({ @@ -61,7 +62,7 @@ const BEISPIELE = { test('Vorlagen K24–K27: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => { const erwartet = { - K24: { file: 'K24 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder'] }, + K24: { file: 'K24 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder', 'weiter'] }, K25: { file: 'K25 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] }, K26: { file: 'K26 Zeiten Abo beendet.html', subject: 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?', placeholders: ['kunde_name', 'projekt', 'frist', 'preis', 'portal_url'], blocks: ['abo_ende', 'hosting', 'wechsel_widerrufen'] }, K27: { file: 'K27 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] }, @@ -100,26 +101,48 @@ test('Anrede: Kunde als Name oder als Kundendokument, ohne Namen bleibt der Wert test('K24: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => { const auto = BEISPIELE['K24 angestossen']() assert.equal(auto.template, 'K24') - assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false }) + assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false, weiter: true }) const textAuto = render(auto).text assert.match(textAuto, /nach dem nächsten Veröffentlichen/) assert.doesNotMatch(textAuto, /baut unser Team/) assert.match(textAuto, /Ticket #100123/, 'Ticketnummer steht in beiden Fällen in der Mail') const hand = BEISPIELE['K24 manuell mit Ticket']() - assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false }) + assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true }) const textHand = render(hand).text assert.match(textHand, /baut unser Team/) assert.doesNotMatch(textHand, /nach dem nächsten Veröffentlichen/) assert.match(textHand, /Ticket #100123/) - // ohne bekannten Einbau sagt die Mail das Vorsichtige: das Team baut ein + // ohne bekannten Einbau sagt die Mail das Vorsichtige: das Team baut ein (null gilt nur mit wieder als "kein Einbau nötig", siehe unten) for (const einbau of [undefined, null, '', 'unbekannt']) { const unklar = k24Mail({ kunde: 'K', projekt: 'P', einbau }) - assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false }, String(einbau)) + assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true }, String(einbau)) } }) +test('K24 wieder ohne Neubau (einbau null, Abo kehrt vor dem Abschalten zurück): beide Einbau-Blöcke und der Kasten "Wie geht es weiter" entfallen', () => { + const ohne = BEISPIELE['K24 wieder ohne Neubau']() + assert.deepEqual(ohne.blocks, { angestossen: false, manuell: false, wieder: true, weiter: false }) + const mail = render(ohne) + assert.deepEqual(mail.problems, []) + assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) + assert.match(mail.text, /Zeiten und Einstellungen sind unverändert/) + // nichts, was nach Einbau klingt (die Leisten waren nie weg) + assert.doesNotMatch(mail.text, /Wie geht es weiter|nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten|Ticket/) + // Knopf, Projekt und Funktion bleiben + assert.match(mail.text, /Projekt: Praxis Muster/) + assert.match(mail.html, new RegExp(` { const ohne = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' }) assert.equal(ohne.values.ticket, '') @@ -129,7 +152,7 @@ test('K24: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt assert.equal(k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)') const wieder = BEISPIELE['K24 wieder ohne Ticket']() - assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true }) + assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true, weiter: true }) const mitWieder = render(wieder).text assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/) assert.match(mitWieder, /wieder eingeschaltet/)