shop-old/core/revocation.class.php
2026-04-20 01:03:43 +02:00

448 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}