Initial import: Carteasy Shop (Dev-Stand)

This commit is contained in:
Thomas Bartelt
2026-04-20 01:03:43 +02:00
commit d504f8a05d
3845 changed files with 761050 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
<?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;
}
}