diff --git a/public/widerrufen.html b/public/widerrufen.html index 3086da5..c8c1193 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -21,6 +21,7 @@ + diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index 543bd11..f7580ec 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -9,7 +9,7 @@ Empfänger: E-Mail aus dem Formular · BCC intern Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen - produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). + produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). --> @@ -51,7 +51,7 @@ a{color:#111111;} - + diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index f86d414..23dccc2 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.html @@ -7,7 +7,8 @@ Betreff: [Abo] WIDERRUF eingegangen: {{email}} Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com - Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} + Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} + preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer). Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen --> @@ -61,7 +62,7 @@ a{color:#111111;} - + diff --git a/server/routes/abo.js b/server/routes/abo.js index 5d66c34..72527c4 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -19,6 +19,7 @@ import { switchToWartung, } from '../services/planSwitch.js' import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' +import { featureConfig } from '../services/featureRegistry.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -705,13 +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'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine'] +// 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' } // 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'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -784,6 +787,7 @@ function orderLabel(order, sub, { customer = false } = {}) { if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' + if (order === 'termine') return 'Terminplaner (Einrichtung)' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` } @@ -1092,19 +1096,29 @@ async function withdrawOrder(sub, d, receivedAt) { const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' } /** - * Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags- - * ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team - * bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand. + * Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo + * und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; + * das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von + * Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt + * (Zeit des Klicks der bezahlten Sitzung, ohne Nachweis orderedAt); das spaeteste Fristende entscheidet ueber "rechtzeitig". + * Dokumente ohne featureKey stammen aus der Zeit, als es nur die News gab. */ -async function withdrawFeature(d, receivedAt) { +async function withdrawFeature(d, receivedAt, featureKey) { const customer = (await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null)) - const orders = customer ? await paidFeatureOrders(customer.$id) : [] - // Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt - const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt) - const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null + const alle = customer ? await paidFeatureOrders(customer.$id) : [] + const orders = alle.filter((o) => (o.featureKey || 'news') === featureKey) + const endOf = (o) => { + const anker = o.vertragAt || o.orderedAt + return anker ? new Date(withdrawalDeadline(anker).endsAt) : null + } + const ends = orders.map(endOf).filter(Boolean) + const deadline = ends.length ? new Date(Math.max(...ends.map((e) => e.getTime()))) : null const lines = orders - .map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`) + .map((o) => { + const ende = endOf(o) + return `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${ende ? `, Fristende ${fmtDate(ende)}` : ''}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}` + }) .join('\n') return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline } } @@ -1127,12 +1141,14 @@ router.post('/public/withdraw', async (req, res) => { let sub = null let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } - // Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) - const feature = d.order === 'funktion' + // Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) + const featureKey = FEATURE_OF_ORDER[d.order] ?? null + const feature = featureKey !== null + const funktion = feature ? featureConfig(featureKey) : null let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false } try { if (feature) { - fn = await withdrawFeature(d, receivedAt) + fn = await withdrawFeature(d, receivedAt, featureKey) result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) } } else { sub = await findActiveSubscriptionByEmail(d.email) @@ -1153,7 +1169,7 @@ router.post('/public/withdraw', async (req, res) => { values: { name: d.name, bestellung: orderLabel(d.order, sub, { customer: true }), - produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), + produkt: feature ? funktion.label.name : productLabelOf(sub), eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber, @@ -1176,7 +1192,8 @@ router.post('/public/withdraw', async (req, res) => { name: d.name, kundennummer: d.customerNumber, bestellung: orderLabel(d.order, sub), - produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), + produkt: feature ? funktion.label.name : productLabelOf(sub), + preis: feature ? funktion.priceLabel : '', stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index a35d610..072637b 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20'] const GUEST = ids('G', 8) @@ -255,7 +255,7 @@ test('optionale Blöcke: nur eingeschaltete erscheinen', () => { }) test('verschachtelte Blöcke (T09) werden aufgelöst', () => { - const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' } + const values = { email: 'a@example.com', name: 'A', kundennummer: '', produkt: 'Terminplaner', preis: '149 €', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' } const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true }) assert.match(ok.html, /sub_1 ]*>\(auf Kündigung gesetzt\)<\/span>/) assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/) @@ -596,6 +596,40 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/) }) +// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion +test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => { + assert.ok(loadTemplate('T09').placeholders.includes('preis')) + assert.ok(loadTemplate('T09').placeholders.includes('produkt')) + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen']) + assert.deepEqual(loadTemplate('T09').blocks, [ + 'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel', + 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', + ]) +}) + +test('F9: K04 und T09 beim Widerruf einer Funktion (begonnene Einrichtung, Wertersatz, Preis, Handgriffe)', () => { + const k04 = { name: 'K', produkt: 'Terminplaner', bestellung: 'Terminplaner (Einrichtung)', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' } + const kunde = renderMail('K04', k04, { funktion: true }) + assert.deepEqual(kunde.problems, []) + assert.equal(kunde.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Terminplaner') + assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/) + assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/) + assert.match(kunde.text, /Wertersatz[^.]*\(§ 357a Abs\. 2 BGB\)/) + assert.doesNotMatch(kunde.text, /abgeschlossen/) + assert.match(kunde.text, /Sie erhalten dazu eine gesonderte Abrechnung/) + + const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Terminplaner', preis: '149 €', ereignisse: 'Studio Anker: in Einrichtung' } + const team = renderMail('T09', t09, { funktion: true }) + assert.deepEqual(team.problems, []) + assert.match(team.text, /Widerruf einer Funktion \(Terminplaner, 149 € einmalig\)/) + assert.match(team.text, /NICHTS automatisch geändert/) + assert.match(team.text, /innerhalb von 14 Tagen erstatten/) + assert.match(team.text, /abzüglich Wertersatz für den begonnenen Einbau \(Aufwand im Arbeitsblatt, ⚖ Q5\)/) + assert.match(team.text, /Reiter „Funktionen“/) + assert.match(team.text, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/) + assert.match(team.text, /Studio Anker: in Einrichtung/) +}) + test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechsels wie in Fassung 4', () => { const values = sampleValues(loadTemplate('K12')) const mit = renderMail('K12', values, { zusatzspeicher: true }) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 4c57d0d..5f67713 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -822,12 +822,12 @@ async function raeumeAnkerTest() { for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id) } -test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => { +test('Widerruf Terminplaner: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => { await resetSubH({ units: 0 }) const klickAt = daysAgo(13) seedBezahlteBestellung({ klickAt }) try { - const res = await withdraw({ order: 'funktion' }) + const res = await withdraw({ order: 'termine' }) assert.equal(res.status, 200, JSON.stringify(res.body)) const team = t09().text // Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung @@ -841,12 +841,12 @@ test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzun } }) -test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => { +test('Widerruf Terminplaner: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => { await resetSubH({ units: 0 }) const klickAt = daysAgo(20) seedBezahlteBestellung({ klickAt }) try { - const res = await withdraw({ order: 'funktion' }) + const res = await withdraw({ order: 'termine' }) assert.equal(res.status, 200, JSON.stringify(res.body)) const team = t09().text assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team) @@ -857,21 +857,21 @@ test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Frist } }) -test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => { +test('Widerruf Terminplaner: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => { await resetSubH({ units: 0 }) aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' }) const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' } try { // consentAt (Altbestand, News) vor orderedAt aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) }) - assert.equal((await withdraw({ order: 'funktion' })).status, 200) + assert.equal((await withdraw({ order: 'termine' })).status, 200) assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt))) assert.match(t09().text, /NACH Fristende/) // weder Nachweis noch consentAt: orderedAt mails.sent.length = 0 aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null }) - assert.equal((await withdraw({ order: 'funktion' })).status, 200) + assert.equal((await withdraw({ order: 'termine' })).status, 200) assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt))) assert.match(t09().text, /RECHTZEITIG/) } finally { @@ -879,7 +879,7 @@ test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedA } }) -test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => { +test('Widerruf Terminplaner: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => { await resetSubH({ units: 0 }) const orderedAt = daysAgo(2) seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt }) @@ -888,7 +888,7 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z console.warn = (...args) => warnungen.push(args.join(' ')) try { aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 }) - const res = await withdraw({ order: 'funktion' }) + const res = await withdraw({ order: 'termine' }) assert.equal(res.status, 200, JSON.stringify(res.body)) const team = t09().text assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team) @@ -901,6 +901,178 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z } }) +/** Bezahltes Funktionsdokument (Projekt wird mit angelegt) fuer die Tests je Funktion. */ +function seedBezahlt({ projectId, projektName, featureKey, orderedAt, state = 'setup' }) { + aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: projektName }) + aw.seed('portalFeatures', { + $id: `f_${projectId}_${featureKey}`, + customerId: 'c1', + projectId, + featureKey, + state, + enabled: false, + unlockedByPurchase: true, + orderedAt, + }) +} + +async function raeumeBezahlt(...ids) { + for (const id of ids) if (aw.docs('portalFeatures').has(id)) await deleteDocument('portalFeatures', id) +} + +test('widerruf_termine_frist_je_funktion: Terminplaner vor 3 Tagen, News vor 20 Tagen bestellt; jede Wahl rechnet mit der eigenen Funktion', async () => { + const newsAt = daysAgo(20) + const termineAt = daysAgo(3) + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' }) + seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: termineAt }) + try { + await resetSubH({ units: 0 }) + const before = st.calls.length + assert.equal((await withdraw({ order: 'termine' })).status, 200) + const termine = t09().text + assert.match(termine, /RECHTZEITIG/) + assert.doesNotMatch(termine, /NACH Fristende/) + assert.ok(termine.includes(fmtDate(withdrawalDeadline(termineAt).endsAt)), 'Fristende des Terminplaners') + assert.match(termine, /Studio Termine: in Einrichtung/) + assert.doesNotMatch(termine, /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_pt1_termine').state, 'setup', 'nichts automatisch geändert') + + // umgekehrt: Widerruf "Funktion" meint die News und rechnet mit deren Frist; der junge Terminplaner rettet sie nicht + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'funktion' })).status, 200) + const news = t09().text + assert.match(news, /NACH Fristende/) + assert.doesNotMatch(news, /RECHTZEITIG/) + assert.ok(news.includes(fmtDate(withdrawalDeadline(newsAt).endsAt)), 'Fristende der News') + assert.match(news, /Bäckerei News: aktiv/) + assert.doesNotMatch(news, /Studio Termine/) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pt1_termine') + } +}) + +test('widerruf_termine_frist_je_funktion: mehrere Projekte einer Funktion - spätestes Fristende, jede Zeile mit eigenem Fristende', async () => { + const alt = daysAgo(20) + const jung = daysAgo(3) + seedBezahlt({ projectId: 'pn1', projektName: 'Alt News', featureKey: 'news', orderedAt: alt, state: 'active' }) + seedBezahlt({ projectId: 'pn2', projektName: 'Jung News', featureKey: 'news', orderedAt: jung }) + try { + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'funktion' })).status, 200) + const team = t09().text + // bisheriges Verhalten: das Maximum der Fristenden entscheidet + assert.match(team, /RECHTZEITIG/) + assert.ok(team.includes(`${fmtDate(withdrawalDeadline(jung).endsAt)}, 24 Uhr`), 'Fristende = spätestes') + assert.match(team, new RegExp(`Alt News: aktiv, bestellt am ${fmtDate(alt)}, Fristende ${fmtDate(withdrawalDeadline(alt).endsAt)}`)) + assert.match(team, new RegExp(`Jung News: in Einrichtung, bestellt am ${fmtDate(jung)}, Fristende ${fmtDate(withdrawalDeadline(jung).endsAt)}`)) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pn2_news') + } +}) + +test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 den Preis und das Projekt; bei der News unverändert', async () => { + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(3) }) + seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) }) + try { + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'termine', customerNumber: 'KD-9' })).status, 200) + const kunde = k04() + assert.match(kunde.subject, /WEBklar Terminplaner$/) + assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/) + assert.match(kunde.text, /Wir prüfen Ihren Widerruf/) + assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/) + assert.match(kunde.text, /§ 357a Abs\. 2 BGB/) + assert.doesNotMatch(kunde.text, /Funktion \(z\. B\. News-Pinnwand\)|Es erfolgen keine weiteren Abbuchungen/) + const team = t09().text + assert.match(team, /Terminplaner \(Einrichtung\)/) + assert.ok(team.includes(`Widerruf einer Funktion (${config.features.termine.label.name}, ${config.features.termine.priceLabel} einmalig)`), team) + assert.match(team, /149 €/) + assert.match(team, /Studio Termine: in Einrichtung/) + assert.match(team, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/) + assert.match(team, /NICHTS automatisch geändert/) + + // "Funktion (z. B. News-Pinnwand)" bleibt wie bisher: News-Pinnwand, 30 € + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'funktion' })).status, 200) + assert.match(k04().subject, /WEBklar News-Pinnwand$/) + assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/) + assert.match(k04().text, /Wir prüfen Ihren Widerruf/) + assert.match(t09().text, /Funktion \(z\. B\. News-Pinnwand\)/) + assert.ok(t09().text.includes(`Widerruf einer Funktion (${config.features.news.label.name}, ${config.features.news.priceLabel} einmalig)`), t09().text) + assert.match(t09().text, /Bäckerei News: in Einrichtung/) + assert.doesNotMatch(t09().text, /Studio Termine/) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pt1_termine') + } +}) + +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) + const klickAt = daysAgo(13) + seedBezahlteBestellung({ klickAt }) + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) }) + try { + assert.equal((await withdraw({ order: 'termine' })).status, 200) + let team = t09().text + assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team) + assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt') + assert.match(team, /RECHTZEITIG/) + + // Rückfall ohne featureConsents-Eintrag: orderedAt + mails.sent.length = 0 + await deleteDocument('featureConsents', 'fc_bezahlt') + assert.equal((await withdraw({ order: 'termine' })).status, 200) + team = t09().text + assert.ok(team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), team) + assert.match(team, /RECHTZEITIG/) + } finally { + await raeumeAnkerTest() + await raeumeBezahlt('f_pn1_news') + } +}) + +test('widerruf_unbekannte_funktion: kein bezahltes Dokument dieser Funktion - ungeprüft (keine bezahlte Funktion gefunden)', async () => { + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'termine' })).status, 200) + assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/) + assert.match(t09().text, /keine Frist gefunden/) + assert.ok(k04(), 'K04 geht trotzdem an den Kunden') + + // nur eine News bezahlt, Widerruf des Terminplaners: auch dann ungeprüft statt Frist der News + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) }) + try { + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'termine' })).status, 200) + assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/) + assert.doesNotMatch(t09().text, /RECHTZEITIG|NACH Fristende/) + // noch nicht bezahlt (ordered) zählt nicht + aw.seed('portalFeatures', { $id: 'f_pt9_termine', customerId: 'c1', projectId: 'pt9', featureKey: 'termine', state: 'ordered', enabled: false, unlockedByPurchase: false, orderedAt: daysAgo(1) }) + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'termine' })).status, 200) + assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pt9_termine') + } +}) + +test('Widerruf Funktion: Dokument ohne featureKey (Altbestand) zählt als News-Pinnwand', async () => { + await resetSubH({ units: 0 }) + aw.seed('websiteProjects', { $id: 'pl1', customerId: 'c1', projectName: 'Altbestand' }) + aw.seed('portalFeatures', { $id: 'f_pl1_alt', customerId: 'c1', projectId: 'pl1', state: 'active', enabled: true, unlockedByPurchase: true, orderedAt: daysAgo(3) }) + try { + assert.equal((await withdraw({ order: 'funktion' })).status, 200) + assert.match(t09().text, /Altbestand: aktiv/) + assert.match(t09().text, /RECHTZEITIG/) + mails.sent.length = 0 + assert.equal((await withdraw({ order: 'termine' })).status, 200) + assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/) + } finally { + await raeumeBezahlt('f_pl1_alt') + } +}) + test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => { const startedAt = daysAgo(2) await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } }) @@ -1100,12 +1272,13 @@ test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/) }) -test('widerrufen.html: Radioknöpfe order', () => { +test('widerrufen_html_radio: Radioknöpfe order', () => { const src = html('widerrufen.html') assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/) assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/) 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.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) })