From 68a03080d158140e859404cf351f6034a6bf59e8 Mon Sep 17 00:00:00 2001
From: Thomas Bartelt
Date: Sun, 14 Jun 2026 23:02:54 +0200
Subject: [PATCH] =?UTF-8?q?feat(widerruf):=20Anonymous-Pfad=20+=20Token-Pr?=
=?UTF-8?q?efill=20f=C3=BCr=20=C2=A7=20356a-Konformit=C3=A4t?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- widerruf.php zeigt jetzt Formular (Name + Bestellnr + E-Mail) ohne Token-Zwang
- Token-Link aus Bestätigungs-Mail bleibt als Convenience, füllt Felder vor
- submit_anonymous() in Revocation-Klasse: Lookup via order_number + customer_email
- Anonymous-Eintrag wird inline angelegt, falls noch kein Token-basierter Record existiert
- Button-Beschriftung 'Vertrag widerrufen' (Gesetzes-Empfehlung)
- prefill_from_token() für Vorausfüllung bei Token-Link
---
core/revocation.class.php | 179 ++++++++++++++++++++++++++++++++++++++
widerruf.php | 138 ++++++++++++++++-------------
2 files changed, 258 insertions(+), 59 deletions(-)
diff --git a/core/revocation.class.php b/core/revocation.class.php
index 598fef6..3da386e 100644
--- a/core/revocation.class.php
+++ b/core/revocation.class.php
@@ -444,4 +444,183 @@ class Revocation {
}
return $out;
}
+
+ // ============================================================
+ // Anonymous-Pfad (§ 356a BGB – Button im Footer, ohne Token)
+ // ============================================================
+
+ /**
+ * Vorausfüllung des anonymen Formulars aus einem Token-Link.
+ * Liefert ['name','order_number','email'] oder false.
+ */
+ public function prefill_from_token($token_plain) {
+ if (!REVOCATION_ENABLED) {
+ $this->error = 'feature_disabled';
+ return false;
+ }
+ $token_plain = trim((string)$token_plain);
+ if (strlen($token_plain) !== REVOCATION_TOKEN_BYTES * 2) {
+ $this->error = 'invalid_token';
+ return false;
+ }
+ $hash = RevocationHelper::hash_token($token_plain);
+ $esc = $this->db->real_escape_string($hash);
+ $sql = "SELECT r.customer_email,
+ o.order_number,
+ o.billing_firstname, o.billing_surname
+ FROM revocations r
+ JOIN orders o ON o.id = r.order_id
+ WHERE r.token_hash = '$esc'
+ AND r.status = 'pending'
+ LIMIT 1";
+ $res = $this->db->query($sql);
+ if (!$res || $res->num_rows === 0) {
+ $this->error = 'invalid_token';
+ return false;
+ }
+ $row = $res->fetch_object();
+ return [
+ 'name' => trim(($row->billing_firstname ?? '').' '.($row->billing_surname ?? '')),
+ 'order_number' => $row->order_number,
+ 'email' => $row->customer_email,
+ ];
+ }
+
+ /**
+ * Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail.
+ * Wenn noch kein revocation-Record existiert, wird er inline angelegt.
+ */
+ public function submit_anonymous($name, $order_number, $email, $reason_id, $reason_text, $ip, $user_agent) {
+ if (!REVOCATION_ENABLED) {
+ $this->error = 'feature_disabled';
+ return false;
+ }
+
+ $order_number = trim((string)$order_number);
+ $email = trim(strtolower((string)$email));
+
+ if ($order_number === '') {
+ $this->error = 'missing_order';
+ return false;
+ }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $this->error = 'invalid_email';
+ return false;
+ }
+
+ $order = $this->fetch_order_by_number_email($order_number, $email);
+ if (!$order) {
+ $this->error = 'order_not_found';
+ return false;
+ }
+
+ $group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
+ if (!RevocationHelper::is_b2c($group_id)) {
+ $this->error = 'not_eligible';
+ return false;
+ }
+
+ $expires_at = RevocationHelper::calc_expires_at($order->order_date);
+ if (strtotime($expires_at) < time()) {
+ $this->error = 'expired';
+ return false;
+ }
+
+ // Existing Eintrag wiederverwenden, sonst inline anlegen
+ $existing = $this->fetch_revocation_by_order((int)$order->id);
+ if ($existing) {
+ if ($existing->status !== 'pending') {
+ $this->error = 'already_submitted';
+ return false;
+ }
+ $revocation_id = (int)$existing->id;
+ } else {
+ $token_plain = RevocationHelper::generate_token();
+ $token_hash = RevocationHelper::hash_token($token_plain);
+ $stmt = $this->db->prepare(
+ "INSERT INTO revocations
+ (order_id, customer_id, customer_email, customer_group_id,
+ token_hash, expires_at, status)
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')"
+ );
+ if (!$stmt) {
+ $this->error = 'prepare failed: '.$this->db->error;
+ return false;
+ }
+ $customer_id = $order->customer_id ? (int)$order->customer_id : null;
+ $order_id = (int)$order->id;
+ $stmt->bind_param(
+ 'iisiss',
+ $order_id, $customer_id, $order->customer_email, $group_id,
+ $token_hash, $expires_at
+ );
+ if (!$stmt->execute()) {
+ $this->error = 'insert failed: '.$stmt->error;
+ $stmt->close();
+ return false;
+ }
+ $revocation_id = (int)$this->db->insert_id;
+ $stmt->close();
+ }
+
+ $reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null;
+ $reason_text = trim((string)$reason_text);
+ if (mb_strlen($reason_text) > 2000) {
+ $reason_text = mb_substr($reason_text, 0, 2000);
+ }
+ $ip_anon = RevocationHelper::anonymize_ip($ip);
+ $ua = $user_agent ? mb_substr($user_agent, 0, 255) : null;
+
+ $stmt = $this->db->prepare(
+ "UPDATE revocations
+ SET status = 'submitted',
+ submitted_at = NOW(),
+ reason_id = ?,
+ reason_text = ?,
+ ip_anonymized = ?,
+ user_agent = ?
+ WHERE id = ? AND status = 'pending'"
+ );
+ if (!$stmt) {
+ $this->error = 'prepare failed: '.$this->db->error;
+ return false;
+ }
+ $stmt->bind_param('isssi', $reason_id, $reason_text, $ip_anon, $ua, $revocation_id);
+ $ok = $stmt->execute();
+ $affected = $stmt->affected_rows;
+ $stmt->close();
+ if (!$ok || $affected < 1) {
+ $this->error = 'update failed';
+ return false;
+ }
+
+ $fresh = $this->fetch_revocation($revocation_id);
+ if ($fresh) {
+ $this->send_owner_notification($fresh);
+ $this->send_customer_confirmation($fresh);
+ }
+ return true;
+ }
+
+ private function fetch_order_by_number_email($order_number, $email) {
+ $on = $this->db->real_escape_string($order_number);
+ $em = $this->db->real_escape_string($email);
+ $sql = "SELECT id, order_number, order_date, order_total, customer_id,
+ customer_email, billing_firstname, billing_surname,
+ payment_method
+ FROM orders
+ WHERE order_number = '$on'
+ AND LOWER(customer_email) = '$em'
+ LIMIT 1";
+ $res = $this->db->query($sql);
+ if (!$res || $res->num_rows === 0) return false;
+ return $res->fetch_object();
+ }
+
+ private function fetch_revocation_by_order($order_id) {
+ $order_id = (int)$order_id;
+ $res = $this->db->query("SELECT * FROM revocations WHERE order_id = $order_id LIMIT 1");
+ if (!$res || $res->num_rows === 0) return false;
+ return $res->fetch_object();
+ }
}
diff --git a/widerruf.php b/widerruf.php
index 5e9e1b2..1b6bc02 100644
--- a/widerruf.php
+++ b/widerruf.php
@@ -1,11 +1,12 @@
→ Token validieren, Widerrufs-Formular anzeigen
- * POST → Widerruf erfassen, Bestätigungsseite anzeigen
+ * GET → Leeres Formular
+ * GET ?t= → Formular mit vorausgefüllten Feldern (Convenience aus Bestell-Mail)
+ * POST → Widerruf erfassen, Bestätigungsseite anzeigen
*
- * Standalone-Entry-Point, nutzt das Shop-Base-Objekt für DB + Config.
+ * Submit läuft IMMER über Bestellnr + E-Mail Lookup, nicht über Token.
*
* @copyright Wlanium / Thomas Bartelt
* @since 2026-04-19
@@ -26,11 +27,10 @@ $base_object = new base();
Registry::set('base', $base_object);
include_once './core/revocation.class.php';
-
$revocation = new Revocation($base_object);
$view = 'form';
-$payload = null;
+$prefill = ['name' => '', 'order_number' => '', 'email' => ''];
$error = null;
// ------------------------------------------------------------
@@ -39,48 +39,69 @@ $error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- $token = isset($_POST['t']) ? (string)$_POST['t'] : '';
- $reason_id = isset($_POST['reason_id']) ? (int)$_POST['reason_id'] : 0;
- $reason_text = isset($_POST['reason_text']) ? (string)$_POST['reason_text'] : '';
- $confirm = isset($_POST['confirm']) && $_POST['confirm'] === '1';
+ $form_name = isset($_POST['name']) ? trim((string)$_POST['name']) : '';
+ $form_order_number = isset($_POST['order_number']) ? trim((string)$_POST['order_number']) : '';
+ $form_email = isset($_POST['email']) ? trim((string)$_POST['email']) : '';
+ $reason_id = isset($_POST['reason_id']) ? (int)$_POST['reason_id'] : 0;
+ $reason_text = isset($_POST['reason_text']) ? (string)$_POST['reason_text'] : '';
+ $confirm = isset($_POST['confirm']) && $_POST['confirm'] === '1';
+
+ // Eingaben für Fehlerfall ins Formular zurückspielen
+ $prefill = [
+ 'name' => $form_name,
+ 'order_number' => $form_order_number,
+ 'email' => $form_email,
+ ];
if (!$confirm) {
- // Zweite Stufe nicht aktiv bestätigt → zurück zum Form mit Hinweis
- $payload = $revocation->validate_token($token);
- if ($payload) {
- $view = 'form';
- $error = 'Bitte bestätigen Sie den Widerruf durch Setzen des Hakens.';
- } else {
- $view = 'error';
- $error = $revocation->get_error();
- }
+ $view = 'form';
+ $error = 'no_confirm';
} else {
- $ip = $_SERVER['REMOTE_ADDR'] ?? '';
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
- if ($revocation->submit($token, $reason_id, $reason_text, $ip, $ua)) {
+ if ($revocation->submit_anonymous(
+ $form_name, $form_order_number, $form_email,
+ $reason_id, $reason_text, $ip, $ua
+ )) {
$view = 'success';
} else {
- $view = 'error';
+ $view = 'form';
$error = $revocation->get_error();
}
}
} else {
- $token = isset($_GET['t']) ? (string)$_GET['t'] : '';
- $payload = $revocation->validate_token($token);
- if (!$payload) {
- $view = 'error';
- $error = $revocation->get_error();
+ // GET — bei Token-Link: Felder vorausfüllen
+ $token = isset($_GET['t']) ? (string)$_GET['t'] : '';
+ if ($token !== '') {
+ $pf = $revocation->prefill_from_token($token);
+ if ($pf) {
+ $prefill = $pf;
+ } elseif ($revocation->get_error() === 'feature_disabled') {
+ $view = 'error';
+ $error = 'feature_disabled';
+ }
+ // Bei anderen Token-Fehlern: leeres Formular anbieten ohne Hinweistext
}
}
-// ------------------------------------------------------------
-// View rendering
-// ------------------------------------------------------------
-
$reasons = $revocation->fetch_reasons();
+$messages = [
+ 'no_confirm' => 'Bitte bestätigen Sie den Widerruf durch Setzen des Hakens.',
+ 'missing_order' => 'Bitte geben Sie Ihre Bestellnummer an.',
+ 'invalid_email' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.',
+ 'order_not_found' => 'Wir konnten keine Bestellung mit dieser Bestellnummer und E-Mail-Adresse finden. '
+ . 'Bitte prüfen Sie Ihre Eingaben.',
+ 'expired' => 'Die 14-tägige Widerrufsfrist ist überschritten. '
+ . 'Wenden Sie sich bitte direkt an uns.',
+ 'already_submitted' => 'Für diese Bestellung wurde bereits ein Widerruf erklärt.',
+ 'feature_disabled' => 'Die elektronische Widerrufsfunktion ist aktuell noch nicht aktiv.',
+ 'not_eligible' => 'Für diese Bestellung steht der elektronische Widerruf nicht zur Verfügung.',
+];
+$display_error = isset($messages[$error]) ? $messages[$error] : null;
+
?>
@@ -98,10 +119,10 @@ $reasons = $revocation->fetch_reasons();
.meta{background:#f4f4f4;padding:12px 16px;border-left:4px solid #c00;
font-size:.9em;margin:16px 0;}
label{display:block;margin:12px 0 4px;font-weight:600;}
- select,textarea,input[type=text]{width:100%;padding:10px;font-size:1em;
+ select,textarea,input[type=text],input[type=email]{width:100%;padding:10px;font-size:1em;
border:1px solid #ccc;border-radius:4px;box-sizing:border-box;}
textarea{min-height:90px;font-family:inherit;}
- .check{margin:20px 0;font-size:.95em;}
+ .check{margin:20px 0;font-size:.95em;font-weight:normal;}
.btn{background:#c00;color:#fff;border:0;padding:14px 28px;
font-size:1.05em;font-weight:bold;border-radius:4px;cursor:pointer;}
.btn:hover{background:#a00;}
@@ -117,22 +138,30 @@ $reasons = $revocation->fetch_reasons();
- Widerruf Ihrer Bestellung
+ Vertrag widerrufen
Gemäß § 355 BGB haben Sie das Recht, Ihren Vertrag binnen 14 Tagen
- ohne Angabe von Gründen zu widerrufen.
+ ohne Angabe von Gründen zu widerrufen. Füllen Sie bitte die folgenden
+ drei Pflichtfelder aus.
-
- Bestellnummer: = htmlspecialchars($payload->order_number ?? ('#'.$payload->order_id)) ?>
- Bestelldatum: = htmlspecialchars($payload->order_date ?? '–') ?>
- E-Mail: = htmlspecialchars($payload->customer_email) ?>
-
+ = htmlspecialchars($display_error) ?>
- = htmlspecialchars($error) ?>
+