448 lines
16 KiB
PHP
448 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* Carteasy – Revocation (elektronischer Widerruf nach EU-RL 2026/2673)
|
||
*
|
||
* Zuständig für:
|
||
* - Erzeugen eines Widerrufs-Tokens bei Bestellabschluss (B2C only)
|
||
* - Validierung des Tokens auf der Landingpage
|
||
* - Verarbeitung der Widerrufserklärung (Status, Mails an Kunde + Shop)
|
||
*
|
||
* Silent-Modus ist möglich, siehe revocation_config.inc.php.
|
||
*
|
||
* @copyright Wlanium / Thomas Bartelt
|
||
* @since 2026-04-19
|
||
*/
|
||
|
||
include_once $_SERVER['DOCUMENT_ROOT'].'/core/config/revocation_config.inc.php';
|
||
include_once $_SERVER['DOCUMENT_ROOT'].'/core/revocationhelper.class.php';
|
||
include_once $_SERVER['DOCUMENT_ROOT'].'/core/mail.class.php';
|
||
|
||
class Revocation {
|
||
|
||
private $base_object;
|
||
private $db;
|
||
private $error = '';
|
||
|
||
public function __construct($base_object) {
|
||
$this->base_object = $base_object;
|
||
$this->db = $base_object->db;
|
||
}
|
||
|
||
public function get_error() {
|
||
return $this->error;
|
||
}
|
||
|
||
// ============================================================
|
||
// 1) Bei Bestellabschluss aufrufen
|
||
// ============================================================
|
||
|
||
/**
|
||
* Legt ein Revocation-Record für eine Bestellung an, sofern B2C.
|
||
* Versendet (wenn Flag an) die Info-Mail mit Widerrufs-Button.
|
||
*
|
||
* @param int $order_id
|
||
* @return bool true bei erfolgreicher Verarbeitung (auch "nicht zuständig" = true)
|
||
*/
|
||
public function create_for_order($order_id) {
|
||
if (!REVOCATION_ENABLED) {
|
||
return true; // Silent-Mode, nichts tun
|
||
}
|
||
|
||
$order_id = (int)$order_id;
|
||
if ($order_id <= 0) {
|
||
$this->error = 'invalid order_id';
|
||
return false;
|
||
}
|
||
|
||
$order = $this->fetch_order($order_id);
|
||
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)) {
|
||
return true; // B2B oder unbekannt → kein Widerrufsbutton
|
||
}
|
||
|
||
if (empty($order->customer_email)) {
|
||
$this->error = 'no customer_email on order';
|
||
return false;
|
||
}
|
||
|
||
// Bereits vorhanden? (UK auf order_id → skippen)
|
||
if ($this->exists_for_order($order_id)) {
|
||
return true;
|
||
}
|
||
|
||
$token_plain = RevocationHelper::generate_token();
|
||
$token_hash = RevocationHelper::hash_token($token_plain);
|
||
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
|
||
|
||
$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;
|
||
$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;
|
||
}
|
||
$stmt->close();
|
||
|
||
if (REVOCATION_INFO_MAIL_ENABLED) {
|
||
$this->send_info_mail($order, $token_plain);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// 2) Landingpage – Token validieren
|
||
// ============================================================
|
||
|
||
/**
|
||
* Prüft Token auf Existenz, Gültigkeit, Status.
|
||
* @return object|false Revocation-Row + Order-Row gemerged, oder false
|
||
*/
|
||
public function validate_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.*,
|
||
o.order_number, o.order_date, o.order_total,
|
||
o.billing_firstname, o.billing_surname,
|
||
o.payment_method AS payment_method_id
|
||
FROM revocations r
|
||
JOIN orders o ON o.id = r.order_id
|
||
WHERE r.token_hash = '$esc'
|
||
LIMIT 1";
|
||
$res = $this->db->query($sql);
|
||
if (!$res || $res->num_rows === 0) {
|
||
$this->error = 'invalid_token';
|
||
return false;
|
||
}
|
||
$row = $res->fetch_object();
|
||
|
||
if ($row->status !== 'pending') {
|
||
$this->error = ($row->status === 'submitted' || $row->status === 'processed')
|
||
? 'already_submitted' : 'not_eligible';
|
||
return false;
|
||
}
|
||
|
||
if (strtotime($row->expires_at) < time()) {
|
||
// Auto-Expire markieren
|
||
$this->db->query("UPDATE revocations SET status='expired' WHERE id = ".(int)$row->id);
|
||
$this->error = 'expired';
|
||
return false;
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
// ============================================================
|
||
// 3) Landingpage – Widerruf einreichen
|
||
// ============================================================
|
||
|
||
/**
|
||
* Kunde hat auf der Landingpage den Widerruf abgeschickt.
|
||
* Validiert nochmal, setzt Status, versendet beide Mails.
|
||
*/
|
||
public function submit($token_plain, $reason_id, $reason_text, $ip, $user_agent) {
|
||
$row = $this->validate_token($token_plain);
|
||
if (!$row) {
|
||
return false;
|
||
}
|
||
|
||
$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;
|
||
}
|
||
$id = (int)$row->id;
|
||
$stmt->bind_param('isssi', $reason_id, $reason_text, $ip_anon, $ua, $id);
|
||
$ok = $stmt->execute();
|
||
$affected = $stmt->affected_rows;
|
||
$stmt->close();
|
||
|
||
if (!$ok || $affected < 1) {
|
||
$this->error = 'update failed';
|
||
return false;
|
||
}
|
||
|
||
// Mails senden (beide, auch wenn eine scheitert wir loggen)
|
||
$fresh = $this->fetch_revocation($id);
|
||
if ($fresh) {
|
||
$this->send_owner_notification($fresh);
|
||
$this->send_customer_confirmation($fresh);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// 4) Admin – als bearbeitet markieren
|
||
// ============================================================
|
||
|
||
public function mark_processed($revocation_id, $admin_note = '') {
|
||
$revocation_id = (int)$revocation_id;
|
||
$stmt = $this->db->prepare(
|
||
"UPDATE revocations
|
||
SET status = 'processed', processed_at = NOW(), admin_note = ?
|
||
WHERE id = ? AND status = 'submitted'"
|
||
);
|
||
$stmt->bind_param('si', $admin_note, $revocation_id);
|
||
$ok = $stmt->execute();
|
||
$stmt->close();
|
||
return $ok;
|
||
}
|
||
|
||
// ============================================================
|
||
// Mails
|
||
// ============================================================
|
||
|
||
private function send_info_mail($order, $token_plain) {
|
||
$mailer = new mail_tools($this->base_object);
|
||
$link = RevocationHelper::build_link($token_plain);
|
||
$name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? ''));
|
||
$order_number = $order->order_number ?: ('#'.$order->id);
|
||
|
||
$subject = 'Ihre Widerrufsrechte zu Bestellung '.$order_number;
|
||
|
||
$msg = '<p>Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',</p>';
|
||
$msg .= '<p>vielen Dank für Ihre Bestellung <strong>'.htmlspecialchars($order_number).'</strong>.</p>';
|
||
$msg .= '<h3>Widerrufsrecht</h3>';
|
||
$msg .= '<p>Sie haben das Recht, binnen 14 Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. '
|
||
. 'Die Widerrufsfrist beträgt 14 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.</p>';
|
||
$msg .= '<p>Um Ihr Widerrufsrecht elektronisch auszuüben, klicken Sie bitte auf den folgenden Button:</p>';
|
||
$msg .= '<p style="margin:24px 0;text-align:center;">'
|
||
. '<a href="'.htmlspecialchars($link).'" '
|
||
. 'style="background:#c00;color:#fff;padding:14px 28px;text-decoration:none;'
|
||
. 'font-weight:bold;border-radius:4px;display:inline-block;">'
|
||
. htmlspecialchars(REVOCATION_BUTTON_LABEL)
|
||
. '</a></p>';
|
||
$msg .= '<p style="font-size:0.85em;color:#666;">Falls der Button nicht funktioniert, kopieren Sie bitte '
|
||
. 'diesen Link in Ihren Browser:<br><code>'.htmlspecialchars($link).'</code></p>';
|
||
$msg .= '<p>Der Link ist bis '.date('d.m.Y', strtotime(RevocationHelper::calc_expires_at($order->order_date))).' gültig.</p>';
|
||
$msg .= '<p>Alternativ können Sie uns Ihren Widerruf auch formlos (E-Mail, Brief) mitteilen. '
|
||
. 'Unsere ausführliche Widerrufsbelehrung finden Sie auf unserer Website.</p>';
|
||
$msg .= '<hr><p style="font-size:0.85em;color:#666;">'.REVOCATION_RETURN_COMPANY.'</p>';
|
||
|
||
return $mailer->send_mail($subject, $msg, $order->customer_email);
|
||
}
|
||
|
||
private function send_owner_notification($revocation) {
|
||
$mailer = new mail_tools($this->base_object);
|
||
|
||
// Order + Payment-Method-Label + Reason-Label nachladen
|
||
$order_id = (int)$revocation->order_id;
|
||
$order = $this->fetch_order_with_payment($order_id);
|
||
$reason_label = $this->fetch_reason_label($revocation->reason_id);
|
||
|
||
$order_number = $order->order_number ?? ('#'.$order_id);
|
||
$subject = 'Widerruf eingegangen – Bestellung '.$order_number;
|
||
|
||
$msg = '<h2>Widerruf eingegangen</h2>';
|
||
$msg .= '<table cellpadding="4" cellspacing="0" border="0" style="font-family:Arial,sans-serif;">';
|
||
$msg .= $this->mail_row('Bestellnummer', $order_number);
|
||
$msg .= $this->mail_row('Bestelldatum', $order->order_date ?? '');
|
||
$msg .= $this->mail_row('Kunde', trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? '')));
|
||
$msg .= $this->mail_row('E-Mail', $revocation->customer_email);
|
||
$msg .= $this->mail_row('Bestellsumme', number_format((float)($order->order_total ?? 0), 2, ',', '.').' EUR');
|
||
$msg .= $this->mail_row('Zahlungsart', $order->payment_method_name ?? '–');
|
||
$msg .= $this->mail_row('Widerrufsdatum', $revocation->submitted_at);
|
||
$msg .= $this->mail_row('Grund', $reason_label ?: '(kein Grund angegeben)');
|
||
if (!empty($revocation->reason_text)) {
|
||
$msg .= $this->mail_row('Anmerkung', nl2br(htmlspecialchars($revocation->reason_text)));
|
||
}
|
||
$msg .= $this->mail_row('IP (anon.)', $revocation->ip_anonymized ?? '–');
|
||
$msg .= '</table>';
|
||
$msg .= '<p style="margin-top:20px;"><strong>Bitte Rückzahlung über die Warenwirtschaft '
|
||
. 'veranlassen.</strong> Die Zahlung muss über dieselbe Zahlungsart wie die ursprüngliche '
|
||
. 'Bestellung erfolgen.</p>';
|
||
$msg .= '<p>Admin: <a href="https://'.($_SERVER['HTTP_HOST'] ?? 'www.intelectra.de')
|
||
. '/index.php?admin_modul=admin_revocation_list">Widerrufsliste öffnen</a></p>';
|
||
|
||
$ok = $mailer->send_mail($subject, $msg, REVOCATION_OWNER_EMAIL);
|
||
|
||
if ($ok) {
|
||
$this->db->query(
|
||
"UPDATE revocations SET owner_notified_at = NOW() WHERE id = ".(int)$revocation->id
|
||
);
|
||
}
|
||
return $ok;
|
||
}
|
||
|
||
private function send_customer_confirmation($revocation) {
|
||
$mailer = new mail_tools($this->base_object);
|
||
|
||
$order = $this->fetch_order((int)$revocation->order_id);
|
||
$order_number = $order->order_number ?? ('#'.$revocation->order_id);
|
||
$name = trim(($order->billing_firstname ?? '').' '.($order->billing_surname ?? ''));
|
||
|
||
$subject = 'Bestätigung Ihres Widerrufs – Bestellung '.$order_number;
|
||
|
||
$msg = '<p>Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',</p>';
|
||
$msg .= '<p>hiermit bestätigen wir den Eingang Ihrer Widerrufserklärung zu Bestellung '
|
||
. '<strong>'.htmlspecialchars($order_number).'</strong> am '
|
||
. date('d.m.Y H:i', strtotime($revocation->submitted_at)).' Uhr.</p>';
|
||
$msg .= '<h3>Nächste Schritte</h3>';
|
||
$msg .= '<p>Bitte senden Sie die Waren unverzüglich, spätestens binnen 14 Tagen, an:</p>';
|
||
$msg .= '<p style="background:#f4f4f4;padding:12px;border-left:4px solid #c00;">'
|
||
. '<strong>'.REVOCATION_RETURN_COMPANY.'</strong><br>'
|
||
. REVOCATION_RETURN_STREET.'<br>'
|
||
. REVOCATION_RETURN_CITY.'</p>';
|
||
$msg .= '<p>'.REVOCATION_RETURN_COSTS_NOTE.'</p>';
|
||
$msg .= '<p>Die Rückzahlung erfolgt innerhalb von 14 Tagen nach Eingang Ihres Widerrufs über '
|
||
. 'dieselbe Zahlungsart, die Sie bei der ursprünglichen Bestellung verwendet haben – '
|
||
. 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart.</p>';
|
||
$msg .= '<p>Bei Rückfragen erreichen Sie uns jederzeit unter '
|
||
. '<a href="mailto:'.REVOCATION_OWNER_EMAIL.'">'.REVOCATION_OWNER_EMAIL.'</a>.</p>';
|
||
$msg .= '<hr><p style="font-size:0.85em;color:#666;">'.REVOCATION_RETURN_COMPANY.'</p>';
|
||
|
||
$ok = $mailer->send_mail($subject, $msg, $revocation->customer_email);
|
||
|
||
if ($ok) {
|
||
$this->db->query(
|
||
"UPDATE revocations SET customer_confirmed_at = NOW() WHERE id = ".(int)$revocation->id
|
||
);
|
||
}
|
||
return $ok;
|
||
}
|
||
|
||
private function mail_row($label, $value) {
|
||
return '<tr><td style="color:#666;padding-right:12px;">'.htmlspecialchars($label)
|
||
. ':</td><td><strong>'.($value ?: '–').'</strong></td></tr>';
|
||
}
|
||
|
||
// ============================================================
|
||
// Queries
|
||
// ============================================================
|
||
|
||
private function fetch_order($order_id) {
|
||
$order_id = (int)$order_id;
|
||
$sql = "SELECT id, order_number, order_date, order_total, customer_id,
|
||
customer_email, billing_firstname, billing_surname,
|
||
payment_method
|
||
FROM orders WHERE id = $order_id LIMIT 1";
|
||
$res = $this->db->query($sql);
|
||
if (!$res || $res->num_rows === 0) return false;
|
||
return $res->fetch_object();
|
||
}
|
||
|
||
private function fetch_order_with_payment($order_id) {
|
||
$order_id = (int)$order_id;
|
||
$sql = "SELECT o.id, o.order_number, o.order_date, o.order_total,
|
||
o.billing_firstname, o.billing_surname,
|
||
pm.name AS payment_method_name
|
||
FROM orders o
|
||
LEFT JOIN payment_methods pm ON pm.id = o.payment_method
|
||
WHERE o.id = $order_id LIMIT 1";
|
||
$res = $this->db->query($sql);
|
||
if (!$res || $res->num_rows === 0) return false;
|
||
return $res->fetch_object();
|
||
}
|
||
|
||
private function fetch_revocation($id) {
|
||
$id = (int)$id;
|
||
$res = $this->db->query("SELECT * FROM revocations WHERE id = $id LIMIT 1");
|
||
if (!$res || $res->num_rows === 0) return false;
|
||
return $res->fetch_object();
|
||
}
|
||
|
||
private function fetch_reason_label($reason_id) {
|
||
if (empty($reason_id)) return null;
|
||
$reason_id = (int)$reason_id;
|
||
$res = $this->db->query("SELECT label FROM revocation_reasons WHERE id = $reason_id LIMIT 1");
|
||
if (!$res || $res->num_rows === 0) return null;
|
||
return $res->fetch_object()->label;
|
||
}
|
||
|
||
private function exists_for_order($order_id) {
|
||
$order_id = (int)$order_id;
|
||
$res = $this->db->query("SELECT 1 FROM revocations WHERE order_id = $order_id LIMIT 1");
|
||
return ($res && $res->num_rows > 0);
|
||
}
|
||
|
||
/**
|
||
* Öffentliche Liste für Admin-View.
|
||
*/
|
||
public function list_all($status = null, $limit = 200) {
|
||
$limit = (int)$limit;
|
||
$where = '';
|
||
if ($status !== null && $status !== '') {
|
||
$status = $this->db->real_escape_string($status);
|
||
$where = "WHERE r.status = '$status'";
|
||
}
|
||
$sql = "SELECT r.id, r.order_id, r.customer_email, r.status,
|
||
r.submitted_at, r.processed_at, r.owner_notified_at,
|
||
o.order_number, o.order_date
|
||
FROM revocations r
|
||
LEFT JOIN orders o ON o.id = r.order_id
|
||
$where
|
||
ORDER BY r.created_at DESC
|
||
LIMIT $limit";
|
||
$res = $this->db->query($sql);
|
||
$out = [];
|
||
if ($res) {
|
||
while ($obj = $res->fetch_object()) $out[] = $obj;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
public function fetch_reasons() {
|
||
$res = $this->db->query(
|
||
"SELECT id, label FROM revocation_reasons WHERE active = 1 ORDER BY sort, id"
|
||
);
|
||
$out = [];
|
||
if ($res) {
|
||
while ($o = $res->fetch_object()) $out[] = $o;
|
||
}
|
||
return $out;
|
||
}
|
||
}
|