From 172d9f1778a76f1aa767e9ef1ff4aeff8d7851b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:18:14 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20P1b:=20Bestellnummer=20im=20Widerruf=20f?= =?UTF-8?q?reiwillig,=20ohne=20Nummer=20Bestelldatum=20und=20Artikel=20in?= =?UTF-8?q?=20der=20Erkl=C3=A4rung=20(=C2=A7=20356a=20BGB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/embed/shop.js | 3 ++- server/services/shop/widerrufe.js | 11 ++++++----- test/publicShopWiderruf.test.js | 11 ++++++++++- test/shopEmbedStatic.test.js | 7 +++++++ test/shopMails.test.js | 11 +++++++++++ test/shopWiderrufe.test.js | 29 +++++++++++++++++++++++++++-- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/public/embed/shop.js b/public/embed/shop.js index adbbb1d..115bf59 100644 --- a/public/embed/shop.js +++ b/public/embed/shop.js @@ -1597,7 +1597,7 @@ var refs = {} form.appendChild(feld(refs, { name: 'name', label: 'Ihr Name', typ: 'text', wert: w.name, pflicht: true, autocomplete: 'name', maxlength: 100, onInput: function (v) { w.name = v } })) form.appendChild(feld(refs, { name: 'email', label: 'E-Mail-Adresse der Bestellung', typ: 'email', wert: w.email, pflicht: true, autocomplete: 'email', maxlength: 254, onInput: function (v) { w.email = v } })) - form.appendChild(feld(refs, { name: 'bestellnummer', label: 'Bestellnummer', typ: 'text', wert: w.bestellnummer, pflicht: false, maxlength: 40, onInput: function (v) { w.bestellnummer = v } })) + form.appendChild(feld(refs, { name: 'bestellnummer', label: 'Bestellnummer (falls zur Hand)', typ: 'text', wert: w.bestellnummer, pflicht: false, maxlength: 40, hinweis: 'Ohne Bestellnummer: Nennen Sie bitte Bestelldatum und Artikel im Feld „Erklärung“, damit wir Ihren Vertrag zuordnen können.', onInput: function (v) { w.bestellnummer = v } })) form.appendChild(feld(refs, { name: 'nachricht', label: 'Erklärung', typ: 'textarea', wert: w.nachricht, pflicht: false, maxlength: 1000, hinweis: 'Zum Beispiel, welche Artikel Sie widerrufen. Ohne Angabe widerrufen Sie die gesamte Bestellung.', onInput: function (v) { w.nachricht = v } })) form.appendChild(honigfeld()) var fehlerBox = el('div', 'wks-fehlerbox') @@ -1615,6 +1615,7 @@ var f = {} if (!w.name.trim()) f.name = 'Bitte geben Sie Ihren Namen an.' if (!EMAIL_FORM.test(w.email.trim())) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.' + if (!w.bestellnummer.trim() && !w.nachricht.trim()) f.nachricht = 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' fehlerBox.textContent = '' zeigeFeldfehler(refs, f) if (Object.keys(f).length) return diff --git a/server/services/shop/widerrufe.js b/server/services/shop/widerrufe.js index 2339dfb..6034a65 100644 --- a/server/services/shop/widerrufe.js +++ b/server/services/shop/widerrufe.js @@ -10,8 +10,8 @@ import { anHaendler, anKaeufer, haendlerK26 } from './mails.js' /** * Widerrufe von Kaeufern (§ 356a BGB; Spezifikation 2026-10-05-webshop-design.md Abschnitt 5.4; Plan Task E5). * - * Der Kaeufer erklaert den Widerruf ueber die oeffentliche Widerrufsseite des Shops (Name, Bestellnummer als Vertragsbezeichnung, - * E-Mail als Kontakt, optionale Nachricht). erfasseWiderruf speichert die Erklaerung (shopWiderrufe), haengt sie an die passende + * Der Kaeufer erklaert den Widerruf ueber die oeffentliche Widerrufsseite des Shops (Name, E-Mail als Kontakt, Bestellnummer als + * freiwillige Vertragsbezeichnung; ohne Nummer ist die Nachricht mit Bestelldatum und Artikel Pflicht). erfasseWiderruf speichert die Erklaerung (shopWiderrufe), haengt sie an die passende * Bestellung (gleiche Nummer und gleiche E-Mail) und bestaetigt den Eingang sofort mit G12 (Datum und Uhrzeit, Berliner Zeit, auf * dem dauerhaften Datentraeger E-Mail). K26 informiert den Haendler. Mailfehler lassen die Anfrage nie scheitern: dann bleibt * bestaetigungAt leer. Die Bestellnummer muss nicht existieren (der Widerruf ist trotzdem wirksam erklaert). @@ -69,11 +69,12 @@ export function normalisiereWiderruf(eingabe) { else if (email.length > 254 || !EMAIL_RE.test(email)) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.' const nummer = einzeilig(roh.bestellnummer).toUpperCase() - if (nummer === '') f.bestellnummer = 'Bitte geben Sie die Bestellnummer an.' - else if (!NUMMER_RE.test(nummer)) f.bestellnummer = 'Die Bestellnummer darf nur aus Buchstaben, Ziffern und Bindestrichen bestehen und höchstens 32 Zeichen lang sein.' + // Die Bestellnummer ist freiwillig (§ 356a BGB: der Kaeufer hat sie nicht immer zur Hand); ohne sie muss die Erklaerung den Vertrag benennen + if (nummer !== '' && !NUMMER_RE.test(nummer)) f.bestellnummer = 'Die Bestellnummer darf nur aus Buchstaben, Ziffern und Bindestrichen bestehen und höchstens 32 Zeichen lang sein.' const nachricht = mehrzeilig(roh.nachricht) if (nachricht.length > 2000) f.nachricht = 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' + else if (nummer === '' && nachricht === '') f.nachricht = 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' if (Object.keys(f).length > 0) return { fehler: f } return { widerruf: { name, email, bestellnummer: nummer, nachricht } } @@ -131,7 +132,7 @@ export async function erfasseWiderruf(ctx, eingabe, { now = new Date(), ip } = { if (geprueft.fehler) throw Object.assign(fehler(400, Object.values(geprueft.fehler)[0]), { fehler: geprueft.fehler }) const { name, email, bestellnummer, nachricht } = geprueft.widerruf - const order = await passendeBestellung(shopId, bestellnummer, email) + const order = bestellnummer === '' ? null : await passendeBestellung(shopId, bestellnummer, email) const doc = await createDocument(col(), { shopId, diff --git a/test/publicShopWiderruf.test.js b/test/publicShopWiderruf.test.js index 0659264..277c23f 100644 --- a/test/publicShopWiderruf.test.js +++ b/test/publicShopWiderruf.test.js @@ -292,10 +292,19 @@ test('kein Objekt als Rumpf: 400; zu groß: 413', async () => { assert.equal(gross.status, 413) }) +test('Ohne Bestellnummer: 400 fehler.nachricht, solange die Erklärung fehlt; mit Erklärung 200', async () => { + const leer = await widerruf(koerper({ bestellnummer: '', nachricht: '' })) + assert.equal(leer.status, 400) + assert.deepEqual(leer.body.fehler, { nachricht: 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' }) + const ok = await widerruf(koerper({ bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' })) + assert.equal(ok.status, 200) + assert.equal(ok.body.ok, true) +}) + test('Prüfung: 400 mit fehler je Feld, nichts gespeichert', async () => { const res = await widerruf(koerper({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) })) assert.equal(res.status, 400) - assert.deepEqual(Object.keys(res.body.fehler).sort(), ['bestellnummer', 'email', 'nachricht', 'name']) + assert.deepEqual(Object.keys(res.body.fehler).sort(), ['email', 'nachricht', 'name']) for (const t of Object.values(res.body.fehler)) assert.match(t, /Bitte|darf/) assert.equal(res.body.code, undefined) assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE) diff --git a/test/shopEmbedStatic.test.js b/test/shopEmbedStatic.test.js index b82316b..09ed939 100644 --- a/test/shopEmbedStatic.test.js +++ b/test/shopEmbedStatic.test.js @@ -74,6 +74,13 @@ test('Anfragen ohne Cookies und nur an die Shop-Schnittstelle, eine Stelle fuer assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/bestellung'/g)].length, 1, 'genau eine Stelle legt Bestellungen an') }) +test('Widerruf: Bestellnummer freiwillig mit Hinweis auf Bestelldatum und Artikel (§ 356a BGB)', () => { + assert.ok(QUELLE.includes("label: 'Bestellnummer (falls zur Hand)'")) + assert.ok(QUELLE.includes('Ohne Bestellnummer: Nennen Sie bitte Bestelldatum und Artikel im Feld „Erklärung“, damit wir Ihren Vertrag zuordnen können.')) + assert.ok(QUELLE.includes('Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.'), 'Pruefung im Formular ohne Nummer') + assert.ok(!/label: 'Bestellnummer'/.test(QUELLE)) +}) + test('Pflichttexte: Knopf der Kasse, Widerrufsfunktion, Gewaehrleistungsbild, Stripe-Hinweis, Fuss, Kopfzeile, geschlossen', () => { for (const noetig of [ "'Zahlungspflichtig bestellen'", "'Vertrag widerrufen'", "'Widerruf bestätigen'", "'Weiter'", diff --git a/test/shopMails.test.js b/test/shopMails.test.js index c93de2b..e401c54 100644 --- a/test/shopMails.test.js +++ b/test/shopMails.test.js @@ -252,6 +252,17 @@ test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum un assert.deepEqual(warnLog(), []) }) +test('G12 und K26 ohne Bestellnummer: „–“ statt leerer Stelle', async () => { + const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf02', name: 'Kai Käufer', bestellnummer: '', eingang: JETZT, nachricht: 'Bestellt am 01.10., Tasse blau' }, JETZT) + assert.equal(erg.ok, true) + const k = haendlerK26(ctxMit(), { name: 'Kai', email: 'kai@example.com', bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau', eingang: JETZT }) + assert.equal(k.values.bestellnummer, '–') + assert.equal((await anHaendler('K26', ctxMit(), k.values, k.blocks)).ok, true) + await warteAufVersand() + assert.match(sink.sent[0].text, /Bestellnummer: –/) + assert.match(sink.sent[1].text, /Bestellnummer: –/) +}) + test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => { assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true) assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true) diff --git a/test/shopWiderrufe.test.js b/test/shopWiderrufe.test.js index 132f915..79d78cf 100644 --- a/test/shopWiderrufe.test.js +++ b/test/shopWiderrufe.test.js @@ -111,9 +111,34 @@ test('normalisiereWiderruf: gültige Eingabe wird bereinigt (E-Mail klein, Beste assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'X' }).widerruf.nachricht, '', 'Nachricht ist freiwillig') }) +test('normalisiereWiderruf: Bestellnummer freiwillig; ohne Nummer ist die Erklärung Pflicht (§ 356a BGB)', () => { + const ohneNummer = normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: ' ', nachricht: 'Bestellt am 01.10., Tasse blau' }) + assert.deepEqual(ohneNummer, { widerruf: { name: 'Ab', email: 'a@b.de', bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' } }) + assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de' }).fehler.nachricht, 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.') + assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', nachricht: ' \n ' }).fehler.nachricht, 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.') + assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '26-K7QM2X' }).fehler, undefined, 'mit Nummer bleibt die Erklärung freiwillig') + assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'a b', nachricht: 'x' }).fehler.bestellnummer, 'gegebene Nummer: gleiche Formatregel') +}) + +test('erfasseWiderruf ohne Bestellnummer: gespeichert ohne Zuordnung, G12 und K26 mit „–“', async () => { + aw.seed('shopOrders', bestellung()) + const erg = await erfasseWiderruf(ctxMit(), { ...EINGABE, bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' }, { now: JETZT, ip: '203.0.113.77' }) + assert.equal(erg.ok, true) + const d = widerrufe().find((x) => x.$id === erg.id) + assert.equal(d.bestellnummer, '') + assert.equal(d.orderId, '') + await warteAufVersand() + assert.equal(outbox().filter((e) => e.art === 'G12').length, 1) + assert.equal(JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf.length, 1, 'ohne Nummer keine Zuordnung zum Verlauf') + await assert.rejects( + () => erfasseWiderruf(ctxMit(), { ...EINGABE, bestellnummer: '', nachricht: '' }, { now: JETZT, ip: '203.0.113.78' }), + (err) => err.status === 400 && err.fehler.nachricht === 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.', + ) +}) + test('normalisiereWiderruf: Fehler je Feld in Sie-Form', () => { const grenzen = normalisiereWiderruf({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) }) - assert.deepEqual(Object.keys(grenzen.fehler).sort(), ['bestellnummer', 'email', 'nachricht', 'name']) + assert.deepEqual(Object.keys(grenzen.fehler).sort(), ['email', 'nachricht', 'name'], 'leere Bestellnummer ist erlaubt') for (const text of Object.values(grenzen.fehler)) assert.match(text, /Bitte|darf/) assert.equal(normalisiereWiderruf({ name: 'x'.repeat(201), email: 'a@b.de', bestellnummer: '1' }).fehler.name.includes('200'), true) assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'x'.repeat(33) }).fehler.bestellnummer) @@ -124,7 +149,7 @@ test('normalisiereWiderruf: Fehler je Feld in Sie-Form', () => { for (const email of ['a@b.de, c@d.de', 'a@b.de;c@d.de', 'Max ', 'a@b.de\nc@d.de', 'a b@c.de', 'a@b']) { assert.ok(normalisiereWiderruf({ name: 'Ab', email, bestellnummer: '1' }).fehler?.email, email) } - assert.deepEqual(Object.keys(normalisiereWiderruf(null).fehler).sort(), ['bestellnummer', 'email', 'name']) + assert.deepEqual(Object.keys(normalisiereWiderruf(null).fehler).sort(), ['email', 'nachricht', 'name']) assert.ok(normalisiereWiderruf({ name: 5, email: {}, bestellnummer: [] }).fehler.name, 'falsche Typen sind leer') })