feat(widerruf): Anonymous-Pfad + Token-Prefill für § 356a-Konformität
Deploy to Dev / deploy (push) Successful in 0s

- 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
This commit is contained in:
Thomas Bartelt
2026-06-14 23:02:54 +02:00
parent afd2cedf92
commit 68a03080d1
2 changed files with 258 additions and 59 deletions
+179
View File
@@ -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();
}
}