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 = '
Sehr geehrte Kundin, sehr geehrter Kunde'.($name ? ' '.htmlspecialchars($name) : '').',
';
$msg .= 'vielen Dank für Ihre Bestellung '.htmlspecialchars($order_number).'.
';
$msg .= 'Widerrufsrecht
';
$msg .= '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.
';
$msg .= 'Um Ihr Widerrufsrecht elektronisch auszuüben, klicken Sie bitte auf den folgenden Button:
';
$msg .= ''
. ''
. htmlspecialchars(REVOCATION_BUTTON_LABEL)
. '
';
$msg .= 'Falls der Button nicht funktioniert, kopieren Sie bitte '
. 'diesen Link in Ihren Browser:
'.htmlspecialchars($link).'
';
$msg .= 'Der Link ist bis '.date('d.m.Y', strtotime(RevocationHelper::calc_expires_at($order->order_date))).' gültig.
';
$msg .= 'Alternativ können Sie uns Ihren Widerruf auch formlos (E-Mail, Brief) mitteilen. '
. 'Unsere ausführliche Widerrufsbelehrung finden Sie auf unserer Website.
';
$msg .= '
'.REVOCATION_RETURN_COMPANY.'
';
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 = 'Widerruf eingegangen
';
$msg .= '';
$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 .= '
';
$msg .= 'Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.
';
$msg .= 'Admin: Widerrufsliste öffnen
';
$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 ?? ''));
$ts = strtotime($revocation->submitted_at);
$datum = date('d.m.Y', $ts);
$uhrzeit = date('H:i', $ts);
$ok = $this->send_neutral_eingangsbestaetigung(
$revocation->customer_email,
$name,
$revocation->customer_email,
$order_number,
$datum,
$uhrzeit,
$revocation->reason_text ?? ''
);
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 '| '.htmlspecialchars($label)
. ': | '.($value ?: '–').' |
';
}
// ============================================================
// 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;
}
// ============================================================
// 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.
* Pflichtfelder: Name + E-Mail. Button-Klick = Erklärung.
* Bestellnummer + Artikel sind OPTIONAL (Händlerbund-Linie). Wenn Order
* gefunden → DB-Tracking. Sonst → reiner Anonym-Pfad mit Owner-Mail zur
* manuellen Zuordnung. Der Widerruf wird in jedem Fall entgegengenommen.
*/
public function submit_anonymous($name, $order_number, $email, $article, $ip, $user_agent) {
if (!REVOCATION_ENABLED) {
$this->error = 'feature_disabled';
return false;
}
$name = trim((string)$name);
$order_number = trim((string)$order_number);
$email = trim(strtolower((string)$email));
$article = trim((string)$article);
if ($name === '') {
$this->error = 'missing_name';
return false;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error = 'invalid_email';
return false;
}
// Order-Lookup nur informativ. Wenn keine Order angegeben oder
// keine gefunden oder nicht widerrufsfähig → Anonym-Pfad.
$order = ($order_number !== '')
? $this->fetch_order_by_number_email($order_number, $email)
: null;
if (!$order) {
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id);
if (!RevocationHelper::is_b2c($group_id)) {
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
$expires_at = RevocationHelper::calc_expires_at($order->order_date);
if (strtotime($expires_at) < time()) {
return $this->process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent);
}
// 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 = null;
$reason_text = ($article !== '' && mb_strlen($article) > 2000)
? mb_substr($article, 0, 2000)
: $article;
$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();
}
// ============================================================
// Anonym-Pfad: ohne (passende) Bestellung
// ============================================================
/**
* Widerruf ohne Order-Match.
* Kein DB-Eintrag, dafür Owner-Mail mit Hinweis auf manuelle Zuordnung
* und Eingangsbestätigung an den Kunden.
*/
private function process_anonymous_unmatched($name, $order_number, $email, $article, $ip, $user_agent) {
$ip_anon = RevocationHelper::anonymize_ip($ip);
$ua = $user_agent ? mb_substr((string)$user_agent, 0, 255) : null;
$article = trim((string)$article);
if (mb_strlen($article) > 2000) {
$article = mb_substr($article, 0, 2000);
}
$this->send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua);
$this->send_anonymous_customer_confirmation($name, $order_number, $email, $article);
return true;
}
private function send_anonymous_owner_notification($name, $order_number, $email, $article, $ip_anon, $ua) {
$mailer = new mail_tools($this->base_object);
$subject = 'Widerruf eingegangen (manuelle Zuordnung erforderlich)';
$msg = 'Widerruf eingegangen
';
$msg .= '';
$msg .= 'Bitte manuell zuordnen. Der Widerruf wurde ohne (oder mit nicht gefundener) Bestellnummer eingereicht. ';
$msg .= 'Es wurde kein automatischer DB-Eintrag erzeugt.';
$msg .= '
';
$msg .= '';
$msg .= $this->mail_row('Name', htmlspecialchars($name));
$msg .= $this->mail_row('E-Mail', htmlspecialchars($email));
$msg .= $this->mail_row('Bestellnummer', $order_number !== '' ? htmlspecialchars($order_number) : '(nicht angegeben)');
$msg .= $this->mail_row('Artikel/Bemerkung', $article !== '' ? nl2br(htmlspecialchars($article)) : '(nicht angegeben)');
$msg .= $this->mail_row('Datum/Uhrzeit', date('d.m.Y H:i:s'));
$msg .= $this->mail_row('IP (anon.)', $ip_anon ?? '–');
$msg .= '
';
$msg .= 'Bitte prüfen und E-Mail an die Kundin / den Kunden schreiben.
';
return $mailer->send_mail($subject, $msg, REVOCATION_OWNER_EMAIL);
}
private function send_anonymous_customer_confirmation($name, $order_number, $email, $article) {
return $this->send_neutral_eingangsbestaetigung(
$email,
$name,
$email,
$order_number !== '' ? $order_number : '(nicht angegeben)',
date('d.m.Y'),
date('H:i'),
$article
);
}
/**
* Neutrale Eingangsbestätigung gemäß Händlerbund-Vorlage.
*
* WICHTIG: Diese E-Mail darf NUR den Eingang der Widerrufserklärung
* bestätigen und KEINE wirksame Bestätigung des Widerrufs selbst sein.
* Keine Rücksendeadresse, keine Frist-Hinweise, keine Werbung.
*
* @param string $to Empfänger-Mail
* @param string $consumer_name Name des Verbrauchers für die Anrede
* @param string $consumer_email E-Mail aus dem Formular (für „Inhalt")
* @param string $order_number Bestellnr aus dem Formular ('' = nicht angegeben)
* @param string $date_str Eingangsdatum (dd.mm.yyyy)
* @param string $time_str Eingangsuhrzeit (HH:MM)
* @param string $article Artikel/Bemerkung (optional)
*/
private function send_neutral_eingangsbestaetigung($to, $consumer_name, $consumer_email,
$order_number, $date_str, $time_str, $article) {
$mailer = new mail_tools($this->base_object);
$subject = 'Ihr Widerruf ist bei uns eingegangen';
$name_safe = htmlspecialchars(trim((string)$consumer_name));
$anrede = $name_safe !== '' ? 'Hallo '.$name_safe.',' : 'Hallo,';
$msg = ''.$anrede.'
';
$msg .= 'wir bestätigen hiermit den Eingang Ihres Widerrufs mit folgendem Inhalt:
';
$msg .= '';
$msg .= '- Name: '.$name_safe.'
';
$msg .= '- E-Mail: '.htmlspecialchars($consumer_email).'
';
$msg .= '- Bestellnummer: '.htmlspecialchars($order_number).'
';
if (!empty($article)) {
$msg .= '- Artikel/Bemerkung: '.nl2br(htmlspecialchars($article)).'
';
}
$msg .= '- Eingang am: '.htmlspecialchars($date_str).' um '.htmlspecialchars($time_str).' Uhr
';
$msg .= '
';
$msg .= 'Wir prüfen Ihr Anliegen und melden uns zeitnah bei Ihnen.
';
$msg .= 'Viele Grüße
Ihre '.htmlspecialchars(REVOCATION_RETURN_COMPANY).'
';
return $mailer->send_mail($subject, $msg, $to);
}
}