diff --git a/public/widerrufen.html b/public/widerrufen.html index c8c1193..003f2e9 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -22,6 +22,7 @@ + diff --git a/server/config.js b/server/config.js index d47afd7..f727f15 100644 --- a/server/config.js +++ b/server/config.js @@ -376,9 +376,11 @@ const features = { label: { name: 'Öffnungszeiten live', kurz: 'Zeiten', keine: 'keine Öffnungszeiten live' }, priceLabel: '30 €', setupEstimate: 'meist innerhalb weniger Tage', - // Wortlaut der Zustimmung (bezahlter Weg); wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB) + // Wortlaut der Zustimmung (bezahlter Weg); wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB, + // Wertersatz § 357a Abs. 2 BGB; der Wertersatz-Satz steht wörtlich wie bei News und Terminplaner, Entwurf bis zur Rechtsrunde, ZQ3). + // Fassung 2026-10-05: mit dem Wertersatz-Satz; bestehende Bestellungen behalten Text und Fassung aus ihrem Nachweis. consent: { - version: '2026-10-04', + version: '2026-10-05', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [ { @@ -389,7 +391,7 @@ const features = { { id: 'beginn', haken: true, - text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB).', + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', }, ], }, diff --git a/server/routes/abo.js b/server/routes/abo.js index 72527c4..71a09cb 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -706,15 +706,15 @@ export function publicRateLimitState() { // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert const CONTRACTS = ['abo', 'zusatzspeicher'] -const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'zeiten'] // Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist) -const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' } +const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', zeiten: 'zeiten' } // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } /** * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. - * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'zeiten'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -788,6 +788,7 @@ function orderLabel(order, sub, { customer = false } = {}) { if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' if (order === 'termine') return 'Terminplaner (Einrichtung)' + if (order === 'zeiten') return 'Öffnungszeiten live' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` } diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 032d89a..24dd14a 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -390,7 +390,9 @@ router.post('/zeiten/einschalten', requireSession, async (req, res) => { const feature = featureConfig('zeiten') const body = req.body || {} // 2 Zustimmung: Haken und aktuelle Fassung der Gratis-Zustimmung (consentFrei) - pruefeZustimmung(freiweg(feature), body) + // nur { zustimmung }: die Altform { consent: true } (News, ohne Fassungspruefung) gilt bei dieser neuen Route nicht, sonst bestaetigte ein + // alter Tab nach einer Textaenderung den neuen Wortlaut, ohne ihn gesehen zu haben + pruefeZustimmung(freiweg(feature), { zustimmung: body.zustimmung }) // 3 Projekt const { projectId } = body if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 67becc1..189f527 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -462,11 +462,26 @@ export async function markCheckoutFailed(sessionId) { }) } +/** + * Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der + * Aktivierung. Als `extra` an activate() (nur der Zeiten-Pfad; die Felder legt scripts/zeiten-setup.mjs an, News und Terminplaner + * teilen die Collection). + */ +export const ZEITEN_AKTIVIERUNG_FELDER = Object.freeze({ aboEndeAt: null, behaltenSessionId: '' }) + function byNote(by, verb) { return by ? `${verb} von ${by}`.slice(0, 300) : undefined } -export async function activate(docId, { by, adminNote } = {}) { +/** + * Funktion aktivieren (jede Funktion gleich). `extra` sind zusaetzliche Felder, die der Aufrufer mitschreibt: Oeffnungszeiten live + * leert damit sein erkanntes Abo-Ende und eine offene Behalten-Zahlung (ZEITEN_AKTIVIERUNG_FELDER). Diese Felder legt erst + * scripts/zeiten-setup.mjs an; darum schreibt activate() sie nie von selbst, sonst scheiterte die Aktivierung von News und + * Terminplaner mit "Unknown attribute", solange der Code vor dem Skript laeuft. + * @param {string} docId + * @param {{ by?: string, adminNote?: string, extra?: Record }} [options] + */ +export async function activate(docId, { by, adminNote, extra } = {}) { const note = adminNote ?? byNote(by, 'Freigeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), @@ -477,10 +492,7 @@ 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: '', + ...(extra ?? {}), ...(note ? { adminNote: note } : {}), }) } @@ -583,7 +595,7 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId) - return activate(doc.$id, { by, adminNote }) + return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) }) } /** @@ -814,6 +826,26 @@ async function ticketNachZahlung(doc, note) { } } +/** + * Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt + * holeZeitenNach nach (Neubau, Einbau-Ticket, K24 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst + * erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen -> + * zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es). + */ +async function ticketNachHolen(doc, note) { + const { holeZeitenNach } = await import('./zeiten/einschalten.js') + const nachher = await holeZeitenNach(doc) + if (!nachher.ticketId) return { doc: nachher, created: false } + if (!note) return { doc: nachher, created: true } + try { + return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true } + } catch (err) { + // Ticket und Mail sind raus; die Notiz ist nur ein Vermerk + console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message) + return { doc: nachher, created: true } + } +} + /** * Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim * ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:), damit kein Stripe-Retry daneben @@ -835,7 +867,8 @@ async function ersetzeGeloeschtesTicket(doc, note) { /** * "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409). * (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen, - * Ticket + K15 + T16 genau einmal (ticketNachZahlung); + * Ticket + K15 + T16 genau einmal (ticketNachZahlung); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt + * holeZeitenNach Neubau, Ticket und K24 nach (ticketNachHolen); * (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket); * (c) Ticket vorhanden: nichts, created false. * Eine Sperre feature-ticket: je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten @@ -849,7 +882,7 @@ export async function ticketNachlegen({ customerId, projectId, key, by, adminNot return withLocks([`feature-ticket:${vorab.$id}`], async () => { const doc = await frischesDokument(vorab, key) if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) - if (!doc.ticketId) return ticketNachZahlung(doc, note) + if (!doc.ticketId) return key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : ticketNachZahlung(doc, note) if (await ticketBesteht(doc.ticketId)) return { doc, created: false } return ersetzeGeloeschtesTicket(doc, note) }) diff --git a/server/services/zeiten/aboAbgleich.js b/server/services/zeiten/aboAbgleich.js index 3946818..2f4e0b5 100644 --- a/server/services/zeiten/aboAbgleich.js +++ b/server/services/zeiten/aboAbgleich.js @@ -2,7 +2,7 @@ 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 { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon, ZEITEN_AKTIVIERUNG_FELDER } 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' @@ -22,8 +22,8 @@ import { ladeZeiten, loescheZeiten } from './speicher.js' * * 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 nicht, Frist erreicht Schritt 2: disabled/'abo_ende', dann das Ersatz-Ticket des Teams + * (nachAbschaltung), dann K27 (Löschdatum = Abschalten + 30 Tage) * 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) @@ -32,7 +32,7 @@ import { ladeZeiten, loescheZeiten } from './speicher.js' * 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 + * nach disabledAt k24At, activatedAt, 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 @@ -50,9 +50,11 @@ import { ladeZeiten, loescheZeiten } from './speicher.js' * 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) + * Abschalten der K27 (nur ein Marker, der nicht vor disabledAt liegt: k27Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es + * könnte scheitern). 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). Die K27 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es + * weder K27 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K27 so höchstens einmal). 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. @@ -102,7 +104,7 @@ function entscheide(doc, lage, now) { if (istAbgeschaltet(doc)) { if (lage.laeuft) return 'zurueck' if (tag >= loeschTag(doc)) return 'loeschen' - return doc.aboEndeMailAt ? null : 'k27' + return k27Gesendet(doc) ? null : 'k27' } if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null if (!doc.aboEndeAt) return 'ende' @@ -110,6 +112,13 @@ function entscheide(doc, lage, now) { return doc.aboEndeMailAt ? null : 'k26' } +/** + * Hat die K27 ihren Marker? aboEndeMailAt gilt der K27 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K26 (aus der Frist) + * ist älter als disabledAt. So hängt die K27 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte + * (scheiterte dieses Schreiben, blieb der K26-Marker stehen und K27 samt Ersatz-Ticket liefen nie). + */ +const k27Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt)) + /** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */ async function datenDa(featureId) { return (await ladeZeiten(featureId))?.daten != null @@ -123,6 +132,7 @@ async function loesche(frisch, ctx) { ticketId: '', einbau: '', k24At: null, + activatedAt: null, // ein späteres Einschalten beginnt wieder in setup (sonst käme es durch das alte activatedAt zu früh auf active) aboEndeAt: null, behaltenSessionId: '', adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`), @@ -156,8 +166,8 @@ async function unterSperre(doc, ctx) { 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 } + // ab jetzt gilt der Marker der K27, nicht mehr der der K26 (k27Gesendet: der K26-Marker ist älter als disabledAt, kein Zurücksetzen nötig) + return { aktion: 'k27', doc: aus, lage } } case 'loeschen': await loesche(frisch, ctx) @@ -170,7 +180,7 @@ async function unterSperre(doc, ctx) { } const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`) const zurueck = frisch.activatedAt - ? await activate(frisch.$id, { adminNote: note }) + ? await activate(frisch.$id, { adminNote: note, extra: ZEITEN_AKTIVIERUNG_FELDER }) : await updateDocument(portalFeatures(), frisch.$id, { ...stateFields('setup'), disabledAt: null, @@ -210,8 +220,12 @@ async function ladeProjekt(projectId) { 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) { +/** + * K26 oder K27 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt). + * `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K27: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem + * `now` des Laufs schreibt; ein früherer Marker gälte k27Gesendet nicht und die K27 ginge jeden Tag erneut raus). + */ +async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) { const customer = await customerOf(doc.customerId) const projekt = projektName(await ladeProjekt(doc.projectId)) const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt })) @@ -220,7 +234,8 @@ async function sendeMitMarker(doc, bauen, ctx) { ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach return } - await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: ctx.jetzt }) + const marker = nichtVor && zeitMs(nichtVor) > zeitMs(ctx.jetzt) ? new Date(zeitMs(nichtVor)).toISOString() : ctx.jetzt + await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: marker }) } /** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */ @@ -243,8 +258,13 @@ async function danach({ aktion, doc, lage, hatteMail }, ctx) { 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) + // Erst das Ersatz-Ticket, dann K27 mit Marker. Scheitert das Ticket, gibt es weder K27 noch Marker: der nächste Lauf wiederholt beides + // (das Ticket ist über den Hash idempotent, die K27 ging noch nicht raus und geht so höchstens einmal raus) + if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') { + ctx.erg.fehler += 1 + return + } + await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt) return case 'zurueck': { const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) diff --git a/server/services/zeiten/einschalten.js b/server/services/zeiten/einschalten.js index 7381c3e..047eca0 100644 --- a/server/services/zeiten/einschalten.js +++ b/server/services/zeiten/einschalten.js @@ -48,7 +48,7 @@ function notizDesTeams(staff, bisherige, now) { * @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null, * nachweis?: (() => Promise)|null, now?: Date }} p * by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K24 geht an den Kunden. - * by 'team': keine Abo-Pruefung, unlockedBy 'team', keine K24; staff ergibt die Notiz am Dokument. + * by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K24; staff ergibt die Notiz am Dokument. * nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert. * @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf * @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen) @@ -77,12 +77,16 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s // einbau beginnt leer, der Neubau (holeZeitenNach) setzt es neu. const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, KEY)) const notiz = by === 'team' ? notizDesTeams(staff, vorhanden.adminNote, now) : null + // Ein gekauftes Dokument (bisher kauf), das das Team wieder einschaltet, bleibt gekauft: unlockedBy/unlockedByPurchase ordnen den + // Widerruf zu (paidFeatureOrders). Neubau und Ticket kennt dort markPaid, nicht das Nachholen (holeZeitenNach laesst kauf aus): einbau + // bleibt darum stehen, statt auf leer gesetzt zu werden, ohne dass es je neu gefuellt wuerde. + const gekauft = by === 'team' && unlockedByVon(bisher) === 'kauf' const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, { ...stateFields('setup'), featureKey: KEY, - unlockedBy: by === 'kunde' ? 'abo' : 'team', - unlockedByPurchase: false, - einbau: '', + unlockedBy: gekauft ? 'kauf' : by === 'kunde' ? 'abo' : 'team', + unlockedByPurchase: gekauft, + ...(gekauft ? {} : { einbau: '' }), aboEndeAt: null, behaltenSessionId: '', disabledAt: null, diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 5f67713..619cddf 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1007,6 +1007,40 @@ test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 de } }) +test('widerruf_zeiten: „Öffnungszeiten live“ gehört zu den Zeiten (nicht zur News), Frist und Produkt in K04 und T09 stimmen', async () => { + const newsAt = daysAgo(20) + const zeitenAt = daysAgo(3) + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' }) + seedBezahlt({ projectId: 'pz1', projektName: 'Praxis Zeiten', featureKey: 'zeiten', orderedAt: zeitenAt }) + try { + await resetSubH({ units: 0 }) + const before = st.calls.length + assert.equal((await withdraw({ order: 'zeiten', customerNumber: 'KD-9' })).status, 200) + const kunde = k04() + assert.match(kunde.subject, /WEBklar Öffnungszeiten live$/) + assert.match(kunde.text, /Widerruf der Bestellung „Öffnungszeiten live“/) + assert.match(kunde.text, /Wir prüfen Ihren Widerruf/) + assert.doesNotMatch(kunde.text, /News-Pinnwand|Es erfolgen keine weiteren Abbuchungen/) + const team = t09().text + assert.ok(team.includes(`Widerruf einer Funktion (${config.features.zeiten.label.name}, ${config.features.zeiten.priceLabel} einmalig)`), team) + assert.match(team, /Öffnungszeiten live/) + assert.match(team, /RECHTZEITIG/, 'Frist der Zeiten (vor 3 Tagen), nicht die der News (vor 20 Tagen)') + assert.ok(team.includes(fmtDate(withdrawalDeadline(zeitenAt).endsAt)), 'Fristende der Zeiten') + assert.match(team, /Praxis Zeiten: in Einrichtung/) + assert.doesNotMatch(team, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl') + assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') + assert.equal(aw.docs('portalFeatures').get('f_pz1_zeiten').state, 'setup', 'nichts automatisch geändert') + + // „Funktion“ bleibt die News: ihre Frist ist abgelaufen, die jungen Zeiten retten sie nicht + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'funktion' })).status, 200) + assert.match(t09().text, /NACH Fristende/) + assert.doesNotMatch(t09().text, /Praxis Zeiten/) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pz1_zeiten') + } +}) + test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => { await resetSubH({ units: 0 }) // Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss) @@ -1279,6 +1313,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => { assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/) assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/) assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/) + assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/) assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) }) diff --git a/test/zeitenAboAbgleich.test.js b/test/zeitenAboAbgleich.test.js index c516ea6..992e44d 100644 --- a/test/zeitenAboAbgleich.test.js +++ b/test/zeitenAboAbgleich.test.js @@ -34,7 +34,7 @@ const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixt 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 { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = 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') @@ -394,7 +394,7 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste } 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.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K26 gilt nicht für die K27') assert.equal(ersatzTickets().length, 1) sink.sendMail = roh @@ -405,17 +405,72 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) }) -test('Ersatz-Ticket scheitert: abgeschaltet und K27 gehen trotzdem durch, fehler zählt', async () => { +test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K27 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K27 genau einmal', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) + mails().length = 0 aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) - const erg = await laufe(nach(30)) + assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) 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) + assert.equal(mailsMit(K27).length, 0, 'K27 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus') + assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K27-Marker') + + // der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K27 einmal + assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(ersatzTickets().length, 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') + assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K27') + assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) + assert.equal(ersatzTickets().length, 1) + assert.equal(mailsMit(K27).length, 1) +}) + +test('Ersatz-Ticket scheitert mehrere Läufe: kein K27, jeder Lauf zählt fehler; sobald es klappt, genau eine K27', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + mails().length = 0 + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) + await laufe(nach(30)) + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) + assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(mailsMit(K27).length, 0) + assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mailsMit(K27).length, 1) + assert.equal(ersatzTickets().length, 1) +}) + +test('Abschalten gelingt, die Folge scheitert (Marker der K26 steht noch, älter als disabledAt): der nächste Lauf holt K27 und das Ersatz-Ticket nach, einmal', async () => { + // So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K26 + await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR }) + abgelaufen() + assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(mailsMit(K27).length, 1) + assert.equal(ersatzTickets().length, 1) + assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt)) + assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 1) + assert.equal(ersatzTickets().length, 1) +}) + +test('Wanduhr nach dem Laufstart (disabledAt später als now): der K27-Marker liegt nie davor, K27 geht nicht täglich raus', async () => { + await legeFunktion(1) + abgelaufen() + await laufe(JETZT) + mails().length = 0 + const tag30 = nach(30) + mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs + try { + assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + } finally { + mock.timers.reset() + } + assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt') + assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 })) + assert.equal(mailsMit(K27).length, 1) }) // ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos @@ -560,9 +615,9 @@ test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht, 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' + [doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null], + ['', '', null, null, '', null], + 'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)' ) 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') @@ -809,7 +864,8 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark behaltenSessionId: 'cs_test_offen', zahlungMeldung: 'abo_ende', }) - const doc = await activate(funktionsId(1), { adminNote: 'Test' }) + // der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection) + const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER }) 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') @@ -818,6 +874,19 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 })) }) +test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => { + const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' } + await legeFunktion(1, offen) + const ohne = await activate(funktionsId(1), { adminNote: 'Test' }) + assert.equal(ohne.state, 'active') + assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen']) + + await aw.seed('portalFeatures', { ...funktion(1), ...offen }) + const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' }) + assert.equal(team.state, 'active') + assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, '']) +}) + 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() diff --git a/test/zeitenActivate.test.js b/test/zeitenActivate.test.js new file mode 100644 index 0000000..5e95615 --- /dev/null +++ b/test/zeitenActivate.test.js @@ -0,0 +1,42 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js' + +// activate() ist der gemeinsame Schreibpfad aller Funktionen (News, Terminplaner, Zeiten). Die neuen Zeiten-Felder von portalFeatures +// (unlockedBy, aboEndeAt, einbau, behaltenSessionId, k24At) legt erst scripts/zeiten-setup.mjs an: laeuft der Code vor diesem Skript, +// darf die Aktivierung von News und Terminplaner nie ein Zeiten-Feld schreiben (Appwrite: "Unknown attribute"). Der Nachbau hier hat +// darum genau das Schema OHNE die Zeiten-Felder; nur der Zeiten-Pfad (extra) schreibt sie. +const ZEITEN_FELDER = ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key) +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures').filter((k) => !ZEITEN_FELDER.includes(k)) } }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { activate, adminActivate } = await import('../server/services/siteFeatures.js') + +const ZUVOR = '2026-09-20T10:00:00.000Z' +const lesen = (id) => aw.docs('portalFeatures').get(id) + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins' }) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' }) + +for (const key of ['news', 'termine']) { + test(`activate() einer abgeschalteten Funktion (${key}) schreibt keine Zeiten-Felder und scheitert nicht ohne zeiten-setup`, async () => { + const id = `f_${key}` + aw.seed('portalFeatures', { + $id: id, customerId: 'c1', projectId: 'p1', featureKey: key, state: 'disabled', enabled: false, + disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeMailAt: ZUVOR, + }) + const doc = await activate(id, { by: 'kenso' }) + assert.deepEqual([doc.state, doc.enabled, doc.disabledAt, doc.disabledReason, doc.aboEndeMailAt], ['active', true, null, null, null]) + for (const feld of ZEITEN_FELDER) assert.equal(feld in lesen(id), false, `${feld} wurde nicht geschrieben`) + }) +} + +test('adminActivate() (News) schreibt keine Zeiten-Felder', async () => { + aw.seed('portalFeatures', { $id: 'f_admin', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false }) + const doc = await adminActivate('c1', 'p1', 'news', { by: 'kenso' }) + assert.equal(doc.state, 'active') +}) diff --git a/test/zeitenEinschalten.test.js b/test/zeitenEinschalten.test.js index 44883a9..087897c 100644 --- a/test/zeitenEinschalten.test.js +++ b/test/zeitenEinschalten.test.js @@ -277,6 +277,18 @@ test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout, mit der Grat keineSpuren() }) +test('Altform { consent: true } (News, ohne Fassungsprüfung) wird bei dieser neuen Route nicht angenommen: 400, keine Spur', async () => { + // sonst würde nach einer Textänderung der Gratis-Zustimmung ein alter Tab den neuen Text bestätigen, ohne ihn gesehen zu haben + for (const body of [{ projectId: 'p1', consent: true }, { projectId: 'p1', consent: true, zustimmung: null }, { projectId: 'p1', consent: true, zustimmung: {} }]) { + const res = await einschalten(body) + assert.equal(res.status, 400, JSON.stringify(body)) + assert.equal(res.body.code, 'zustimmung_fehlt') + } + keineSpuren() + // die Form mit Fassung und Haken geht weiter + assert.equal((await einschalten(koerper({ consent: true }))).status, 200) +}) + test('Projekt: fehlt ⇒ 400, fremd oder unbekannt ⇒ 403, archiviert ⇒ 409', async () => { assert.equal((await einschalten({ zustimmung: zustimmung() })).status, 400) assert.equal((await einschalten(koerper({ projectId: 'p3' }))).status, 403) @@ -455,6 +467,27 @@ test('Team aus disabled: zurück auf setup, Neubau erneut, kein neues Ticket', a assert.equal(tickets().length, 0) }) +test('Team aus disabled bei einem gekauften Dokument: kauf und unlockedByPurchase bleiben (Widerrufs-Zuordnung), einbau bleibt, kein Neubau', async () => { + for (const [nr, kauf] of [[1, { unlockedBy: 'kauf', unlockedByPurchase: true }], [2, { unlockedByPurchase: true }]]) { + const projectId = `p_k${nr}` + projekt(projectId) + aw.seed('portalFeatures', { + $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', + einbau: 'angestossen', ticketId: 'ticket_alt', disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', ...kauf, + }) + const { doc } = await einschaltenZeiten({ customerId: 'c1', projectId, by: 'team', now: JETZT }) + assert.deepEqual([doc.state, sf.unlockedByVon(doc), doc.unlockedByPurchase, doc.disabledAt, doc.disabledReason, doc.ticketId, doc.einbau], ['setup', 'kauf', true, null, null, 'ticket_alt', 'angestossen'], `Fall ${nr}`) + } + assert.deepEqual([rebuilds.length, tickets().length], [0, 0], 'bei gekauften Dokumenten kümmert sich markPaid um Neubau und Ticket, nicht das Nachholen') + // ein Dokument, das nie gekauft war, wird weiter ein Team-Dokument; ohne Dokument ebenso + aw.seed('portalFeatures', { + $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', + unlockedBy: 'abo', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 'ticket_alt', + }) + const team = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', now: JETZT }) + assert.deepEqual([team.doc.unlockedBy, team.doc.unlockedByPurchase], ['team', false]) +}) + test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy abo und leerem k24At, danach Marker', async () => { aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' }) const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT }) diff --git a/test/zeitenRegister.test.js b/test/zeitenRegister.test.js index 22373d5..651d40b 100644 --- a/test/zeitenRegister.test.js +++ b/test/zeitenRegister.test.js @@ -46,7 +46,7 @@ test('zeiten steht im Register mit 30 € und Abo-Inklusive', () => { test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', () => { const { consent } = feature() - assert.equal(consent.version, '2026-10-04') + assert.equal(consent.version, '2026-10-05', 'Fassung nach dem Wertersatz-Satz (§ 357a Abs. 2 BGB)') assert.equal(consent.knopf, 'Zahlungspflichtig bestellen · 30 €') assert.deepEqual(consent.items.map((i) => [i.id, i.haken]), [['leistung', true], ['beginn', true]]) assert.equal( @@ -55,11 +55,20 @@ test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 ) assert.equal( consent.items[1].text, - 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB).' + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' ) assert.match(consent.items[1].text, /§ 356 Abs\. 5 Nr\. 2/) }) +test('Zustimmung bezahlt: der Wertersatz-Satz ist wörtlich der von News und Terminplaner (einheitliche Fassungen; Wortlaut bleibt Sache der Rechtsrunde, ZQ3)', () => { + const satz = (text) => text.match(/Widerrufe ich vor der vollständigen Erbringung[^.]*\.[^.]*\)\./)?.[0] + const zeiten = satz(config.features.zeiten.consent.items.find((i) => i.id === 'beginn').text) + assert.ok(zeiten, 'Wertersatz-Satz vorhanden') + for (const key of ['news', 'termine']) { + assert.equal(satz(config.features[key].consent.items.find((i) => i.id === 'beginn').text), zeiten, `${key} hat denselben Satz`) + } +}) + test('Zustimmung gratis: ein Haken, Knopf „Einschalten“, kein „zahlungspflichtig“', () => { const f = feature() assert.equal(f.consentFrei.version, '2026-10-04') diff --git a/test/zeitenTeam.test.js b/test/zeitenTeam.test.js index 4d5072b..fd72b28 100644 --- a/test/zeitenTeam.test.js +++ b/test/zeitenTeam.test.js @@ -201,6 +201,44 @@ test('Team „Freischalten (Abnahme)“ der Zeiten: ohne Zeiten 409 (bleibt setu assert.equal(ok.body.ersatzTicket, undefined, 'abnahme legt kein Ersatz-Ticket an') }) +// Review N8 / I1: „Ticket nachlegen“ lief für Abo und Team in den Zahlungsweg und scheiterte immer mit 409 „keine Zahlung vermerkt“ +test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 409, Neubau und Einbau-Ticket kommen über das Nachholen, keine K24', async () => { + aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'team', einbau: '' })) + const res = await aktion('ticket') + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = zeitenDoc() + assert.ok(doc.ticketId, 'Ticket angelegt') + assert.equal(doc.einbau, 'angestossen') + assert.equal(tickets().length, 1) + assert.equal(rebuilds.length, 1) + assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false) + assert.match(doc.adminNote, / Andrej: ticket$/) + assert.equal(sink.sent.length, 0, 'unlockedBy team: keine K24') + + // zweiter Klick: das Ticket ist da, nichts entsteht neu + assert.equal((await aktion('ticket')).status, 200) + assert.equal(tickets().length, 1) + assert.equal(rebuilds.length, 1) +}) + +test('Team „Ticket nachlegen“ der Zeiten (Abo, setup, ohne Ticket): Ticket und K24 über das Nachholen, nicht über den Zahlungsweg', async () => { + aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' })) + const res = await aktion('ticket') + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(zeitenDoc().ticketId) + assert.equal(tickets().length, 1) + assert.equal(sink.sent.length, 1, 'K24 an den Kunden (unlockedBy abo)') + assert.ok(zeitenDoc().k24At) +}) + +test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => { + aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true, einbau: 'angestossen' })) + const res = await aktion('ticket') + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.match(res.body.error, /keine Zahlung vermerkt/) + assert.equal(tickets().length, 0) +}) + test('Aktivieren: fremdes oder unbekanntes Projekt 403, unbekannte Aktion 400, ohne Anmeldung 401, nichts entsteht', async () => { assert.equal((await aktion('activate', {}, { projectId: 'p3' })).status, 403) assert.equal((await aktion('activate', {}, { projectId: 'gibtsnicht' })).status, 403)