From 0063c04d8ab9e54d9d82bdffbfcbebcc11bfc9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:40:23 +0000 Subject: [PATCH] Shop C5: Muster-Rechtstexte (Widerrufsbelehrung, Formular, AGB), Versand/Zahlung, Gewaehrleistungshinweis, Normalisierung und Hash Widerrufsbelehrung nach Anlage 1 (Stand 2026, mit Hinweis auf die Widerrufsfunktion), Formular nach Anlage 2, Muster-AGB ohne OS-Plattform, erzeugter Versand-/Zahlungstext, Gewaehrleistungshinweis (Wortlaut zu pruefen), Muster-Markdown fuer die Kanzlei und Tests inkl. Abgleich Markdown/Code. Co-Authored-By: Claude Opus 5.5 --- docs/funktionen/shop-muster-rechtstexte.md | 150 ++++++++++ server/services/shop/rechtstexte.js | 312 +++++++++++++++++++++ test/shopRechtstexte.test.js | 311 ++++++++++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 docs/funktionen/shop-muster-rechtstexte.md create mode 100644 server/services/shop/rechtstexte.js create mode 100644 test/shopRechtstexte.test.js diff --git a/docs/funktionen/shop-muster-rechtstexte.md b/docs/funktionen/shop-muster-rechtstexte.md new file mode 100644 index 0000000..01d53d3 --- /dev/null +++ b/docs/funktionen/shop-muster-rechtstexte.md @@ -0,0 +1,150 @@ +# Shop: Muster-Rechtstexte (Widerrufsbelehrung, Widerrufsformular, AGB) + +**Muster – keine Rechtsberatung.** Diese Texte sind Muster, die das Kundenportal im Webshop vorbefüllt (`server/services/shop/rechtstexte.js`, dort mit den Angaben aus den Shop-Einstellungen). Der Händler prüft sie, passt sie an und bestätigt sie selbst; WEBklar berät nicht rechtlich. Diese Datei enthält dieselben Texte mit Platzhaltern in eckigen Klammern zur Prüfung durch eine Kanzlei (Plan 2026-10-05, Task C5; Offene Punkte 5 und 9 der Recherche). + +Stand: 2026-10-05. + +## Quellen und Fundstellen + +- Widerrufsbelehrung: Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Fassung 2026 mit dem Baustein zur Widerrufsfunktion (https://www.gesetze-im-internet.de/bgbeg/BJNR006049896.html). Ein richtig ausgefülltes Muster erfüllt die Informationspflicht (Art. 246a § 1 Abs. 2 S. 3 EGBGB). +- Elektronische Widerrufsfunktion: § 356a BGB (seit 19.06.2026), Hinweispflicht in der Belehrung nach Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Widerrufsformular: Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB. +- Ausschluss und vorzeitiges Erlöschen: § 312g Abs. 2 Nr. 1, 2, 3 BGB; Information nach Art. 246a § 1 Abs. 3 EGBGB. +- Gewährleistungshinweis: Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB i. V. m. Durchführungsverordnung (EU) 2025/1960 Anhang I (seit 27.09.2026). +- Streitbeilegung: § 36 VSBG. Die EU-Plattform zur Online-Streitbeilegung wurde am 20.07.2025 eingestellt; es gibt keinen Link darauf. +- Button und Vertragsschluss: § 312j Abs. 3 und 4 BGB, § 312i BGB. Recherche: `/root/reports/webshop-2026-10-05/recherche-recht-stripe.md` (B.1 bis B.10). + +## Hinweise zur Prüfung (nicht Teil der Texte) + +- Fristbeginn: Die Belehrung enthält beide gesetzlichen Alternativen (eine Ware bzw. die Waren; mehrere Waren einer Bestellung, getrennt geliefert: die letzte Ware), weil der Warenkorb beides erzeugen kann. Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen. +- Rücksendekosten: Standard ist „Käufer trägt“. Variante „Händler trägt“ steht unten. +- Abholung: Bietet der Shop Abholung an, kommt der Satz „Haben Sie die Waren bei uns abgeholt …“ hinzu. +- Ausschlussgründe: Der letzte Absatz der Belehrung nennt § 312g Abs. 2 Nr. 1, 2 und 3 BGB in allgemeiner Form. Ob und für welche Waren er zutrifft, entscheidet der Händler; Einordnung von Lieferung und Abholung von Speisen (§ 312g Abs. 2 Nr. 9, § 312 Abs. 2 Nr. 8 BGB) ist offen (Recherche, Offene Punkte 6). +- Wortlaut des Gewährleistungshinweises: im Code als „Wortlaut prüfen (Kanzlei)“ markiert; der amtliche deutsche Wortlaut von Anhang I ist einzusetzen. +- Widerrufsfunktion: Erreichbarkeit ohne Login bei Gastbestellungen, Identifikation der Bestellung und Zeitstempel mit der Kanzlei abstimmen (Recherche, Offene Punkte 7). + +## Widerrufsbelehrung + +Widerrufsbelehrung + +Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat. + +Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. + +Sie können Ihr Widerrufsrecht auch online unter [Internetadresse der Widerrufsfunktion im Shop] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. + +Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist. + +Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort]) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. + +Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. + +Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist. + +Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts + +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen. +Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel. +Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB). + +Ende der Widerrufsbelehrung + +### Variante: Händler trägt die Rücksendekosten + +Statt „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“ steht: + +Wir tragen die Kosten der Rücksendung der Waren. + +### Variante: Abholung angeboten + +Nach dem Absatz zur Rücksendung folgt zusätzlich: + +Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: [Abholadresse]. + +## Widerrufsformular + +Muster-Widerrufsformular + +(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) + +An [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]: + +Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) + +– Bestellt am (*)/erhalten am (*) +– Name des/der Verbraucher(s) +– Anschrift des/der Verbraucher(s) +– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +– Datum + +(*) Unzutreffendes streichen. + +## Allgemeine Geschäftsbedingungen + +Die Kopfzeile „Muster – bitte prüfen und anpassen“ steht im vorbefüllten Text und wird vom Händler nach der Prüfung entfernt. Die Zahlungsarten, Länder und Abholung werden aus den Shop-Einstellungen eingesetzt; die Platzhalter unten zeigen den Standardfall. + +Muster – bitte prüfen und anpassen + +Allgemeine Geschäftsbedingungen + +§ 1 Geltung und Anbieter + +Diese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]. + +§ 2 Vertragsschluss + +Die Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Wir bestätigen den Eingang der Bestellung unverzüglich per E-Mail. Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen. + +§ 3 Preise und Versandkosten + +Es gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. Alle Preise enthalten die gesetzliche Umsatzsteuer. Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“. + +§ 4 Zahlung + +Die Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift. Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung. + +§ 5 Lieferung und Abholung + +Wir liefern in folgende Länder: Deutschland. Die Lieferzeit beträgt [Lieferzeit], gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich. + +§ 6 Eigentumsvorbehalt + +Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum. + +§ 7 Widerrufsrecht + +Verbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden. + +§ 8 Gewährleistung + +Es gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop. + +§ 9 Vertragstext und Sprache + +Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch. + +§ 10 Streitbeilegung + +[Name des Händlers] ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). + +Bei Kleinunternehmern steht in § 3 statt „Alle Preise enthalten die gesetzliche Umsatzsteuer.“ der Satz „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.“ + +## Hinweis auf das gesetzliche Gewährleistungsrecht (Kasse und Bestellbestätigung) + +Wortlaut prüfen (Kanzlei): sinngemäße Fassung, bis der amtliche Wortlaut von Anhang I eingesetzt ist. + +Gesetzliches Gewährleistungsrecht + +Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie. diff --git a/server/services/shop/rechtstexte.js b/server/services/shop/rechtstexte.js new file mode 100644 index 0000000..54c276e --- /dev/null +++ b/server/services/shop/rechtstexte.js @@ -0,0 +1,312 @@ +import { createHash } from 'node:crypto' +import { euro } from './geld.js' +import { STANDARD_EINSTELLUNGEN } from './einstellungen.js' + +/** + * Muster-Rechtstexte des Haendlers (Plan 2026-10-05-webshop.md, Task C5; Spec 4.6). + * Alles rein: Einstellungen rein, Klartext raus (Absaetze mit '\n\n' getrennt), Sie-Form fuer Verbraucher. + * Das sind MUSTER, keine Rechtsberatung. Der Haendler prueft, passt an und bestaetigt selbst (BESTAETIGUNG_TEXT). + * Quellen: Anlage 1 und 2 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB (Stand 2026, mit Baustein zur Widerrufsfunktion, § 356a BGB), + * § 312g Abs. 2 BGB, § 36 VSBG, VO (EU) 2025/1960 Anhang I; siehe /root/reports/webshop-2026-10-05/recherche-recht-stripe.md + * (B.2, B.3, B.3a, B.8). Dieselben Texte mit Platzhaltern: docs/funktionen/shop-muster-rechtstexte.md. + */ + +export const BESTAETIGUNG_TEXT = + 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.' + +export const MAX_AGB = 30000 +export const MAX_WIDERRUFSBELEHRUNG = 20000 +export const MAX_WIDERRUFSFORMULAR = 5000 +const MAX_URL = 500 + +const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit'] +const ZAHLUNGSARTEN_TEXT = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' } + +/** Einstellungen mit Standardwerten auffuellen (Unterobjekte eine Ebene tief), damit Teilobjekte nicht werfen. */ +function mitStandard(e) { + const std = STANDARD_EINSTELLUNGEN + const roh = e && typeof e === 'object' ? e : {} + const sub = (k) => ({ ...std[k], ...(roh[k] && typeof roh[k] === 'object' && !Array.isArray(roh[k]) ? roh[k] : {}) }) + return { ...std, ...roh, anbieter: sub('anbieter'), versand: sub('versand'), abholung: sub('abholung') } +} + +const text = (s) => (typeof s === 'string' ? s.trim() : '') + +function anschrift(a) { + const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ') + const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : '' + return [text(a.strasse), ort, land].filter(Boolean).join(', ') || '[Anschrift]' +} + +function anbieterZeile(a) { + return [text(a.name) || '[Name]', anschrift(a), text(a.telefon) ? `Telefon: ${text(a.telefon)}` : '', `E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}`] + .filter(Boolean) + .join(', ') +} + +function laenderListe(e) { + const laender = Array.isArray(e.versand.laender) ? e.versand.laender : [] + return laender.map((l) => LANDNAMEN[l] ?? l).join(', ') +} + +function steuerSatz(e) { + return e.kleinunternehmer + ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.' + : 'Alle Preise enthalten die gesetzliche Umsatzsteuer.' +} + +/** 'Kreditkarte, Apple Pay, Google Pay, Klarna': aktive Zahlungsarten in fester Reihenfolge. Auch fuer die Widget-Konfiguration. */ +export function zahlungsartenText(e) { + const arten = Array.isArray(e?.zahlungsarten) ? e.zahlungsarten : [] + return ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)) + .map((a) => ZAHLUNGSARTEN_TEXT[a]) + .join(', ') +} + +/** + * Widerrufsbelehrung fuer Waren: gesetzliches Muster (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Stand 2026) mit Baustein + * zur Widerrufsfunktion (§ 356a BGB). + * Gestaltungshinweise, so gewaehlt: Fristbeginn = Alternative "Kaufvertrag" (Waren in Besitz genommen) UND Alternative "mehrere + * Waren einer einheitlichen Bestellung, getrennt geliefert" (letzte Ware), beide als eigene Saetze, weil der Warenkorb beides + * erzeugen kann. Teilsendungen/regelmaessige Lieferung entfallen (kommt im Shop nicht vor). Nicht abgeholt: "Wir holen die Waren ab" + * entfaellt, dafuer die Rueckgabe-Alternative (zurueckzusenden oder zu uebergeben) samt Rueckzahlungsverweigerungs-Satz. + * Rueckkosten nach e.ruecksendekosten ('kaeufer' = Standard, 'haendler'). + * @param {object} e Einstellungen + * @param {{ widerrufUrl?: string }} [opts] Adresse der Widerrufsfunktion im Shop + */ +export function musterWiderrufsbelehrung(e, { widerrufUrl = '' } = {}) { + e = mitStandard(e) + const a = e.anbieter + const kontakt = anbieterZeile(a) + const url = text(widerrufUrl) || '[Internetadresse der Widerrufsfunktion]' + const rueckadresse = `${text(a.name) || '[Name]'}, ${anschrift(a)}` + const kosten = e.ruecksendekosten === 'haendler' ? 'Wir tragen die Kosten der Rücksendung der Waren.' : 'Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.' + const abholung = e.abholung.aktiv + ? `Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: ${text(e.abholung.adresse) || rueckadresse}.` + : '' + return [ + 'Widerrufsbelehrung', + 'Widerrufsrecht', + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.', + 'Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.', + `Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.`, + `Sie können Ihr Widerrufsrecht auch online unter ${url} ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.`, + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + 'Folgen des Widerrufs', + 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.', + `Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns (${rueckadresse}) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.`, + ...(abholung ? [abholung] : []), + kosten, + 'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.', + 'Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts', + [ + 'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen.', + 'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel.', + 'Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB).', + ].join('\n'), + 'Ende der Widerrufsbelehrung', + ].join('\n\n') +} + +/** Muster-Widerrufsformular (Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB), Unternehmer eingesetzt. */ +export function musterWiderrufsformular(e) { + e = mitStandard(e) + const a = e.anbieter + return [ + 'Muster-Widerrufsformular', + '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)', + `An ${text(a.name) || '[Name]'}, ${anschrift(a)}, E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}:`, + 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', + ['– Bestellt am (*)/erhalten am (*)', '– Name des/der Verbraucher(s)', '– Anschrift des/der Verbraucher(s)', '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)', '– Datum'].join('\n'), + '(*) Unzutreffendes streichen.', + ].join('\n\n') +} + +/** Kurze Muster-AGB fuer kleine Shops. Kopfzeile kennzeichnet sie als Muster; der Haendler loescht sie nach der Pruefung. */ +export function musterAgb(e) { + e = mitStandard(e) + const a = e.anbieter + const name = text(a.name) || '[Name]' + const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : [] + const zahlung = zahlungsartenText(e) + const abs = [] + abs.push('Muster – bitte prüfen und anpassen') + abs.push('Allgemeine Geschäftsbedingungen') + abs.push( + `§ 1 Geltung und Anbieter\n\nDiese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist ${anbieterZeile(a)}${text(a.ustId) ? `, USt-IdNr.: ${text(a.ustId)}` : ''}.` + ) + abs.push( + '§ 2 Vertragsschluss\n\nDie Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Wir bestätigen den Eingang der Bestellung unverzüglich per E-Mail. Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen.' + ) + abs.push( + `§ 3 Preise und Versandkosten\n\nEs gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. ${steuerSatz(e)} Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“.` + ) + let z = `§ 4 Zahlung\n\nDie Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: ${zahlung || '[Zahlungsarten]'}.` + if (arten.includes('sepa_debit')) z += ' Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.' + abs.push(z) + let l = '§ 5 Lieferung und Abholung\n\n' + const teile = [] + if (e.versand.aktiv) teile.push(`Wir liefern in folgende Länder: ${laenderListe(e) || '[Länder]'}.`) + if (e.abholung.aktiv) teile.push(`Sie können die Waren auch bei uns abholen: ${text(e.abholung.adresse) || '[Abholadresse]'}.${text(e.abholung.hinweis) ? ` ${text(e.abholung.hinweis)}` : ''}`) + if (!teile.length) teile.push('Angaben zur Lieferung und Abholung: [bitte ergänzen].') + l += teile.join(' ') + l += ` Die Lieferzeit beträgt ${text(e.lieferzeit) || '[Lieferzeit]'}, gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich.` + abs.push(l) + abs.push(`§ 6 Eigentumsvorbehalt\n\nDie Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.`) + abs.push('§ 7 Widerrufsrecht\n\nVerbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden.') + abs.push( + '§ 8 Gewährleistung\n\nEs gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop.' + ) + abs.push( + '§ 9 Vertragstext und Sprache\n\nWir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch.' + ) + abs.push( + `§ 10 Streitbeilegung\n\n${name} ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).` + ) + return abs.join('\n\n') +} + +/** Aus den Einstellungen erzeugter Text zu Versand und Zahlung (nicht frei editierbar, damit er stimmt). */ +export function versandUndZahlung(e) { + e = mitStandard(e) + const abs = ['Versand und Zahlung'] + if (e.versand.aktiv) { + abs.push(`Liefergebiet: ${laenderListe(e) || '–'}`) + const laender = Array.isArray(e.versand.laender) ? e.versand.laender : [] + const zeilen = laender + .filter((l) => Number.isInteger(e.versand.kostenCent?.[l])) + .map((l) => `${LANDNAMEN[l] ?? l}: ${euro(e.versand.kostenCent[l])}`) + if (zeilen.length) abs.push(`Versandkosten:\n${zeilen.join('\n')}`) + if (Number.isInteger(e.versand.freiAbCent)) abs.push(`Versandkostenfrei ab einem Bestellwert von ${euro(e.versand.freiAbCent)}.`) + } else { + abs.push('Versand: Wir versenden derzeit nicht.') + } + if (e.abholung.aktiv) { + abs.push(`Abholung: ${text(e.abholung.adresse) || '–'}${text(e.abholung.hinweis) ? `\n${text(e.abholung.hinweis)}` : ''}`) + } + abs.push(`Lieferzeit: ${text(e.lieferzeit) || '–'}`) + abs.push(`Zahlungsarten: ${zahlungsartenText(e) || '–'}`) + if (Number.isInteger(e.mindestbestellCent)) abs.push(`Mindestbestellwert: ${euro(e.mindestbestellCent)}`) + abs.push(e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.' : 'Alle Preise inkl. gesetzlicher USt.') + return abs.join('\n\n') +} + +/** + * Harmonisierter Hinweis auf das gesetzliche Gewährleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB, VO (EU) 2025/1960 Anhang I, + * seit 27.09.2026), in Kasse hervorgehoben. + * Wortlaut prüfen (Kanzlei): Der amtliche deutsche Wortlaut von Anhang I stand in der Recherche nicht vor (EUR-Lex nicht geladen); + * dies ist eine sinngemaesse Fassung (Mindestdauer zwei Jahre, wichtigste Elemente). Grafik/Symbol und Farbe kommen spaeter. + */ +export function gewaehrleistungsHinweis() { + return [ + 'Gesetzliches Gewährleistungsrecht', + 'Für Waren besteht ein gesetzliches Gewährleistungsrecht von mindestens zwei Jahren ab Lieferung der Ware. Ist die Ware nicht vertragsgemäß, haben Sie zunächst Anspruch auf kostenlose Nachbesserung (Reparatur) oder Ersatzlieferung; unter den gesetzlichen Voraussetzungen können Sie stattdessen den Preis mindern oder vom Vertrag zurücktreten. Diese Rechte bestehen unabhängig von einer Garantie.', + ].join('\n\n') +} + +// --- Normalisierung -------------------------------------------------------------------------------------------------------- + +// Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus +const STEUER = /[\p{Cc}\p{Cf}\u2028\u2029]/gu + +function bereinigeText(s) { + return s + .replace(/\r\n?/g, '\n') + .replace(/\t/g, ' ') + .replace(STEUER, (z) => (z === '\n' ? z : '')) + .replace(/[ ]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function pruefeUrl(wert) { + if (wert === undefined || wert === null) return { wert: '' } + if (typeof wert !== 'string') return { fehler: 'Bitte gib die Adresse als Text an.' } + const s = wert.trim() + if (s === '') return { wert: '' } + if (s.length > MAX_URL) return { fehler: `Die Adresse darf höchstens ${MAX_URL} Zeichen lang sein.` } + if (/[\s\p{Cc}\p{Cf}]/u.test(s)) return { fehler: 'Die Adresse darf keine Leerzeichen oder Sonderzeichen enthalten.' } + let u + try { + u = new URL(s) + } catch { + return { fehler: 'Bitte gib eine vollständige Adresse an, die mit https:// beginnt.' } + } + if (u.protocol !== 'https:' || !u.hostname || !/^https:\/\//i.test(s)) return { fehler: 'Die Adresse muss mit https:// beginnen.' } + if (u.username || u.password) return { fehler: 'Die Adresse darf keine Zugangsdaten enthalten.' } + return { wert: s } +} + +/** + * Pruefen und Normalisieren der Haendler-Rechtstexte. Texte duerfen im Entwurf leer sein; URLs muessen https sein oder leer. + * @returns {{rechtstexte: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl}} | {fehler: Object}} + */ +export function normalisiereRechtstexte(eingabe) { + if (!eingabe || typeof eingabe !== 'object' || Array.isArray(eingabe)) return { fehler: { rechtstexte: 'Die Rechtstexte fehlen oder sind unlesbar.' } } + const fehler = {} + const out = {} + const texte = [ + ['agb', 'AGB', MAX_AGB], + ['widerrufsbelehrung', 'Widerrufsbelehrung', MAX_WIDERRUFSBELEHRUNG], + ['widerrufsformular', 'Widerrufsformular', MAX_WIDERRUFSFORMULAR], + ] + for (const [key, name, max] of texte) { + const w = Object.hasOwn(eingabe, key) ? eingabe[key] : undefined + if (w === undefined || w === null) { + out[key] = '' + continue + } + if (typeof w !== 'string') { + fehler[key] = `Bitte gib die ${name} als Text an.` + out[key] = '' + continue + } + const s = bereinigeText(w) + if ([...s].length > max) { + fehler[key] = `Die ${name} darf höchstens ${max} Zeichen lang sein.` + out[key] = '' + } else out[key] = s + } + for (const [key, name] of [['impressumUrl', 'Impressum'], ['datenschutzUrl', 'Datenschutzerklärung']]) { + const r = pruefeUrl(Object.hasOwn(eingabe, key) ? eingabe[key] : undefined) + if (r.fehler) { + fehler[key] = `${name}: ${r.fehler}` + out[key] = '' + } else out[key] = r.wert + } + if (Object.keys(fehler).length) return { fehler } + return { + rechtstexte: { + agb: out.agb, + widerrufsbelehrung: out.widerrufsbelehrung, + widerrufsformular: out.widerrufsformular, + impressumUrl: out.impressumUrl, + datenschutzUrl: out.datenschutzUrl, + }, + } +} + +/** Alle drei Texte gefuellt und beide Adressen gesetzt: Voraussetzung fuer die Bestaetigung. */ +export function vollstaendig(rechtstexte) { + const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {} + return ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl'].every((k) => typeof r[k] === 'string' && r[k].trim() !== '') +} + +/** sha256 (hex) ueber kanonisches JSON der fuenf Felder plus erzeugtem Versand-/Zahlungstext und Gewaehrleistungshinweis. */ +export function rechtstexteHash(rechtstexte, einstellungen) { + const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {} + const s = (k) => (typeof r[k] === 'string' ? r[k] : '') + const kanonisch = JSON.stringify([ + ['agb', s('agb')], + ['widerrufsbelehrung', s('widerrufsbelehrung')], + ['widerrufsformular', s('widerrufsformular')], + ['impressumUrl', s('impressumUrl')], + ['datenschutzUrl', s('datenschutzUrl')], + ['versandUndZahlung', versandUndZahlung(einstellungen)], + ['gewaehrleistungsHinweis', gewaehrleistungsHinweis()], + ]) + return createHash('sha256').update(kanonisch).digest('hex') +} diff --git a/test/shopRechtstexte.test.js b/test/shopRechtstexte.test.js new file mode 100644 index 0000000..cc82444 --- /dev/null +++ b/test/shopRechtstexte.test.js @@ -0,0 +1,311 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + musterWiderrufsbelehrung, + musterWiderrufsformular, + musterAgb, + versandUndZahlung, + gewaehrleistungsHinweis, + zahlungsartenText, + normalisiereRechtstexte, + vollstaendig, + rechtstexteHash, + BESTAETIGUNG_TEXT, +} from '../server/services/shop/rechtstexte.js' +import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js' + +const NB = '\u00a0' +const basis = () => { + const e = JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)) + e.anbieter = { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123456', ustId: 'DE123456789' } + return e +} +const WIDERRUF_URL = 'https://brot.example/shop#widerruf' + +test('Widerrufsbelehrung: gesetzliche Kernsaetze und eingesetzte Anbieterdaten', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + for (const s of [ + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.', + 'die letzte Ware in Besitz genommen haben bzw. hat.', + 'mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail)', + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + 'unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist.', + 'in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.', + 'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.', + ]) { + assert.ok(t.includes(s), `fehlt: ${s}`) + } + assert.ok(t.includes('(Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, Telefon: 0631 123456, E-Mail: shop@brot.example)')) + assert.ok(t.includes('an uns (Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern) zurückzusenden oder zu übergeben')) + assert.ok(!t.includes('['), 'keine offenen Platzhalter bei vollen Angaben') + assert.ok(t.split('\n\n').length > 10) +}) + +test('Widerrufsbelehrung: Hinweis auf die Widerrufsfunktion mit Adresse', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes(`Sie können Ihr Widerrufsrecht auch online unter ${WIDERRUF_URL} ausüben.`)) + assert.ok( + t.includes( + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + ) + ) + // ohne Adresse: sichtbarer Platzhalter statt leerer Stelle + assert.ok(musterWiderrufsbelehrung(basis()).includes('[Internetadresse der Widerrufsfunktion]')) +}) + +test('Widerrufsbelehrung: Variante Ruecksendekosten', () => { + const e = basis() + assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.')) + e.ruecksendekosten = 'haendler' + const t = musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('Wir tragen die Kosten der Rücksendung der Waren.')) + assert.ok(!t.includes('Sie tragen die unmittelbaren Kosten')) +}) + +test('Widerrufsbelehrung: Abholung nur wenn angeboten', () => { + const e = basis() + assert.ok(!musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('abgeholt')) + e.abholung = { aktiv: true, adresse: 'Lager, Bahnhofstraße 1, 67655 Kaiserslautern', hinweis: '' } + assert.ok(musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).includes('Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: Lager, Bahnhofstraße 1, 67655 Kaiserslautern.')) +}) + +test('Widerrufsbelehrung: Ausschluss und vorzeitiges Erlöschen nach § 312g Abs. 2 Nr. 1, 2, 3', () => { + const t = musterWiderrufsbelehrung(basis(), { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts')) + assert.ok(t.includes('die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB)')) + assert.ok(t.includes('Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB)')) + assert.ok(t.includes('Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB)')) +}) + +test('Widerrufsbelehrung: leere Anbieterdaten ergeben Platzhalter, kein Absturz', () => { + const t = musterWiderrufsbelehrung(STANDARD_EINSTELLUNGEN, { widerrufUrl: WIDERRUF_URL }) + assert.ok(t.includes('[Name]')) + assert.ok(t.includes('[Anschrift]')) + assert.ok(t.includes('[E-Mail-Adresse]')) + assert.doesNotThrow(() => musterWiderrufsbelehrung(undefined)) + assert.doesNotThrow(() => musterAgb({})) + assert.doesNotThrow(() => versandUndZahlung(null)) +}) + +test('Widerrufsformular nach Anlage 2 mit Anschrift', () => { + const t = musterWiderrufsformular(basis()) + assert.ok(t.includes('An Brot & Mehr GmbH, Hauptstraße 5, 67655 Kaiserslautern, E-Mail: shop@brot.example:')) + assert.ok(t.includes('Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)')) + assert.ok(t.includes('– Bestellt am (*)/erhalten am (*)')) + assert.ok(t.includes('– Name des/der Verbraucher(s)')) + assert.ok(t.includes('– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)')) + assert.ok(t.includes('(*) Unzutreffendes streichen.')) + assert.ok(t.startsWith('Muster-Widerrufsformular')) +}) + +test('AGB: Kopf, Paragraphen, Vertragsschluss, Zahlung, Streitbeilegung ohne OS-Plattform', () => { + const e = basis() + e.zahlungsarten = ['card', 'klarna', 'sepa_debit'] + e.versand.laender = ['DE', 'AT'] + e.kleinunternehmer = false + const t = musterAgb(e) + assert.ok(t.startsWith('Muster – bitte prüfen und anpassen')) + for (let i = 1; i <= 10; i += 1) assert.ok(new RegExp(`^§ ${i} `, 'm').test(t), `§ ${i} fehlt`) + assert.ok(t.includes('Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.')) + assert.ok(t.includes('Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.')) + assert.ok(t.includes('Die Zahlung erfolgt über den Zahlungsdienstleister Stripe.')) + assert.ok(t.includes('Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift')) + assert.ok(t.includes('Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.')) + assert.ok(t.includes('Wir liefern in folgende Länder: Deutschland, Österreich.')) + assert.ok(t.includes('Die Lieferzeit beträgt 2–4 Werktage')) + assert.ok(t.includes('Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.')) + assert.ok(t.includes('Es gelten die gesetzlichen Mängelhaftungsrechte.')) + assert.ok(t.includes('Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar.')) + assert.ok(t.includes('Die Vertragssprache ist Deutsch.')) + assert.ok(t.includes('ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)')) + assert.ok(t.includes('Alle Preise enthalten die gesetzliche Umsatzsteuer.')) + assert.ok(t.includes('USt-IdNr.: DE123456789')) + assert.doesNotMatch(t, /ec\.europa\.eu|OS-Plattform|ODR/i) +}) + +test('AGB: Varianten Kleinunternehmer, nur Karte, Abholung, kein Versand', () => { + const e = basis() + e.kleinunternehmer = true + e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' } + e.versand.aktiv = false + const t = musterAgb(e) + assert.ok(t.includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.')) + assert.ok(!t.includes('SEPA'), 'SEPA-Satz nur bei SEPA') + assert.ok(!t.includes('Wir liefern in folgende Länder')) + assert.ok(t.includes('Sie können die Waren auch bei uns abholen: Laden, Hauptstraße 5. Di–Fr 9–17 Uhr')) +}) + +test('versandUndZahlung: Liefergebiet, Kosten je Land, frei ab, Abholung, Zahlungsarten, Mindestbestellwert', () => { + const e = basis() + e.versand = { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 } + e.abholung = { aktiv: true, adresse: 'Laden, Hauptstraße 5', hinweis: 'Di–Fr 9–17 Uhr' } + e.zahlungsarten = ['card', 'klarna'] + e.mindestbestellCent = 1000 + e.kleinunternehmer = false + const t = versandUndZahlung(e) + assert.ok(t.includes('Liefergebiet: Deutschland, Österreich')) + assert.ok(t.includes(`Deutschland: 4,90${NB}€`)) + assert.ok(t.includes(`Österreich: 9,90${NB}€`)) + assert.ok(t.includes(`Versandkostenfrei ab einem Bestellwert von 50,00${NB}€.`)) + assert.ok(t.includes('Abholung: Laden, Hauptstraße 5\nDi–Fr 9–17 Uhr')) + assert.ok(t.includes('Lieferzeit: 2–4 Werktage')) + assert.ok(t.includes('Zahlungsarten: Kreditkarte, Apple Pay, Google Pay, Klarna')) + assert.ok(t.includes(`Mindestbestellwert: 10,00${NB}€`)) + assert.ok(t.includes('Alle Preise inkl. gesetzlicher USt.')) + e.kleinunternehmer = true + e.mindestbestellCent = null + e.versand.freiAbCent = null + e.abholung.aktiv = false + const k = versandUndZahlung(e) + assert.ok(k.includes('§ 19 UStG')) + assert.ok(!k.includes('Mindestbestellwert')) + assert.ok(!k.includes('Versandkostenfrei')) + assert.ok(!k.includes('Abholung')) + e.versand.aktiv = false + assert.ok(versandUndZahlung(e).includes('Wir versenden derzeit nicht.')) +}) + +test('zahlungsartenText: feste Reihenfolge, nur aktive Arten', () => { + assert.equal(zahlungsartenText({ zahlungsarten: ['card'] }), 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(zahlungsartenText({ zahlungsarten: ['klarna', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay, Klarna') + assert.equal(zahlungsartenText({ zahlungsarten: ['sepa_debit', 'klarna'] }), 'Klarna, SEPA-Lastschrift') + assert.equal(zahlungsartenText({ zahlungsarten: ['paypal', 'card'] }), 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(zahlungsartenText({}), '') + assert.equal(zahlungsartenText(undefined), '') +}) + +test('Gewaehrleistungshinweis: zwei Jahre, stabil, ohne Platzhalter', () => { + const t = gewaehrleistungsHinweis() + assert.ok(t.startsWith('Gesetzliches Gewährleistungsrecht')) + assert.ok(t.includes('mindestens zwei Jahren')) + assert.equal(gewaehrleistungsHinweis(), t) +}) + +test('BESTAETIGUNG_TEXT genau nach Spec 4.6', () => { + assert.equal(BESTAETIGUNG_TEXT, 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.') +}) + +test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung', () => { + const r = normalisiereRechtstexte({ + agb: ' Zeile 1\r\nZeile 2​ \n\n\n\nZeile 3 ', + widerrufsbelehrung: 'B', + widerrufsformular: 'F', + impressumUrl: ' https://brot.example/impressum ', + datenschutzUrl: 'https://brot.example/datenschutz?x=1#a', + unbekannt: 'wird ignoriert', + }) + assert.deepEqual(r, { + rechtstexte: { + agb: 'Zeile 1\nZeile 2\n\nZeile 3', + widerrufsbelehrung: 'B', + widerrufsformular: 'F', + impressumUrl: 'https://brot.example/impressum', + datenschutzUrl: 'https://brot.example/datenschutz?x=1#a', + }, + }) + const leer = normalisiereRechtstexte({}) + assert.deepEqual(leer, { rechtstexte: { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' } }) + assert.equal(vollstaendig(leer.rechtstexte), false) +}) + +test('normalisiereRechtstexte: Laengen', () => { + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular) +}) + +test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => { + for (const u of ['http://x.example/a', 'javascript:alert(1)', 'ftp://x.example', '//x.example/a', 'x.example/impressum', 'https://user:pw@x.example/a', 'https://user@x.example/a', 'https://x.example/a b', 'https://', 'data:text/html,hi']) { + const r = normalisiereRechtstexte({ impressumUrl: u }) + assert.ok(r.fehler?.impressumUrl, `muss abgelehnt werden: ${u}`) + } + assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://' + 'a'.repeat(500) + '.example' }).fehler.datenschutzUrl) + assert.ok(normalisiereRechtstexte({ datenschutzUrl: 'https://x.example/datenschutz' }).rechtstexte) + assert.equal(normalisiereRechtstexte({ impressumUrl: '' }).rechtstexte.impressumUrl, '') +}) + +test('normalisiereRechtstexte: falsche Typen und Nicht-Objekte', () => { + for (const v of [null, undefined, 'x', 5, [], true]) assert.ok(normalisiereRechtstexte(v).fehler.rechtstexte) + const r = normalisiereRechtstexte({ agb: 5, widerrufsbelehrung: {}, impressumUrl: 7 }) + assert.ok(r.fehler.agb && r.fehler.widerrufsbelehrung && r.fehler.impressumUrl) + assert.ok(!('rechtstexte' in r)) + // Prototyp-Schluessel werden nie gelesen + const p = normalisiereRechtstexte(JSON.parse('{"__proto__":{"agb":"x"}}')) + assert.equal(p.rechtstexte.agb, '') +}) + +test('vollstaendig: alle drei Texte und beide URLs', () => { + const voll = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' } + assert.equal(vollstaendig(voll), true) + for (const k of Object.keys(voll)) { + assert.equal(vollstaendig({ ...voll, [k]: '' }), false, k) + assert.equal(vollstaendig({ ...voll, [k]: ' ' }), false, `${k} nur Leerraum`) + } + assert.equal(vollstaendig(null), false) + assert.equal(vollstaendig(undefined), false) +}) + +test('rechtstexteHash: stabil, sha256 hex, reagiert auf jedes Feld und die Einstellungen', () => { + const e = basis() + const r = { agb: 'a', widerrufsbelehrung: 'b', widerrufsformular: 'c', impressumUrl: 'https://x.example/i', datenschutzUrl: 'https://x.example/d' } + const h = rechtstexteHash(r, e) + assert.match(h, /^[0-9a-f]{64}$/) + assert.equal(rechtstexteHash({ ...r }, JSON.parse(JSON.stringify(e))), h) + // Reihenfolge der Schluessel spielt keine Rolle + assert.equal(rechtstexteHash({ datenschutzUrl: r.datenschutzUrl, impressumUrl: r.impressumUrl, widerrufsformular: 'c', widerrufsbelehrung: 'b', agb: 'a' }, e), h) + // zusaetzliche Felder zaehlen nicht + assert.equal(rechtstexteHash({ ...r, extra: 'x' }, e), h) + for (const k of Object.keys(r)) assert.notEqual(rechtstexteHash({ ...r, [k]: `${r[k]}x` }, e), h, k) + // Versand-/Zahlungstext fliesst ein + const e2 = JSON.parse(JSON.stringify(e)) + e2.versand.kostenCent.DE = 590 + assert.notEqual(rechtstexteHash(r, e2), h) + const e3 = JSON.parse(JSON.stringify(e)) + e3.zahlungsarten = ['card', 'klarna'] + assert.notEqual(rechtstexteHash(r, e3), h) + // Felder ohne Einfluss auf den erzeugten Text aendern den Hash nicht + const e4 = JSON.parse(JSON.stringify(e)) + e4.akzent = '#ff0000' + assert.equal(rechtstexteHash(r, e4), h) + // fehlende Felder wie leere Texte + assert.equal(rechtstexteHash({}, e), rechtstexteHash({ agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }, e)) +}) + +test('Markdown-Muster und Code teilen die Kernsaetze', () => { + const md = readFileSync(new URL('../docs/funktionen/shop-muster-rechtstexte.md', import.meta.url), 'utf8') + assert.ok(md.includes('Muster – keine Rechtsberatung')) + const e = basis() + e.zahlungsarten = ['card', 'klarna', 'sepa_debit'] + e.kleinunternehmer = false + // Absaetze ohne Haendlerdaten muessen wortgleich im Markdown stehen + const absaetze = [ + ...musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).split('\n\n'), + ...musterAgb(e).split('\n\n'), + ...musterWiderrufsformular(e).split('\n\n'), + ...gewaehrleistungsHinweis().split('\n\n'), + ] + const ohneDaten = absaetze.filter((a) => !/Brot & Mehr|brot\.example|Hauptstra|Kaiserslautern|2–4 Werktage|0631|DE123456789/.test(a)) + assert.ok(ohneDaten.length >= 25, `zu wenige vergleichbare Absaetze: ${ohneDaten.length}`) + const mdFlach = md.replace(/ {2}\n/g, '\n') + for (const a of ohneDaten) assert.ok(mdFlach.includes(a), `im Markdown fehlt: ${a.slice(0, 80)}`) + // Absaetze mit Haendlerdaten: Kernsaetze davon + for (const s of [ + 'Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab.', + 'Der Vertrag kommt zustande, sobald wir Ihnen nach erfolgreicher Zahlung die Bestellbestätigung per E-Mail senden.', + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung', + 'ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG)', + 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag', + ]) { + assert.ok(md.includes(s), `Markdown: ${s}`) + } + assert.ok(md.includes('Wir tragen die Kosten der Rücksendung der Waren.')) + assert.doesNotMatch(md, /ec\.europa\.eu\/consumers\/odr/) +})