Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist

gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten
(unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26),
nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket),
bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24
wieder), 30 Tage nach dem Abschalten Daten löschen und state off.
Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen.

R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt
bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503).
R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab,
K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 03:16:56 +00:00
1 parent 53253f0451
commit efc6dcf332
8 files changed
+1285 -10

No files matched your search

@@ -8,9 +8,10 @@
Absender: „WEBklar“ <Portal-Postfach>
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;}
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Öffnungszeiten live ist eingeschaltet</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>Öffnungszeiten live ist für Dein Projekt {{projekt}} eingeschaltet. Die Funktion ist in Deinem Wartungs-Abo ohne Aufpreis enthalten.<!-- WENN wieder --> 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.<!-- ENDE wieder --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Öffnungszeiten live</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<!-- WENN weiter -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;"><!-- WENN angestossen -->Die Hinweis-Leiste und die Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen Deiner Website, meist in wenigen Minuten. Die Zeiten-Tabelle setzt unser Team zusätzlich per Ticket an die passende Stelle Deiner Seite{{ticket}}.<!-- ENDE angestossen --><!-- WENN manuell -->Deine Website können wir nicht automatisch aktualisieren. Deshalb baut unser Team die Hinweis-Leiste, die Kontakt-Leiste und die Zeiten-Tabelle von Hand in Deine Seite ein{{ticket}}.<!-- ENDE manuell --></div><div class="mut" style="color:#4a4843;padding-top:8px;">Im Kundenportal trägst Du Deine Öffnungszeiten ein und änderst sie jederzeit. Ohne gespeicherte Zeiten kann die Tabelle nicht eingebaut werden.</div></td></tr></table></td></tr>
<!-- ENDE weiter -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zeiten bearbeiten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
+4
View File
@@ -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 } : {}),
})
}
+348
View File
@@ -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:<projectId> (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:<projectId>), 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:<projectId>: 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
}
+22 -1
View File
@@ -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<unknown>, 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) {
+5 -1
View File
@@ -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 },
}
}
+830
View File
@@ -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)
})
+43 -1
View File
@@ -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)
+29 -6
View File
@@ -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(`<a href="${PORTAL.replaceAll('.', '\\.')}"`))
// mit Neubau (angestossen oder manuell) bleibt der Kasten, auch beim Wiedereinschalten
for (const einbau of ['angestossen', 'manuell']) {
const text = render(k24Mail({ kunde: 'K', projekt: 'P', einbau, wieder: true })).text
assert.match(text, /Wie geht es weiter/, einbau)
}
// einbau null ohne wieder ist kein Fall der Mail: es bleibt beim Vorsichtigen
assert.deepEqual(k24Mail({ kunde: 'K', projekt: 'P', einbau: null }).blocks, { angestossen: false, manuell: true, wieder: false, weiter: true })
})
test('K24: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => {
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/)