From dbb20429839ef1a3cab164f9ba4471ee1c321f75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:20:39 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20J2:=20Widerruf=20der=20Bestellung=20Webs?= =?UTF-8?q?hop=20(Einrichtung)=20im=20=C3=B6ffentlichen=20Widerrufsformula?= =?UTF-8?q?r=20und=20in=20abo.js,=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/widerrufen.html | 1 + server/routes/abo.js | 11 ++++++----- test/publicForms.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/public/widerrufen.html b/public/widerrufen.html index c8c1193..9b875f8 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -22,6 +22,7 @@ + diff --git a/server/routes/abo.js b/server/routes/abo.js index 72527c4..964e910 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', 'shop'] // 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', shop: 'shop' } // 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' | 'shop'; 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 === 'shop') return 'Webshop (Einrichtung)' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` } @@ -1096,7 +1097,7 @@ async function withdrawOrder(sub, d, receivedAt) { const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' } /** - * Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo + * Widerruf einer Funktion (News-Pinnwand 30 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, 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 @@ -1141,7 +1142,7 @@ 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 (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) + // Funktion (News, Terminplaner oder Webshop): 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 diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 5f67713..3a7eade 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1007,6 +1007,43 @@ test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 de } }) +test('widerruf_shop: Webshop (Einrichtung) rechnet mit der eigenen Frist, K04 und T09 nennen Webshop, Preis und Projekt, nichts wird automatisch geändert', async () => { + const shopAt = daysAgo(3) + seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(20), state: 'active' }) + seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) }) + seedBezahlt({ projectId: 'ps1', projektName: 'Bäckerei Shop', featureKey: 'shop', orderedAt: shopAt }) + try { + await resetSubH({ units: 0 }) + const before = st.calls.length + assert.equal((await withdraw({ order: 'shop', customerNumber: 'KD-9' })).status, 200) + const kunde = k04() + assert.match(kunde.subject, /WEBklar Webshop$/) + assert.match(kunde.text, /Widerruf der Bestellung „Webshop \(Einrichtung\)“/) + assert.match(kunde.text, /Wir prüfen Ihren Widerruf/) + assert.doesNotMatch(kunde.text, /Es erfolgen keine weiteren Abbuchungen/) + const team = t09().text + assert.match(team, /RECHTZEITIG/) + assert.doesNotMatch(team, /NACH Fristende/) + assert.match(team, /Webshop \(Einrichtung\)/) + assert.ok(team.includes(`Widerruf einer Funktion (${config.features.shop.label.name}, ${config.features.shop.priceLabel} einmalig)`), team) + assert.match(team, /299 €/) + assert.ok(team.includes(fmtDate(withdrawalDeadline(shopAt).endsAt)), 'Fristende des Webshops') + assert.match(team, /Bäckerei Shop: in Einrichtung/) + assert.doesNotMatch(team, /Bäckerei News|Studio Termine/, 'andere Funktionen gehören nicht zu dieser Wahl') + assert.match(team, /NICHTS automatisch geändert/) + assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') + assert.equal(aw.docs('portalFeatures').get('f_ps1_shop').state, 'setup', 'nichts automatisch geändert') + + // ohne bezahlten Webshop: ungeprüft, die Antwort bleibt 200 + await raeumeBezahlt('f_ps1_shop') + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'shop' })).status, 200) + assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/) + } finally { + await raeumeBezahlt('f_pn1_news', 'f_pt1_termine', 'f_ps1_shop') + } +}) + 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 +1316,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="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/) assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) })