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;}
Wie es weitergeht
Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.
-
Wie es weitergeht
Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; war die Einrichtung auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist abgeschlossen, verrechnen wir gegebenenfalls einen Wertersatz (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.
+
Wie es weitergeht
Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; hat die Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen, verrechnen wir gegebenenfalls einen Wertersatz für die bis zu Ihrem Widerruf erbrachte Leistung (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.
Wie es weitergeht
Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.
Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen: {{ereignisse}}
-
Zu tun
Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: {{rechtzeitig}}. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden: {{ereignisse}}
+
Zu tun
Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: {{rechtzeitig}}. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende): {{ereignisse}}
Achtung
Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.
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'\]/)
})