diff --git a/core/cart_session_manager.class.php b/core/cart_session_manager.class.php
index 82e458a..3e85696 100644
--- a/core/cart_session_manager.class.php
+++ b/core/cart_session_manager.class.php
@@ -176,12 +176,27 @@ class CartSessionManager {
public function updateCartStatus($session_key, $status) {
$session_key_clean = $this->db->real_escape_string($session_key);
$status_clean = $this->db->real_escape_string($status);
-
+
+ // 25.08.2026: 'completed' ist ein Endzustand -- Geld ist eingezogen, daran aendert
+ // ein spaeterer Request nichts mehr. Vorher konnte jeder Reload der Rueckkehr-URL
+ // eine bezahlte Bestellung auf 'abandoned' zuruecksetzen (belegt am 24.08. bei
+ // Order 111902 und beim Kunden EC-6PP77504DT5715642). Die Statistik zaehlte solche
+ // Faelle als Abbruch, obwohl bezahlt wurde.
+ //
+ // Der Riegel sitzt hier unten in der WHERE-Klausel und nicht im aufrufenden Code,
+ // damit er auch fuer kuenftige Aufrufer gilt -- und weil MySQL das atomar
+ // entscheidet: zwei gleichzeitig zurueckkehrende Requests koennen sich nicht mehr
+ // gegenseitig ueberholen.
+ $schutz = '';
+ if ($status_clean !== 'completed') {
+ $schutz = " AND status <> 'completed'";
+ }
+
$query = "
- UPDATE cart_sessions
- SET status = '{$status_clean}',
- updated_at = CURRENT_TIMESTAMP
- WHERE session_key = '{$session_key_clean}'
+ UPDATE cart_sessions
+ SET status = '{$status_clean}',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE session_key = '{$session_key_clean}'{$schutz}
";
$result = $this->db->query($query);
diff --git a/core/fatal_log.php b/core/fatal_log.php
new file mode 100644
index 0000000..cfbb442
--- /dev/null
+++ b/core/fatal_log.php
@@ -0,0 +1,142 @@
+ 5242880) {
+ @rename($datei, $datei . '.1');
+ }
+
+ $anfrage = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-';
+ $methode = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '-';
+ $referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '-';
+ $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '-';
+ $agent = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 120) : '-';
+
+ // Die Meldung einer ungefangenen Exception bringt den Stack-Trace mit. Der ist
+ // das Wertvollste daran, aber mehrzeilig -- fuer eine Zeile pro Fehler wird er
+ // auf die ersten Ebenen eingedampft.
+ $meldung = preg_replace('/\s+/', ' ', trim($fehler['message']));
+ if (strlen($meldung) > 600) {
+ $meldung = substr($meldung, 0, 600) . ' …';
+ }
+
+ $zeile = sprintf(
+ "[%s] FATAL | %s | %s:%d | %s %s | ref=%s | ip=%s | ua=%s\n",
+ date('Y-m-d H:i:s'),
+ $meldung,
+ $fehler['file'],
+ $fehler['line'],
+ $methode,
+ $anfrage,
+ $referer,
+ $ip,
+ $agent
+ );
+
+ @file_put_contents($datei, $zeile, FILE_APPEND | LOCK_EX);
+ } catch (Throwable $e) {
+ // Ein Protokoll, das selbst Fehler wirft, ist schlimmer als keins.
+ }
+ }
+
+ /**
+ * Wurde genau dieser Fehler gerade eben schon protokolliert?
+ *
+ * Schluessel ist Datei + Zeile + Meldung, Sperrfrist 60 Sekunden. Die Merkliste liegt
+ * neben dem Log und wird nur im Fehlerfall angefasst -- im Normalbetrieb kostet diese
+ * Datei nichts.
+ */
+ function intelectra_fatal_log_schon_gesehen($fehler) {
+ $merkdatei = __DIR__ . '/../logs/.fatal_seen.json';
+ $schluessel = md5($fehler['file'] . ':' . $fehler['line'] . ':' . $fehler['message']);
+ $jetzt = time();
+
+ $gesehen = array();
+ if (is_readable($merkdatei)) {
+ $roh = @file_get_contents($merkdatei);
+ if ($roh) {
+ $dekodiert = @json_decode($roh, true);
+ if (is_array($dekodiert)) {
+ $gesehen = $dekodiert;
+ }
+ }
+ }
+
+ if (isset($gesehen[$schluessel]) && ($jetzt - (int)$gesehen[$schluessel]) < 60) {
+ return true;
+ }
+
+ // Abgelaufene Eintraege raus, damit die Datei nicht waechst.
+ foreach ($gesehen as $k => $zeitpunkt) {
+ if (($jetzt - (int)$zeitpunkt) > 3600) {
+ unset($gesehen[$k]);
+ }
+ }
+
+ $gesehen[$schluessel] = $jetzt;
+
+ if (count($gesehen) > 200) {
+ $gesehen = array_slice($gesehen, -200, 200, true);
+ }
+
+ @file_put_contents($merkdatei, json_encode($gesehen), LOCK_EX);
+
+ return false;
+ }
+}
+
+/* EOF */
diff --git a/core/paymentmethods/paypalpaymentmethod.class.php b/core/paymentmethods/paypalpaymentmethod.class.php
index 8c4ec19..869177b 100644
--- a/core/paymentmethods/paypalpaymentmethod.class.php
+++ b/core/paymentmethods/paypalpaymentmethod.class.php
@@ -15,6 +15,10 @@ include_once('core/paypal/nvp.class.php');
include_once('core/paypal/expresscheckout.class.php');
include_once('core/cart_session_manager.class.php');
+// 25.08.2026: Ereignisprotokoll in PG -- liefert die Uhrzeiten, die `orders` nicht hat.
+// Faellt still aus, wenn Postgres nicht erreichbar ist (siehe shop_events.php).
+include_once($_SERVER['DOCUMENT_ROOT'].'/core/postgres/shop_events.php');
+
class PaypalPaymentMethod {
@@ -54,8 +58,13 @@ class PaypalPaymentMethod {
$this->gift_certificate = false;
$this->order_id = false;
- $this->return_url = 'http://'.$_SERVER['SERVER_NAME'].'/Kasse/';
- $this->cancel_url = $this->return_url.'&cancel=true';
+ // 24.08.2026: https statt http -- die alte URL schickte jeden PayPal-Rueckkehrer
+ // erst durch eine 301-Umleitung, mit dem Token im Klartext.
+ // Und "?cancel=true" statt "&cancel=true": ohne "?" war der Query-String kaputt,
+ // $_GET['cancel'] wurde nie gesetzt und der Cancel-Zweig in change_order() lief
+ // ins Leere -- wer bei PayPal abgebrochen hat, landete auf einer toten URL.
+ $this->return_url = 'https://'.$_SERVER['SERVER_NAME'].'/Kasse/';
+ $this->cancel_url = $this->return_url.'?cancel=true';
} // end __construct
@@ -131,6 +140,41 @@ class PaypalPaymentMethod {
// Continue anyway - fallback to original session handling
}
+ // 24.08.2026 -- Post/Redirect/Get.
+ // Bis hierher kam der Kunde per POST auf /Kasse/. Wuerde jetzt direkt das
+ // Template mit dem PayPal-Link folgen, bliebe dieser POST im Browser stehen:
+ // F5 oder "Zurueck" schicken ihn erneut ab und legen eine zweite Bestellung an.
+ // Genau das war der Weg in "ORDER PREVENTED: Empty cart" (der Warenkorb war zu
+ // dem Zeitpunkt schon geloescht). Deshalb 302 auf eine reine GET-Seite --
+ // ein Reload dort laedt nur noch den Link neu.
+ //
+ // Der pending-Eintrag ist der zweite Riegel: kommt trotzdem noch einmal ein
+ // ready2fly-POST mit demselben Warenkorb, greift website_cashdesk.php darauf
+ // zurueck, statt eine weitere Order anzulegen.
+ $_SESSION['easyshop']['paypal_pending'] = array(
+ 'token' => $token,
+ 'order_id' => $this->order_id,
+ 'total' => round((float)$order_total, 2),
+ 'time' => time()
+ );
+
+ if (function_exists('shop_event')) {
+ shop_event('payment_started', array(
+ 'order_id' => $this->order_id,
+ 'payment_method' => 4,
+ 'betrag' => $order_total,
+ 'paypal_token' => $token,
+ 'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null
+ ));
+ }
+
+ if (!headers_sent()) {
+ header('Location: /Kasse/?pp_open='.urlencode($token));
+ exit;
+ }
+
+ // Fallback: wurde wider Erwarten schon Output gesendet, bleibt es beim
+ // bisherigen Verhalten -- Template mit Link, ohne PRG.
$call = 'payment_open';
} else {
$message = 'Beim Initialisieren von PayPal ist ein Fehler aufgetreten.';
@@ -163,12 +207,57 @@ class PaypalPaymentMethod {
$order = new Order($this->base_object);
+ // 24.08.2026 -- Riegel VOR allen drei Zweigen: ist diese Bestellung schon bezahlt?
+ //
+ // PayPal-Tokens sind Einwegtickets. Ruft jemand die Rueckkehr-Adresse ein zweites
+ // Mal auf -- Zurueck-Taste, Reload, zweiter Tab -- scheitert jede weitere Nachfrage
+ // bei PayPal zwangslaeufig. Der Code stempelte die Bestellung daraufhin auf
+ // "abandoned" und zeigte eine Fehlerseite. Einem Kunden, der laengst bezahlt hat.
+ //
+ // Belegt am Testkauf 24.08.2026 (Order 111902, Token EC-2G607902TV9783051):
+ // 23:26:45 Updated ... to completed <- Geld eingezogen
+ // 23:26:56 Updated ... to abandoned <- Reload der Rueckkehr-Adresse
+ // 23:27:01 Updated ... to abandoned
+ // Dasselbe Muster am selben Tag bei einem echten Kunden (EC-6PP77504DT5715642).
+ // Ein Teil der vermeintlichen "Abbrueche" in der Statistik sind also bezahlte
+ // Bestellungen mit Fehlerseite.
+ //
+ // Deckt auch den cancel-Zweig ab: eine bezahlte Bestellung darf nicht nachtraeglich
+ // auf "storniert" gesetzt werden, nur weil jemand auf einem alten Tab abbricht.
+ $bezahlt_token = isset($_GET['token']) ? $_GET['token'] : '';
+
+ if ($bezahlt_token) {
+ $bezahlte_order = $order->get_order_by_paypal_token($bezahlt_token);
+
+ if (is_object($bezahlte_order) && !empty($bezahlte_order->paypal_transaction_id)) {
+ error_log("PayPal Fix: Rueckkehr auf bereits bezahlte Order "
+ . $bezahlte_order->id . " (Token {$bezahlt_token}) -- zeige Bestaetigung, "
+ . "kein erneuter PayPal-Aufruf");
+
+ // Status geraderuecken, falls ihn ein frueherer Fehlversuch verbogen hat.
+ $this->cart_session_manager->updateCartStatus($bezahlt_token, 'completed');
+ unset($_SESSION['easyshop']['paypal_pending']);
+
+ $this->layout_object->assign('order_number', $bezahlte_order->order_number);
+ $this->layout_object->assign('customer_number', $bezahlte_order->customer_number);
+ $this->layout_object->assign('call', 'payment_confirmed');
+
+ return $this->layout_object->_fetch('content_cash_desk/paypal.tpl');
+ }
+ }
+
if (isset($_GET['confirm'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerId'];
$order_data = $order->get_order_by_paypal_token($token);
-
+
+ // 24.08.2026: Session-ID aus dem Moment VOR dem PayPal-Sprung. Der Warenkorb
+ // liegt in shopping_cart_items an der session_id -- kommt der Kunde mit einer
+ // neuen Session zurueck (iOS/Safari verliert das Cookie gern), zeigt
+ // session_id() ins Leere und der alte Warenkorb bliebe stehen.
+ $orig_session_id = '';
+
// PAYPAL FIX: Restore cart session data after PayPal return
$cart_session = $this->cart_session_manager->retrieveCartSession($token);
if ($cart_session && $cart_session['cart_data']) {
@@ -184,7 +273,8 @@ class PaypalPaymentMethod {
$this->shipping_method = $cart_data['shipping_method'] ?? $this->shipping_method;
$this->gift_certificate = $cart_data['gift_certificate'] ?? $this->gift_certificate;
$this->order_id = $cart_data['order_id'] ?? $this->order_id;
-
+ $orig_session_id = $cart_data['php_session_id'] ?? '';
+
// Debug log
error_log("PayPal Fix: Restored order_id: {$this->order_id}, cart items: " . count($this->shoppingcart ?? []));
} else {
@@ -209,25 +299,84 @@ class PaypalPaymentMethod {
// PAYPAL FIX: Mark cart session as completed
$this->cart_session_manager->updateCartStatus($token, 'completed');
+ // 24.08.2026: JETZT erst den Warenkorb leeren -- Geld ist eingezogen.
+ // Vorher passierte das in website_cashdesk::after_saving_order(), also
+ // bevor PayPal ueberhaupt gefragt war. Wer dort abbrach oder neu lud,
+ // stand ohne Warenkorb da und musste alles neu zusammensuchen; genau
+ // daher stammen die Bestellpaare mit identischem Betrag.
+ $this->clear_cart_after_payment($orig_session_id);
+ unset($_SESSION['easyshop']['paypal_pending']);
+
+ // 24.08.2026: Bestellbestaetigung JETZT ausloesen, nicht schon bei der
+ // Order-Anlage. Vorher ging sie in website_cashdesk::save_order() raus --
+ // also bevor der Kunde ueberhaupt bei PayPal war. Beim Testkauf lagen
+ // zwischen Mail (23:25:09) und eingezogener Zahlung (23:26:45) anderthalb
+ // Minuten, und wer nie bezahlt hat, bekam trotzdem eine Bestaetigung.
+ $this->bestellbestaetigung_senden($order_data);
+
+ if (function_exists('shop_event')) {
+ shop_event('paid', array(
+ 'order_id' => $order_data->id,
+ 'order_number' => $order_data->order_number,
+ 'payment_method' => 4,
+ 'betrag' => $order->get_order_total_by_id($order_data->id),
+ 'paypal_token' => $token,
+ 'transaction_id' => isset($paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'])
+ ? $paypal_raw_data['PAYMENTINFO_0_TRANSACTIONID'] : null,
+ 'customer_id' => $order_data->customer_id
+ ));
+ }
+
$call = 'payment_confirmed';
} else {
$this->layout_object->assign('message', 'Beim Bezahlabschluss ist ein Fehler aufgetreten. ');
-
+
// PAYPAL FIX: Mark cart session as abandoned
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
-
+
+ if (function_exists('shop_event')) {
+ shop_event('failed', array(
+ 'order_id' => isset($order_data->id) ? $order_data->id : null,
+ 'order_number' => isset($order_data->order_number) ? $order_data->order_number : null,
+ 'payment_method' => 4,
+ 'paypal_token' => $token,
+ 'detail' => 'DoExpressCheckoutPayment abgelehnt'
+ ));
+ }
+
$call = 'payment_error';
}
} else if (isset($_GET['cancel'])) {
$token = $_GET['token'] ?? '';
-
+
// PAYPAL FIX: Mark cart session as abandoned on cancel
if ($token) {
$this->cart_session_manager->updateCartStatus($token, 'abandoned');
+
+ // 24.08.2026: $order_data war in diesem Zweig nie gesetzt -- der Aufruf
+ // unten lief auf ein undefiniertes Objekt und setzte im Zweifel Order 0
+ // auf "storniert". Die Order gehoert ueber den Token geholt.
+ $order_data = $order->get_order_by_paypal_token($token);
+ if (isset($order_data->id) && (int)$order_data->id > 0) {
+ $order->set_order_status('6', $order_data->id);
+ }
}
-
+
+ // Der Warenkorb bleibt jetzt stehen (er wird erst nach erfolgreicher Zahlung
+ // geleert) -- der Kunde kann direkt eine andere Zahlart waehlen, statt alles
+ // neu zusammenzusuchen.
+ unset($_SESSION['easyshop']['paypal_pending']);
+
+ if (function_exists('shop_event')) {
+ shop_event('cancelled', array(
+ 'order_id' => isset($order_data->id) ? $order_data->id : null,
+ 'payment_method' => 4,
+ 'paypal_token' => $token,
+ 'detail' => 'Kunde hat bei PayPal abgebrochen'
+ ));
+ }
+
$call = 'payment_cancelled';
- $order->set_order_status('6', $order_data->id ?? 0);
} else if (isset($_GET['token'])) {
$token = $_GET['token'];
$payer_id = $_GET['PayerID'];
@@ -267,6 +416,79 @@ class PaypalPaymentMethod {
} // end change_order
+ /**
+ * Loest die Bestellbestaetigung aus -- erst nach eingezogener Zahlung (24.08.2026).
+ *
+ * Der Versand haengt am Ticket-Event 3 (Cs_ticket::event), das bisher in
+ * website_cashdesk::save_order() direkt nach der Order-Anlage lief. Bei PayPal war das
+ * zu frueh: die Bestellung existiert dort schon, bevor der Kunde zu PayPal geschickt
+ * wird. Jeder Abbrecher bekam eine Bestellbestaetigung fuer etwas, das er nie bezahlt
+ * hat. Bei den zuletzt gemessenen Zahlen sind das rund ein Dutzend falsche
+ * Bestaetigungen pro Woche.
+ *
+ * Fuer alle anderen Zahlarten bleibt es, wo es war -- dort ist die Bestellung mit dem
+ * Abschicken tatsaechlich verbindlich.
+ *
+ * Wirft der Versand einen Fehler, wird er protokolliert und geschluckt: die Zahlung ist
+ * an dieser Stelle bereits eingezogen, eine Ausnahme wuerde dem Kunden eine Fehlerseite
+ * fuer eine erfolgreiche Bestellung zeigen.
+ *
+ * @param object $order_data Datensatz aus Order::get_order_by_paypal_token()
+ */
+ private function bestellbestaetigung_senden($order_data) {
+ if (!is_object($order_data) || empty($order_data->id)) {
+ error_log('PayPal Fix: Bestellbestaetigung uebersprungen -- keine Order-Daten');
+ return;
+ }
+
+ try {
+ include_once './core/cs_ticket.class.php';
+
+ $ticket_object = new Cs_ticket($this->base_object);
+ $ticket_object->event(3, $order_data->customer_id, $order_data->id);
+
+ error_log('PayPal Fix: Bestellbestaetigung ausgeloest fuer Order '.$order_data->id);
+ } catch (Throwable $e) {
+ error_log('PayPal Fix: Bestellbestaetigung fuer Order '.$order_data->id
+ . ' fehlgeschlagen: '.$e->getMessage());
+ }
+ } // end bestellbestaetigung_senden
+
+
+ /**
+ * Leert den Warenkorb -- erst nach eingezogener Zahlung (24.08.2026).
+ *
+ * Zwei Session-IDs, weil der Kunde mit einer anderen zurueckkommen kann als er
+ * gegangen ist (iOS/Safari verliert das Session-Cookie beim Sprung ueber PayPal
+ * gern): die aktuelle und die, die beim Weggang in cart_sessions mitgeschrieben
+ * wurde. Was trotzdem liegenbleibt, faengt der DATEDIFF > 2 in
+ * Shoppingcart::clear() ein.
+ *
+ * @param string $orig_session_id session_id() aus dem Moment vor dem PayPal-Sprung
+ */
+ private function clear_cart_after_payment($orig_session_id = '') {
+ $db = $this->base_object->db;
+ $ids = array();
+
+ $current = session_id();
+ if ($current) {
+ $ids[$current] = true;
+ }
+ if ($orig_session_id) {
+ $ids[$orig_session_id] = true;
+ }
+
+ foreach (array_keys($ids) as $sid) {
+ $db->query("
+ DELETE FROM
+ shopping_cart_items
+ WHERE
+ session_id = '".$db->real_escape_string($sid)."'
+ ");
+ }
+ } // end clear_cart_after_payment
+
+
} // end PaypalPaymentMethod
/* EOF */
\ No newline at end of file
diff --git a/core/paymentmethods/paypalrestpaymentmethod.class.php b/core/paymentmethods/paypalrestpaymentmethod.class.php
new file mode 100644
index 0000000..468f054
--- /dev/null
+++ b/core/paymentmethods/paypalrestpaymentmethod.class.php
@@ -0,0 +1,145 @@
+ PaypalrestPaymentMethod).
+ *
+ * Flow: after_order() legt die PayPal-Order an und leitet den Kunden zur
+ * Approve-URL weiter. Der Rueckweg laeuft NICHT ueber den Cashdesk (der ist auf
+ * das Legacy-Modul verdrahtet), sondern ueber /core/paypal_v2/return.php.
+ */
+
+require_once __DIR__ . '/../paypal_v2/Config.php';
+require_once __DIR__ . '/../paypal_v2/PayPalClient.php';
+require_once __DIR__ . '/../paypal_v2/Orders.php';
+require_once __DIR__ . '/../paypal_v2/Gateway.php';
+
+class PaypalrestPaymentMethod
+{
+ private $base_object;
+ private $layout_object;
+
+ private $customer_data;
+ private $shipping_address;
+ private $billing_address;
+ private $shoppingcart;
+ private $payment_method;
+ private $shipping_method;
+ private $gift_certificate;
+ private $order_id;
+
+ public function __construct($base_object, $layout_object)
+ {
+ $this->base_object = $base_object;
+ $this->layout_object = $layout_object;
+
+ $this->customer_data = false;
+ $this->shipping_address = false;
+ $this->billing_address = false;
+ $this->shoppingcart = false;
+ $this->payment_method = false;
+ $this->shipping_method = false;
+ $this->gift_certificate = false;
+ $this->order_id = false;
+ }
+
+ public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
+ {
+ $this->customer_data = $customer;
+ $this->shipping_address = $shipping;
+ $this->billing_address = $billing;
+ $this->shoppingcart = $shoppingcart;
+ $this->payment_method = $delivery;
+ $this->shipping_method = $payment;
+ $this->order_id = $order_id;
+ }
+
+ public function set_gift_certificate($gift_certificate)
+ {
+ $this->gift_certificate = $gift_certificate;
+ }
+
+ public function set_order_id($order_id)
+ {
+ $this->order_id = $order_id;
+ }
+
+ /** Vom Cashdesk vor dem Speichern aufgerufen. Kein Fehler => leere Rueckgabe. */
+ public function before_order($argument = null)
+ {
+ return '';
+ }
+
+ public function during_order($argument = null)
+ {
+ return '';
+ }
+
+ /**
+ * Nach dem Speichern des Auftrags: PayPal-Order anlegen und weiterleiten.
+ *
+ * @param float|false $order_total Bruttosumme des Auftrags
+ * @return string HTML-Fallback (nur, wenn keine Weiterleitung moeglich war)
+ */
+ public function after_order($order_total = false)
+ {
+ $charged = (float) $order_total;
+
+ // Gutschein wie im Legacy-Modul vom zu zahlenden Betrag abziehen
+ if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
+ $charged -= (float) $this->gift_certificate->with_vat;
+ }
+
+ try {
+ $gateway = new \PaypalV2\Gateway($this->base_object);
+ $order_number = $this->resolve_order_number((int) $this->order_id);
+ $approve_url = $gateway->createOrderAndGetApprovalUrl((int) $this->order_id, $charged, $order_number);
+ } catch (\Throwable $e) {
+ \error_log('PayPal v2 after_order: ' . $e->getMessage());
+ return $this->render_error('Beim Initialisieren von PayPal ist ein Fehler aufgetreten.');
+ }
+
+ // Bevorzugt harte Weiterleitung (an dieser Stelle sind noch keine Header gesendet).
+ if (!\headers_sent()) {
+ \header('Location: ' . $approve_url);
+ exit;
+ }
+
+ // Fallback: Button/Meta-Refresh im Template
+ $this->layout_object->assign('call', 'payment_open');
+ $this->layout_object->assign('paypal_link', $approve_url);
+ return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
+ }
+
+ /** Wird vom Cashdesk fuer dieses Modul NICHT aufgerufen (Return laeuft ueber return.php). */
+ public function change_order()
+ {
+ return '';
+ }
+
+ // ---------------------------------------------------------------- intern
+
+ private function resolve_order_number(int $order_id): string
+ {
+ $db = $this->base_object->db;
+ $res = $db->query('SELECT order_number FROM orders WHERE id=' . $order_id . ' LIMIT 1');
+ if ($res && $res->num_rows > 0) {
+ return (string) $res->fetch_object()->order_number;
+ }
+ return (string) $order_id;
+ }
+
+ private function render_error(string $message): string
+ {
+ $this->layout_object->assign('call', 'payment_error');
+ $this->layout_object->assign('message', $message);
+ return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
+ }
+}
diff --git a/core/paypal/expresscheckout.class.php b/core/paypal/expresscheckout.class.php
index 71b4b3f..2e086b6 100644
--- a/core/paypal/expresscheckout.class.php
+++ b/core/paypal/expresscheckout.class.php
@@ -47,8 +47,12 @@ class ExpressCheckout extends NVP {
// ."&L_PAYMENTREQUEST_0_NAME0=Ihre+Bestellung"
// ."&L_PAYMENTREQUEST_0_QTY0=1"
// ."&L_PAYMENTREQUEST_0_AMT0=".$this->order_toal
- ."&RETURNURL=".$this->return_url
- ."&CANCELURL=".$this->cancel_url
+ // 24.08.2026: urlencode ergaenzt. Die NVP-Postfields sind ein
+ // key=value&key=value-String -- eine URL mit "?cancel=true" darin wuerde ihn
+ // zerlegen. Ohne das laesst sich die kaputte CANCELURL (bis heute
+ // ".../Kasse/&cancel=true") nicht reparieren.
+ ."&RETURNURL=".urlencode($this->return_url)
+ ."&CANCELURL=".urlencode($this->cancel_url)
."&LOCALECODE=".'DE'
."&PAYMENTREQUEST_0_CURRENCYCODE=".$this->currency
."&NOSHIPPING="."1"
diff --git a/core/paypal_v2/.htaccess b/core/paypal_v2/.htaccess
new file mode 100644
index 0000000..f499787
--- /dev/null
+++ b/core/paypal_v2/.htaccess
@@ -0,0 +1,14 @@
+# Nur die beiden Endpunkte duerfen direkt aufgerufen werden.
+# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt.
+
+ Require all denied
+
+
+
+ Require all granted
+
+
+# Token-Cache o.ae. niemals ausliefern
+
+ Require all denied
+
diff --git a/core/paypal_v2/Config.php b/core/paypal_v2/Config.php
new file mode 100644
index 0000000..24c1e9e
--- /dev/null
+++ b/core/paypal_v2/Config.php
@@ -0,0 +1,99 @@
+ $clientId,
+ 'secret' => $secret,
+ 'webhook_id' => (string) ($c['webhook_id'] ?? ''),
+ ];
+ }
+
+ public static function apiBase(): string
+ {
+ return self::mode() === 'live'
+ ? 'https://api-m.paypal.com'
+ : 'https://api-m.sandbox.paypal.com';
+ }
+
+ public static function webhookId(): string
+ {
+ $cfg = self::load();
+ $mode = self::mode();
+ return (string) ($cfg[$mode]['webhook_id'] ?? '');
+ }
+
+ /** @return mixed */
+ public static function get(string $key, $default = null)
+ {
+ $cfg = self::load();
+ return $cfg[$key] ?? $default;
+ }
+}
diff --git a/core/paypal_v2/Gateway.php b/core/paypal_v2/Gateway.php
new file mode 100644
index 0000000..0e5e1d9
--- /dev/null
+++ b/core/paypal_v2/Gateway.php
@@ -0,0 +1,250 @@
+db = mysqli).
+ */
+final class Gateway
+{
+ /** @var object Shop-base-Objekt mit ->db (mysqli) */
+ private object $base;
+
+ public function __construct(object $base)
+ {
+ $this->base = $base;
+ }
+
+ /**
+ * Legt eine PayPal-Order an, speichert die Order-ID am Shop-Auftrag und
+ * gibt die Approve-URL zurueck, zu der der Kunde weitergeleitet wird.
+ */
+ public function createOrderAndGetApprovalUrl(int $shopOrderId, float $charged, string $orderNumber): string
+ {
+ $shopBase = \rtrim((string) Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
+ $returnUrl = $shopBase . '/core/paypal_v2/return.php';
+ $cancelUrl = $shopBase . '/core/paypal_v2/return.php?cancel=1';
+
+ $orders = new Orders();
+ $res = $orders->create($charged, $orderNumber, (string) $shopOrderId, $returnUrl, $cancelUrl);
+
+ // PayPal-Order-ID am Auftrag ablegen (vorhandene Spalte wiederverwendet)
+ $order = new \Order($this->base);
+ $order->set_order_paypal_token($res['id'], $shopOrderId);
+
+ // Exakt berechneten Betrag fuer den spaeteren Abgleich sichern (best effort;
+ // funktioniert nur, wenn die Spalte paypal_v2_expected_amount existiert).
+ $this->storeExpectedAmount($shopOrderId, $charged);
+
+ return $res['approve_url'];
+ }
+
+ /**
+ * Schliesst die Zahlung anhand der PayPal-Order-ID ab (Return-Flow).
+ *
+ * @return array{ok:bool,order_number:string,order_id:int,message:string}
+ */
+ public function finalizeByPaypalOrderId(string $paypalOrderId): array
+ {
+ $shopOrder = $this->findOrderByToken($paypalOrderId);
+ if ($shopOrder === null) {
+ return $this->result(false, '', 0, 'Bestellung zu dieser Zahlung nicht gefunden.');
+ }
+
+ $orderId = (int) $shopOrder->id;
+
+ // Bereits bezahlt? -> idempotent OK
+ if ((string) $shopOrder->payment_status === '2') {
+ return $this->result(true, (string) $shopOrder->order_number, $orderId, 'bereits bezahlt');
+ }
+
+ $cap = (new Orders())->capture($paypalOrderId);
+
+ if ($cap['capture_status'] !== 'COMPLETED') {
+ return $this->result(
+ false,
+ (string) $shopOrder->order_number,
+ $orderId,
+ 'Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ').'
+ );
+ }
+
+ if (!$this->amountMatches($shopOrder, $cap['amount'], $cap['currency'], $orderId, $paypalOrderId)) {
+ return $this->result(false, (string) $shopOrder->order_number, $orderId, 'Betrag stimmt nicht ueberein.');
+ }
+
+ $this->markPaid($orderId, $cap);
+
+ return $this->result(true, (string) $shopOrder->order_number, $orderId, 'ok');
+ }
+
+ /**
+ * Bestaetigt eine Zahlung aus einem verifizierten Webhook-Event
+ * (PAYMENT.CAPTURE.COMPLETED). Idempotent.
+ *
+ * @param array $resource Das "resource"-Objekt des Webhook-Events (die Capture)
+ */
+ public function confirmFromWebhookCapture(array $resource): bool
+ {
+ $shopOrderId = (int) ($resource['custom_id'] ?? 0);
+ if ($shopOrderId <= 0) {
+ return false;
+ }
+
+ $shopOrder = $this->findOrderById($shopOrderId);
+ if ($shopOrder === null) {
+ return false;
+ }
+
+ if ((string) $shopOrder->payment_status === '2') {
+ return true; // schon bezahlt
+ }
+
+ if (($resource['status'] ?? '') !== 'COMPLETED') {
+ return false;
+ }
+
+ $amount = (string) ($resource['amount']['value'] ?? '');
+ $currency = (string) ($resource['amount']['currency_code'] ?? '');
+ if (!$this->amountMatches($shopOrder, $amount, $currency, $shopOrderId, 'webhook')) {
+ return false;
+ }
+
+ $this->markPaid($shopOrderId, [
+ 'capture_id' => (string) ($resource['id'] ?? ''),
+ 'payer_email' => '',
+ 'payer_id' => '',
+ ]);
+
+ return true;
+ }
+
+ // ----------------------------------------------------------------- intern
+
+ /** @param array{capture_id?:string,payer_email?:string,payer_id?:string} $cap */
+ private function markPaid(int $orderId, array $cap): void
+ {
+ $db = $this->base->db;
+ $order = new \Order($this->base);
+
+ // payment_status = 2 (bezahlt) – identisch zur Legacy-Semantik
+ $order->set_billing_status('2', $orderId);
+ $order->set_payment_date(\date('Y-m-d'), $orderId);
+
+ $capId = $db->real_escape_string((string) ($cap['capture_id'] ?? ''));
+ $email = $db->real_escape_string((string) ($cap['payer_email'] ?? ''));
+ $payer = $db->real_escape_string((string) ($cap['payer_id'] ?? ''));
+
+ $sql = "UPDATE orders SET paypal_transaction_id='$capId'";
+ if ($email !== '') {
+ $sql .= ", paypal_email='$email'";
+ }
+ if ($payer !== '') {
+ $sql .= ", paypal_payerid='$payer'";
+ }
+ $sql .= ' WHERE id=' . $orderId;
+
+ $db->query($sql);
+ }
+
+ /**
+ * Prueft, ob der gezahlte Betrag zum erwarteten Betrag des Auftrags passt.
+ * Erwartet wird primaer paypal_v2_expected_amount, ersatzweise orders.order_total.
+ */
+ private function amountMatches(object $shopOrder, string $paidAmount, string $paidCurrency, int $orderId, string $ref): bool
+ {
+ $expectedRaw = $shopOrder->paypal_v2_expected_amount ?? null;
+ if ($expectedRaw === null || $expectedRaw === '') {
+ $expectedRaw = $shopOrder->order_total;
+ }
+
+ $expected = \number_format((float) $expectedRaw, 2, '.', '');
+ $paid = \number_format((float) $paidAmount, 2, '.', '');
+ $currency = (string) Config::get('currency', 'EUR');
+
+ $ok = ($paid === $expected) && (\strtoupper($paidCurrency) === \strtoupper($currency));
+
+ if (!$ok) {
+ \error_log(
+ "PayPal v2 BETRAGS-MISMATCH (order $orderId, ref $ref): "
+ . "erwartet $expected $currency, erhalten $paid $paidCurrency"
+ );
+ }
+
+ return $ok;
+ }
+
+ private function findOrderByToken(string $paypalOrderId): ?object
+ {
+ $db = $this->base->db;
+ $token = $db->real_escape_string($paypalOrderId);
+ $res = $db->query(
+ "SELECT id, order_number, order_total, payment_status, "
+ . $this->expectedAmountSelect()
+ . " FROM orders WHERE paypal_token = '$token' LIMIT 1"
+ );
+ return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
+ }
+
+ private function findOrderById(int $orderId): ?object
+ {
+ $db = $this->base->db;
+ $res = $db->query(
+ "SELECT id, order_number, order_total, payment_status, "
+ . $this->expectedAmountSelect()
+ . " FROM orders WHERE id = $orderId LIMIT 1"
+ );
+ return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
+ }
+
+ /**
+ * Liefert das SELECT-Fragment fuer den erwarteten Betrag. Existiert die
+ * optionale Spalte nicht, wird NULL selektiert (Fallback auf order_total).
+ */
+ private function expectedAmountSelect(): string
+ {
+ return $this->columnExists('paypal_v2_expected_amount')
+ ? 'paypal_v2_expected_amount'
+ : 'NULL AS paypal_v2_expected_amount';
+ }
+
+ private function storeExpectedAmount(int $orderId, float $charged): void
+ {
+ if (!$this->columnExists('paypal_v2_expected_amount')) {
+ return;
+ }
+ $db = $this->base->db;
+ $val = \number_format($charged, 2, '.', '');
+ $db->query("UPDATE orders SET paypal_v2_expected_amount='$val' WHERE id=$orderId");
+ }
+
+ /** @var array */
+ private static array $colCache = [];
+
+ private function columnExists(string $column): bool
+ {
+ if (isset(self::$colCache[$column])) {
+ return self::$colCache[$column];
+ }
+ $db = $this->base->db;
+ $col = $db->real_escape_string($column);
+ $res = $db->query("SHOW COLUMNS FROM orders LIKE '$col'");
+ return self::$colCache[$column] = ($res && $res->num_rows > 0);
+ }
+
+ /**
+ * @return array{ok:bool,order_number:string,order_id:int,message:string}
+ */
+ private function result(bool $ok, string $orderNumber, int $orderId, string $message): array
+ {
+ return ['ok' => $ok, 'order_number' => $orderNumber, 'order_id' => $orderId, 'message' => $message];
+ }
+}
diff --git a/core/paypal_v2/Orders.php b/core/paypal_v2/Orders.php
new file mode 100644
index 0000000..e44aa6c
--- /dev/null
+++ b/core/paypal_v2/Orders.php
@@ -0,0 +1,152 @@
+client = $client ?? new PayPalClient();
+ }
+
+ /**
+ * Erstellt eine PayPal-Order (intent=CAPTURE) und liefert Order-ID + Approve-URL.
+ *
+ * @return array{id:string,approve_url:string}
+ */
+ public function create(
+ float $amount,
+ string $orderNumber,
+ string $shopOrderId,
+ string $returnUrl,
+ string $cancelUrl
+ ): array {
+ $value = \number_format($amount, 2, '.', '');
+ $currency = (string) Config::get('currency', 'EUR');
+
+ $payload = [
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => [[
+ 'amount' => [
+ 'currency_code' => $currency,
+ 'value' => $value,
+ ],
+ 'custom_id' => $shopOrderId,
+ 'invoice_id' => $orderNumber . '-' . $shopOrderId,
+ ]],
+ 'application_context' => [
+ 'brand_name' => (string) Config::get('brand_name', 'Shop'),
+ 'locale' => 'de-DE',
+ 'landing_page' => 'NO_PREFERENCE',
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'PAY_NOW',
+ 'return_url' => $returnUrl,
+ 'cancel_url' => $cancelUrl,
+ ],
+ ];
+
+ // Idempotenz: gleicher Request-Id => PayPal legt keine Doppel-Order an.
+ $requestId = 'create-' . $shopOrderId . '-' . \substr(\hash('sha256', $orderNumber . '|' . $value), 0, 16);
+
+ [$status, $body] = $this->client->request('POST', '/v2/checkout/orders', $payload, [
+ 'PayPal-Request-Id: ' . $requestId,
+ ]);
+
+ if ($status !== 201 || empty($body['id'])) {
+ throw new \RuntimeException('PayPal v2: Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
+ }
+
+ $approve = '';
+ foreach (($body['links'] ?? []) as $link) {
+ $rel = $link['rel'] ?? '';
+ if ($rel === 'approve' || $rel === 'payer-action') {
+ $approve = (string) ($link['href'] ?? '');
+ break;
+ }
+ }
+ if ($approve === '') {
+ throw new \RuntimeException('PayPal v2: Kein Approve-Link in der Antwort.');
+ }
+
+ return ['id' => (string) $body['id'], 'approve_url' => $approve];
+ }
+
+ /** @return array */
+ public function get(string $paypalOrderId): array
+ {
+ [$status, $body] = $this->client->request('GET', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId));
+ if ($status !== 200) {
+ throw new \RuntimeException('PayPal v2: Order-Abruf fehlgeschlagen (HTTP ' . $status . ').');
+ }
+ return $body;
+ }
+
+ /**
+ * Captured eine vom Kunden freigegebene Order. Idempotent:
+ * Ist bereits gecaptured (Doppel-Return / Webhook-Race), wird der bestehende
+ * Zustand aus der Order gelesen statt einen Fehler zu werfen.
+ *
+ * @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
+ */
+ public function capture(string $paypalOrderId): array
+ {
+ [$status, $body] = $this->client->request(
+ 'POST',
+ '/v2/checkout/orders/' . \rawurlencode($paypalOrderId) . '/capture',
+ null,
+ [
+ 'PayPal-Request-Id: capture-' . $paypalOrderId,
+ 'Prefer: return=representation',
+ ]
+ );
+
+ if ($status === 201 || $status === 200) {
+ return $this->normalize($body);
+ }
+
+ if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
+ return $this->normalize($this->get($paypalOrderId));
+ }
+
+ throw new \RuntimeException('PayPal v2: Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
+ }
+
+ /**
+ * Vereinheitlicht Capture- bzw. Order-Antwort auf die Felder, die der Shop braucht.
+ *
+ * @param array $body
+ * @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
+ */
+ public function normalize(array $body): array
+ {
+ $cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
+
+ return [
+ 'order_status' => (string) ($body['status'] ?? ''),
+ 'capture_id' => (string) ($cap['id'] ?? ''),
+ 'capture_status' => (string) ($cap['status'] ?? ''),
+ 'amount' => (string) ($cap['amount']['value'] ?? ''),
+ 'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
+ 'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
+ 'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
+ 'raw' => $body,
+ ];
+ }
+
+ /** @param array $body */
+ private function hasIssue(array $body, string $issue): bool
+ {
+ foreach (($body['details'] ?? []) as $d) {
+ if (($d['issue'] ?? '') === $issue) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/core/paypal_v2/PayPalClient.php b/core/paypal_v2/PayPalClient.php
new file mode 100644
index 0000000..51e3759
--- /dev/null
+++ b/core/paypal_v2/PayPalClient.php
@@ -0,0 +1,133 @@
+base = Config::apiBase();
+ }
+
+ /** OAuth2 client_credentials Access-Token, gecacht bis kurz vor Ablauf. */
+ public function accessToken(): string
+ {
+ $cacheFile = self::tokenCacheFile();
+
+ if (\is_file($cacheFile)) {
+ $c = \json_decode((string) @\file_get_contents($cacheFile), true);
+ if (
+ \is_array($c)
+ && ($c['mode'] ?? '') === Config::mode()
+ && isset($c['token'], $c['expires'])
+ && (int) $c['expires'] > \time() + 60
+ ) {
+ return (string) $c['token'];
+ }
+ }
+
+ $creds = Config::creds();
+ [$status, $body] = $this->raw(
+ 'POST',
+ '/v1/oauth2/token',
+ 'grant_type=client_credentials',
+ ['Content-Type: application/x-www-form-urlencoded'],
+ $creds['client_id'] . ':' . $creds['secret']
+ );
+
+ $data = \json_decode($body, true);
+ if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
+ throw new \RuntimeException('PayPal v2: OAuth2 fehlgeschlagen (HTTP ' . $status . ').');
+ }
+
+ @\file_put_contents($cacheFile, \json_encode([
+ 'token' => (string) $data['access_token'],
+ 'expires' => \time() + (int) ($data['expires_in'] ?? 300),
+ 'mode' => Config::mode(),
+ ]), LOCK_EX);
+ @\chmod($cacheFile, 0600);
+
+ return (string) $data['access_token'];
+ }
+
+ /**
+ * Authentifizierter JSON-Request.
+ *
+ * @param array|null $payload
+ * @param list $extraHeaders
+ * @return array{0:int,1:array} [HTTP-Status, dekodierter Body]
+ */
+ public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
+ {
+ $headers = \array_merge(
+ [
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $this->accessToken(),
+ ],
+ $extraHeaders
+ );
+
+ $bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
+
+ [$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
+
+ $decoded = $body === '' ? [] : \json_decode($body, true);
+
+ return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
+ }
+
+ /**
+ * Roher cURL-Aufruf.
+ *
+ * @param list $headers
+ * @return array{0:int,1:string}
+ */
+ private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
+ {
+ $ch = \curl_init($this->base . $path);
+ \curl_setopt_array($ch, [
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+
+ if ($basicAuth !== null) {
+ \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
+ \curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
+ }
+ if ($body !== null) {
+ \curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+
+ $resp = \curl_exec($ch);
+ if ($resp === false) {
+ $err = \curl_error($ch);
+ \curl_close($ch);
+ throw new \RuntimeException('PayPal v2: HTTP-Transportfehler: ' . $err);
+ }
+
+ $status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ \curl_close($ch);
+
+ return [$status, (string) $resp];
+ }
+
+ private static function tokenCacheFile(): string
+ {
+ return Config::dir() . '/.token_cache.json';
+ }
+}
diff --git a/core/paypal_v2/Webhook.php b/core/paypal_v2/Webhook.php
new file mode 100644
index 0000000..8262567
--- /dev/null
+++ b/core/paypal_v2/Webhook.php
@@ -0,0 +1,53 @@
+ $headers Request-Header (Schluessel lowercase)
+ * @param string $rawBody Roher Request-Body (unveraendert!)
+ */
+ public function verify(array $headers, string $rawBody): bool
+ {
+ $webhookId = Config::webhookId();
+ if ($webhookId === '') {
+ \error_log('PayPal v2 Webhook: keine webhook_id konfiguriert – Event abgelehnt.');
+ return false;
+ }
+
+ $event = \json_decode($rawBody, true);
+ if (!\is_array($event)) {
+ return false;
+ }
+
+ $payload = [
+ 'transmission_id' => $headers['paypal-transmission-id'] ?? '',
+ 'transmission_time' => $headers['paypal-transmission-time'] ?? '',
+ 'cert_url' => $headers['paypal-cert-url'] ?? '',
+ 'auth_algo' => $headers['paypal-auth-algo'] ?? '',
+ 'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
+ 'webhook_id' => $webhookId,
+ 'webhook_event' => $event,
+ ];
+
+ foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
+ if ($payload[$k] === '') {
+ return false;
+ }
+ }
+
+ [$status, $body] = (new PayPalClient())->request(
+ 'POST',
+ '/v1/notifications/verify-webhook-signature',
+ $payload
+ );
+
+ return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
+ }
+}
diff --git a/core/paypal_v2/bootstrap.php b/core/paypal_v2/bootstrap.php
new file mode 100644
index 0000000..69a0391
--- /dev/null
+++ b/core/paypal_v2/bootstrap.php
@@ -0,0 +1,35 @@
+ aus dem Dateipfad ableiten
+ $docroot = \dirname(__DIR__, 2);
+}
+
+// base.class.php nutzt relative Includes (./config.inc.php etc.) -> ins Docroot wechseln
+\chdir($docroot);
+
+require_once $docroot . '/core/base.class.php';
+require_once $docroot . '/core/registry.class.php';
+require_once $docroot . '/core/order.class.php';
+
+require_once __DIR__ . '/Config.php';
+require_once __DIR__ . '/PayPalClient.php';
+require_once __DIR__ . '/Orders.php';
+require_once __DIR__ . '/Webhook.php';
+require_once __DIR__ . '/Gateway.php';
+
+/** @var object $base_object Shop-base mit ->db (mysqli) und ->config */
+$base_object = new base();
+Registry::set('base', $base_object);
diff --git a/core/paypal_v2/install.sql b/core/paypal_v2/install.sql
new file mode 100644
index 0000000..f9edcb4
--- /dev/null
+++ b/core/paypal_v2/install.sql
@@ -0,0 +1,74 @@
+-- ============================================================================
+-- PayPal Orders v2 (REST) – Aktivierung
+-- Neues Modul, parallel zum Legacy-NVP-PayPal (payment_methods.id = 4).
+--
+-- In Schritten ausfuehren – nichts wird an der Legacy-Zahlart geaendert,
+-- bis DU es in SCHRITT 4 bewusst tust.
+-- VORHER: DB-Dump ziehen.
+-- ============================================================================
+
+
+-- ----------------------------------------------------------------------------
+-- SCHRITT 1 – Schema: Spalte fuer den Betragsabgleich (Manipulationsschutz).
+-- Additiv, nullable, keine Datenaenderung an bestehenden Auftraegen.
+-- ----------------------------------------------------------------------------
+ALTER TABLE `orders`
+ ADD COLUMN `paypal_v2_expected_amount` DECIMAL(10,2) NULL DEFAULT NULL;
+
+
+-- ----------------------------------------------------------------------------
+-- SCHRITT 2 – Zahlart anlegen (klont die Legacy-PayPal-Zeile id=4).
+-- helperjoinkey='pp' => identischer PayPal-Aufschlag (shipping_area.add_pp).
+-- active=1, ABER noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
+-- ----------------------------------------------------------------------------
+INSERT INTO `payment_methods`
+ (`name`, `module`, `argument`, `active`, `rang`,
+ `invoice_text`, `order_text`, `order_email_text`,
+ `helperjoinkey`, `configuration_group_id`)
+VALUES
+ ('PayPal', 'paypalrest', '', 1, 2,
+ 'Bezahlung erfolgte per PayPal',
+ 'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
+ 'Der zu zahlende Betrag wird per PayPal ueberwiesen.',
+ 'pp', 'PayPalRest');
+
+-- Neue ID merken (fuer die folgenden Schritte):
+-- SELECT id FROM payment_methods WHERE module='paypalrest';
+
+
+-- ----------------------------------------------------------------------------
+-- SCHRITT 3 – TESTEN (Sandbox): Zahlart NUR fuer eine Testgruppe sichtbar.
+-- ersetzen. = Kundengruppe eines Test-Accounts.
+-- (Kundengruppen der Legacy-Methode: 1, 101, 103 aktiv; 100 inaktiv.)
+-- ----------------------------------------------------------------------------
+-- INSERT INTO `customer_group_payment_method_configuration`
+-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
+-- VALUES
+-- (, , 1, 1);
+
+
+-- ----------------------------------------------------------------------------
+-- SCHRITT 4 – GO-LIVE (erst nach erfolgreichem Sandbox-Test,
+-- config.php auf mode='live' + Live-Credentials!):
+-- a) Neue Methode fuer die echten Gruppen freischalten (wie Legacy):
+-- INSERT INTO `customer_group_payment_method_configuration`
+-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
+-- VALUES
+-- (1, , 1, 1),
+-- (101, , 1, 1),
+-- (103, , 1, 1);
+--
+-- b) Alte NVP-PayPal-Zahlart (id=4) deaktivieren, damit sie NICHT mehr
+-- auswaehlbar ist (Bestellungen/Historie bleiben unberuehrt):
+-- UPDATE `payment_methods` SET `active` = 0 WHERE `id` = 4;
+-- Alternativ nur die Gruppen-Sichtbarkeit abschalten:
+-- UPDATE `customer_group_payment_method_configuration`
+-- SET `active` = 0 WHERE `payment_method_id` = 4;
+
+
+-- ----------------------------------------------------------------------------
+-- ROLLBACK (falls noetig): neue Methode wieder verstecken
+-- ----------------------------------------------------------------------------
+-- DELETE FROM `customer_group_payment_method_configuration`
+-- WHERE `payment_method_id` = (SELECT id FROM payment_methods WHERE module='paypalrest');
+-- UPDATE `payment_methods` SET `active` = 0 WHERE `module` = 'paypalrest';
diff --git a/core/paypal_v2/return.php b/core/paypal_v2/return.php
new file mode 100644
index 0000000..dfafc51
--- /dev/null
+++ b/core/paypal_v2/return.php
@@ -0,0 +1,49 @@
+&PayerID=... hierher zurueck.
+ * -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die
+ * bestehende Shop-Erfolgsseite /Kasse/?success=1&order=.
+ * - Abbruch: PayPal leitet mit ?cancel=1 hierher -> zurueck zur Kasse.
+ */
+declare(strict_types=1);
+
+require __DIR__ . '/bootstrap.php';
+
+use PaypalV2\Gateway;
+
+$shopBase = \rtrim((string) \PaypalV2\Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
+
+/** Weiterleitung + sauberes Ende. */
+$redirect = static function (string $url): never {
+ \header('Location: ' . $url);
+ exit;
+};
+
+// --- Abbruch durch den Kunden -------------------------------------------------
+if (isset($_GET['cancel'])) {
+ $redirect($shopBase . '/Kasse/?paypal_cancel=1');
+}
+
+$paypalOrderId = isset($_GET['token']) ? (string) $_GET['token'] : '';
+if ($paypalOrderId === '') {
+ $redirect($shopBase . '/Kasse/?paypal_error=1');
+}
+
+try {
+ $gateway = new Gateway($base_object);
+ $result = $gateway->finalizeByPaypalOrderId($paypalOrderId);
+} catch (\Throwable $e) {
+ \error_log('PayPal v2 return.php: ' . $e->getMessage());
+ $redirect($shopBase . '/Kasse/?paypal_error=1');
+}
+
+if ($result['ok']) {
+ // Bestehende Erfolgsseite des Shops wiederverwenden (completed.tpl)
+ $redirect($shopBase . '/Kasse/?success=1&order=' . \rawurlencode($result['order_number']));
+}
+
+\error_log('PayPal v2 return.php: Abschluss fehlgeschlagen (order ' . $result['order_id'] . '): ' . $result['message']);
+$redirect($shopBase . '/Kasse/?paypal_error=1');
diff --git a/core/paypal_v2/webhook.php b/core/paypal_v2/webhook.php
new file mode 100644
index 0000000..e7737f0
--- /dev/null
+++ b/core/paypal_v2/webhook.php
@@ -0,0 +1,62 @@
+ $value) {
+ if (\strncmp($key, 'HTTP_', 5) === 0) {
+ $name = \strtolower(\str_replace('_', '-', \substr($key, 5)));
+ $headers[$name] = (string) $value;
+ }
+}
+
+// Signatur verifizieren – sonst sofort ablehnen
+try {
+ if (!(new Webhook())->verify($headers, $rawBody)) {
+ \http_response_code(400);
+ echo 'invalid signature';
+ exit;
+ }
+} catch (\Throwable $e) {
+ \error_log('PayPal v2 webhook verify: ' . $e->getMessage());
+ \http_response_code(500);
+ exit;
+}
+
+$event = \json_decode($rawBody, true);
+$type = \is_array($event) ? (string) ($event['event_type'] ?? '') : '';
+
+try {
+ if ($type === 'PAYMENT.CAPTURE.COMPLETED') {
+ $resource = $event['resource'] ?? [];
+ if (\is_array($resource)) {
+ (new Gateway($base_object))->confirmFromWebhookCapture($resource);
+ }
+ }
+ // Weitere Event-Typen (Refund/Denied) koennen hier ergaenzt werden.
+} catch (\Throwable $e) {
+ \error_log('PayPal v2 webhook handle (' . $type . '): ' . $e->getMessage());
+ \http_response_code(500);
+ exit;
+}
+
+// PayPal erwartet 2xx, sonst wird das Event erneut zugestellt.
+\http_response_code(200);
+echo 'ok';
diff --git a/core/postgres/.htaccess b/core/postgres/.htaccess
index 359499e..b96bada 100644
--- a/core/postgres/.htaccess
+++ b/core/postgres/.htaccess
@@ -10,3 +10,11 @@
Deny from all
+
+
+# 06.08.2026 geprueft: Ein waere hier WIRKUNGSLOS. Plesk laesst nginx
+# PHP-Dateien direkt an PHP-FPM weiterreichen, die .htaccess wird dafuer nicht ausgewertet.
+# Unkritisch: KremplDB.php und tba_render.php definieren nur Klasse bzw. Funktionen und geben
+# beim Direktaufruf 0 Bytes aus. Wer sie wirklich sperren will, braucht einen location-Block
+# in /var/www/vhosts/system//conf/vhost_nginx.conf.
+# Die config.ini ist ueber die Regel oben zuverlaessig gesperrt (verifiziert: HTTP 403).
diff --git a/core/postgres/shop_events.php b/core/postgres/shop_events.php
new file mode 100644
index 0000000..e729e4d
--- /dev/null
+++ b/core/postgres/shop_events.php
@@ -0,0 +1,129 @@
+ fuer diesen Request nicht weiter versuchen. Sonst wartet
+ // jeder Aufruf erneut auf einen Timeout.
+ if ($tot) {
+ return false;
+ }
+
+ if (!function_exists('pg_connect')) {
+ $tot = true;
+ return false;
+ }
+
+ if ($verbindung === null) {
+ $ini_pfad = dirname(__FILE__) . '/config.ini';
+
+ if (!is_readable($ini_pfad)) {
+ $tot = true;
+ return false;
+ }
+
+ $ini = @parse_ini_file($ini_pfad, true);
+ if (!$ini || empty($ini['postgresql'])) {
+ $tot = true;
+ return false;
+ }
+
+ $pg = $ini['postgresql'];
+
+ // connect_timeout ist der wichtigste Parameter hier: liegt Postgres,
+ // wartet der Checkout hoechstens zwei Sekunden statt bis zum PHP-Timeout.
+ $dsn = sprintf(
+ 'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2',
+ isset($pg['host']) ? $pg['host'] : 'localhost',
+ isset($pg['port']) ? $pg['port'] : '5432',
+ isset($pg['database']) ? $pg['database'] : '',
+ isset($pg['user']) ? $pg['user'] : '',
+ isset($pg['password']) ? $pg['password'] : ''
+ );
+
+ $verbindung = @pg_connect($dsn);
+
+ if (!$verbindung) {
+ $verbindung = null;
+ $tot = true;
+ error_log('shop_event: keine PG-Verbindung -- Ereignisprotokoll uebersprungen');
+ return false;
+ }
+ }
+
+ $werte = array(
+ (string)$ereignis,
+ isset($daten['order_id']) && $daten['order_id'] !== '' ? (int)$daten['order_id'] : null,
+ isset($daten['order_number']) ? (string)$daten['order_number'] : null,
+ isset($daten['payment_method']) && $daten['payment_method'] !== '' ? (int)$daten['payment_method'] : null,
+ isset($daten['betrag']) && $daten['betrag'] !== '' ? (float)$daten['betrag'] : null,
+ isset($daten['paypal_token']) ? (string)$daten['paypal_token'] : null,
+ isset($daten['transaction_id']) ? (string)$daten['transaction_id'] : null,
+ isset($daten['customer_id']) && $daten['customer_id'] !== '' ? (int)$daten['customer_id'] : null,
+ function_exists('session_id') && session_id() ? session_id() : null,
+ isset($daten['detail']) ? (string)$daten['detail'] : null
+ );
+
+ $ergebnis = @pg_query_params(
+ $verbindung,
+ 'INSERT INTO shop_order_events
+ (ereignis, order_id, order_number, payment_method, betrag,
+ paypal_token, transaction_id, customer_id, session_id, detail)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
+ $werte
+ );
+
+ if (!$ergebnis) {
+ // Einmal melden, dann Ruhe -- ein volllaufendes Log hilft niemandem.
+ $tot = true;
+ error_log('shop_event: INSERT fehlgeschlagen (' . $ereignis . ') -- '
+ . @pg_last_error($verbindung));
+ return false;
+ }
+
+ return true;
+ }
+}
+
+/* EOF */
diff --git a/index.php b/index.php
index c227ab1..d388d8b 100644
--- a/index.php
+++ b/index.php
@@ -37,7 +37,10 @@ ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
ini_set('log_errors', 1);
-ini_set('error_log', __DIR__ . '/error_log.txt');
+// 13.09.2026: wie Live -- Log nach logs/ (per .htaccess gesperrt), Absturzprotokoll in logs/fatal.log
+ini_set('error_log', __DIR__ . '/logs/shop_error.log');
+include_once './core/fatal_log.php';
+intelectra_fatal_log_init();
//setlocale(LC_ALL, 'de_DE.UTF-8@euro', 'de_DE', 'deu_deu', 'ge');
diff --git a/modules/website_cashdesk.php b/modules/website_cashdesk.php
index bc7b55f..89d978a 100644
--- a/modules/website_cashdesk.php
+++ b/modules/website_cashdesk.php
@@ -17,6 +17,11 @@ include_once('./core/deliverer.class.php'); // should be called shipping methods
include_once('./core/order.class.php');
include_once('./core/cs_ticket.class.php');
+// 25.08.2026: Ereignisprotokoll in PG -- liefert die Uhrzeiten zu Bestellungen, die
+// `orders` nicht hat (alle Datumsfelder dort sind Typ DATE). Faellt still aus, wenn
+// Postgres nicht erreichbar ist.
+include_once($_SERVER['DOCUMENT_ROOT'].'/core/postgres/shop_events.php');
+
include_once('./core/orderhelper.class.php');
include_once('./core/giftcertificatehelper.class.php');
@@ -65,6 +70,30 @@ class website_cashdesk {
return $this->layout_object->_fetch('content_cash_desk/completed.tpl');
}
+ // 24.08.2026 -- Zielseite des Post/Redirect/Get aus PaypalPaymentMethod::after_order().
+ // Reines GET: hier steht der Link zu PayPal, ein Reload laedt nur noch ihn neu und
+ // legt keine zweite Bestellung mehr an.
+ // Steht bewusst vor dem Kunden-/Login-Check: nach dem Redirect ist kein
+ // ?guest_order=1 mehr in der URL, ein Gast haette hier sonst keine customer_id.
+ if (isset($_GET['pp_open'])) {
+ $pp_token = trim((string)$_GET['pp_open']);
+
+ if (preg_match('/^EC-[A-Z0-9]{10,30}$/i', $pp_token)) {
+ $pp_sandbox = (class_exists('Config') && Config::get_value('paypal_dev_mode') == 1);
+ $pp_host = $pp_sandbox ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com';
+
+ $this->layout_object->assign('call', 'payment_open');
+ $this->layout_object->assign('paypal_link',
+ $pp_host.'/cgi-bin/webscr?cmd=_express-checkout&token='.urlencode($pp_token));
+ } else {
+ $this->layout_object->assign('call', 'payment_error');
+ $this->layout_object->assign('message',
+ 'Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da – bitte starten Sie die Bestellung neu.');
+ }
+
+ return $this->layout_object->_fetch('content_cash_desk/paypal.tpl');
+ }
+
if (isset($_GET['guest_order']) && $_GET['guest_order'] == 1) {
// guest order
$this->base_object->customer->customer_id = $this->base_object->config->shopConfiguration['guest_order_customer_id'];
@@ -171,6 +200,71 @@ class website_cashdesk {
$this->base_object->customer->save($data);
}
+ // 25.08.2026 -- Stufe 4: ein Bestellvorgang, eine Order.
+ // Traegt dieser POST einen Nonce, der schon zu einer Bestellung gefuehrt
+ // hat, ist es derselbe Vorgang -- egal ob eine Sekunde oder eine Stunde
+ // vergangen ist. Dann zurueck auf das Ergebnis von damals, statt ein
+ // zweites Mal zu bestellen.
+ $erledigt = $this->bestellvorgang_bereits_erledigt();
+
+ if ($erledigt) {
+ $offen = isset($_SESSION['easyshop']['paypal_pending'])
+ ? $_SESSION['easyshop']['paypal_pending'] : null;
+
+ // Laeuft zu dieser Order noch ein unbezahlter PayPal-Vorgang,
+ // gehoert der Kunde dorthin und nicht auf die Erfolgsseite.
+ if ($offen && !empty($offen['token'])
+ && (int)$offen['order_id'] === (int)$erledigt['order_id']
+ && !$this->paypal_order_is_paid($erledigt['order_id'])) {
+
+ error_log('Checkout: Vorgang '.$erledigt['order_number']
+ . ' bereits bestellt, zurueck auf offenen PayPal-Vorgang '
+ . $offen['token']);
+ header('Location: /Kasse/?pp_open='.urlencode($offen['token']));
+ exit;
+ }
+
+ error_log('Checkout: Vorgang bereits bestellt (Order '
+ . $erledigt['order_number'].') -- keine zweite Order angelegt');
+
+ if (function_exists('shop_event')) {
+ shop_event('duplicate_blocked', array(
+ 'order_id' => $erledigt['order_id'],
+ 'order_number' => $erledigt['order_number'],
+ 'betrag' => $erledigt['total'],
+ 'detail' => 'zweiter Bestell-POST desselben Vorgangs'
+ ));
+ }
+
+ header('Location: /Kasse/?success=1&order='.urlencode($erledigt['order_number']));
+ exit;
+ }
+
+ // 24.08.2026 -- zweiter Riegel gegen Doppelbestellungen bei PayPal.
+ // Kommt trotz PRG noch einmal ein ready2fly-POST (Zurueck-Taste,
+ // zweiter Tab, ungeduldiges Klicken) und liegt zu dieser Session
+ // bereits ein PayPal-Vorgang ueber denselben Betrag offen, der noch
+ // nicht bezahlt ist: zurueck auf dessen Link, statt eine zweite Order
+ // anzulegen. Ist der Vorgang bezahlt oder aelter als eine Stunde,
+ // laeuft alles normal weiter -- dann ist es ein neuer Einkauf.
+ $pending = isset($_SESSION['easyshop']['paypal_pending'])
+ ? $_SESSION['easyshop']['paypal_pending'] : null;
+
+ $pending_module = isset($this->payment_methods[$this->payment_method_id]->module)
+ ? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
+ : '';
+
+ if ($pending && !empty($pending['token'])
+ && $pending_module === 'paypal'
+ && abs(round((float)$this->shoppingcart_data['order_total'], 2) - (float)$pending['total']) < 0.01
+ && (time() - (int)(isset($pending['time']) ? $pending['time'] : 0)) < 3600
+ && !$this->paypal_order_is_paid($pending['order_id'])) {
+
+ error_log("PayPal Fix: doppelter ready2fly-POST abgefangen, zurueck auf offenen Vorgang {$pending['token']}");
+ header('Location: /Kasse/?pp_open='.urlencode($pending['token']));
+ exit;
+ }
+
$is_saved = $this->save_order();
if ($is_saved) {
@@ -213,6 +307,10 @@ class website_cashdesk {
$this->layout_object->assign('site_type', 'full_width');
}
+ // 25.08.2026: Nonce fuers Kassenformular -- macht einen zweiten Klick auf
+ // "Bestellen" als denselben Vorgang erkennbar (siehe save_order()).
+ $this->layout_object->assign('checkout_nonce', $this->checkout_nonce_holen());
+
return $this->layout_object->_fetch('content_cash_desk.tpl');
} else if ($_GET['token']) {
@@ -612,41 +710,28 @@ class website_cashdesk {
// end before_saving_order
private function save_order() {
- // 🚀 FIX 1: Verhindere Doppel-Bestellungen (PayPal Return Flow + Neuanlage)
- // Prüfe SOWOHL Customer-ID ALS AUCH Session (wichtig bei Neuanlage!)
- $customer_id = intval($this->base_object->customer->customer_id);
- $session_id = session_id();
-
- // Prüfe ob bereits eine Order in den letzten 5 Minuten angelegt wurde
- // ENTWEDER von dieser Customer-ID ODER von dieser Session (für Neukunden!)
- $sql = "SELECT id, order_number, order_total, order_date, customer_id
- FROM orders
- WHERE (customer_id = " . $this->base_object->db->real_escape_string($customer_id) . "
- OR customer_id IN (
- SELECT id FROM customers
- WHERE email = '" . $this->base_object->db->real_escape_string($this->customer_data->email ?? '') . "'
- AND registration_date >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
- ))
- AND order_date >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
- ORDER BY id DESC LIMIT 1";
-
- $result = $this->base_object->db->query($sql);
- if ($result && $result->num_rows > 0) {
- $existing_order = $result->fetch_object();
-
- error_log("ORDER PREVENTED: Duplicate order detected - " .
- "Customer: " . $customer_id .
- " (Email: " . ($this->customer_data->email ?? 'unknown') . "), " .
- "Existing Order: " . $existing_order->order_number .
- " (ID: " . $existing_order->id . ") from " . $existing_order->order_date .
- ", Context: " . (isset($_GET['token']) ? 'PayPal Return' : 'Direct'));
-
- // Setze Order-Daten für Success-Seite
- $this->order_id = $existing_order->id;
- $this->order_number = $existing_order->order_number;
-
- return true; // Gib vor, dass Order erfolgreich war (ist sie ja!)
- }
+ // 25.08.2026 -- FIX 1 ersetzt (Stufe 4).
+ //
+ // Hier stand eine Duplikatspruefung, die auf
+ // order_date >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
+ // beruhte. `orders.order_date` ist aber Typ DATE, nicht DATETIME -- MySQL macht aus
+ // dem Datum '2026-08-25 00:00:00', und damit war die Bedingung fuer den GANZEN TAG
+ // wahr statt fuer fuenf Minuten. Wer am selben Tag ein zweites Mal bestellte, bekam
+ // seine ERSTE Bestellnummer als "erfolgreich" angezeigt, und der neue Warenkorb
+ // wurde nie bestellt. Ein stiller Verlust, der niemandem auffiel.
+ //
+ // Reparieren liess sich das nicht: die Tabelle `orders` hat ueberhaupt keine Spalte
+ // mit Uhrzeit (alle Datumsfelder sind DATE), und `order_history.timestamp` wird bei
+ // der Bestellanlage nicht gefuellt. Es gibt also keinen Zeitstempel, auf den man
+ // eine Fuenf-Minuten-Regel stuetzen koennte.
+ //
+ // Statt der Uhr entscheidet jetzt der Vorgang: ein checkout_nonce pro Kassenformular
+ // (siehe run() und checkout_nonce_holen()). Wird derselbe Nonce ein zweites Mal
+ // abgeschickt, ist es derselbe Bestellvorgang -- unabhaengig davon, wie viel Zeit
+ // vergangen ist. Als Netz fuer Absendungen ohne Nonce dient das Zeitfenster in
+ // $_SESSION['easyshop']['last_order'], das eine echte Uhrzeit fuehrt.
+ //
+ // Der Empty-Cart-Schutz weiter unten (FIX 2) bleibt unveraendert.
// 🚀 FIX 2: Verhindere 0€ Bestellungen ohne Items (Race Condition / Session Loss)
if (empty($this->shoppingcart_data) ||
@@ -745,9 +830,26 @@ class website_cashdesk {
unset($_SESSION['customer_info']);
if ($this->order_id) {
- // send email
- $ticket_object = new Cs_ticket($this->base_object);
- $ticket_object->event(3, $this->customer_data->id, $this->order_id);
+ // 24.08.2026: Bei PayPal wandert die Bestellbestaetigung ans Ende des
+ // Bezahlvorgangs (PaypalPaymentMethod::bestellbestaetigung_senden). Hier waere
+ // sie zu frueh -- die Order existiert an dieser Stelle zwar, aber der Kunde war
+ // noch nicht einmal bei PayPal. Beim Testkauf am 24.08. lagen zwischen dieser
+ // Mail (23:25:09) und der eingezogenen Zahlung (23:26:45) anderthalb Minuten,
+ // und wer gar nicht bezahlt, bekam trotzdem eine Bestaetigung.
+ // Alle anderen Zahlarten sind mit dem Abschicken verbindlich -- dort bleibt der
+ // Versand genau hier.
+ $mail_modul = isset($this->payment_methods[$this->payment_method_id]->module)
+ ? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
+ : '';
+
+ if ($mail_modul !== 'paypal') {
+ // send email
+ $ticket_object = new Cs_ticket($this->base_object);
+ $ticket_object->event(3, $this->customer_data->id, $this->order_id);
+ } else {
+ error_log('PayPal Fix: Bestellbestaetigung fuer Order '.$this->order_id
+ . ' zurueckgestellt bis zur eingezogenen Zahlung');
+ }
// EU-RL 2026/2673: elektronischer Widerruf (B2C) – silent solange Flag aus
try {
@@ -759,6 +861,24 @@ class website_cashdesk {
}
if ($this->order_number) {
+ // 25.08.2026: Vorgang als erledigt vermerken, damit ein zweiter Klick auf
+ // denselben Bestellbutton keine weitere Order erzeugt.
+ $this->bestellvorgang_vermerken();
+
+ // Ereignisprotokoll in PG -- der einzige Ort mit einer Uhrzeit zur Bestellung.
+ if (function_exists('shop_event')) {
+ shop_event('order_created', array(
+ 'order_id' => $this->order_id,
+ 'order_number' => $this->order_number,
+ 'payment_method' => $this->payment_method_id,
+ 'betrag' => isset($this->shoppingcart_data['order_total'])
+ ? $this->shoppingcart_data['order_total'] : null,
+ 'customer_id' => isset($this->customer_data->id) ? $this->customer_data->id : null,
+ 'detail' => $mail_modul === 'paypal'
+ ? 'PayPal: noch nicht bezahlt' : 'sofort verbindlich'
+ ));
+ }
+
return true;
}
}
@@ -787,8 +907,20 @@ class website_cashdesk {
}
$this->layout_object->assign('trusted_shop_payment', $trusted_shop_payment);
- $shoppingcart_object = new Shoppingcart($this->base_object);
- $shoppingcart_object->clear(1);
+ // 24.08.2026: Der Warenkorb wird nur noch geleert, wenn die Bestellung mit dieser
+ // Antwort wirklich abgeschlossen ist. Bei PayPal faengt das Bezahlen hier erst an
+ // -- vorher flog der Korb schon raus, und wer abbrach, neu lud oder zurueckging,
+ // stand ohne Warenkorb da ("ORDER PREVENTED: Empty cart"). PayPal leert jetzt
+ // selbst, sobald das Geld eingezogen ist (PaypalPaymentMethod::change_order).
+ // Bewusst nur PayPal: die anderen Module raeumen nicht selbst auf.
+ $payment_module = isset($this->payment_methods[$this->payment_method_id]->module)
+ ? strtolower((string)$this->payment_methods[$this->payment_method_id]->module)
+ : '';
+
+ if ($payment_module !== 'paypal') {
+ $shoppingcart_object = new Shoppingcart($this->base_object);
+ $shoppingcart_object->clear(1);
+ }
if ($this->payment_methods[$this->payment_method_id]->module != '') {
$path = $_SERVER['DOCUMENT_ROOT'].'/core/paymentmethods/';
@@ -818,6 +950,145 @@ class website_cashdesk {
// end after_saving_order
+ /**
+ * Nonce fuer das Kassenformular (25.08.2026).
+ *
+ * Ein Wert pro Bestellvorgang, der als hidden field im Formular mitgeht. Nach einer
+ * erfolgreichen Bestellung wird er verbraucht und ein neuer erzeugt -- ein erneut
+ * abgeschickter alter Nonce ist damit erkennbar derselbe Vorgang. Das ersetzt die
+ * Zeitfenster-Pruefung, die auf einer DATE-Spalte nicht funktionieren konnte.
+ *
+ * @return string
+ */
+ private function checkout_nonce_holen() {
+ if (empty($_SESSION['easyshop']['checkout_nonce'])) {
+ if (function_exists('random_bytes')) {
+ try {
+ $_SESSION['easyshop']['checkout_nonce'] = bin2hex(random_bytes(16));
+ } catch (Exception $e) {
+ $_SESSION['easyshop']['checkout_nonce'] = md5(uniqid(mt_rand(), true));
+ }
+ } else {
+ $_SESSION['easyshop']['checkout_nonce'] = md5(uniqid(mt_rand(), true));
+ }
+ }
+
+ return $_SESSION['easyshop']['checkout_nonce'];
+ }
+
+ // end checkout_nonce_holen
+
+
+ /**
+ * Wurde dieser Bestellvorgang schon abgeschlossen? (25.08.2026)
+ *
+ * @return array|false Daten der bereits angelegten Order, sonst false
+ */
+ private function bestellvorgang_bereits_erledigt() {
+ $nonce = isset($_POST['checkout_nonce']) ? trim((string)$_POST['checkout_nonce']) : '';
+
+ if ($nonce !== '' && isset($_SESSION['easyshop']['checkout_done'][$nonce])) {
+ return $_SESSION['easyshop']['checkout_done'][$nonce];
+ }
+
+ // Netz fuer Absendungen ohne Nonce -- alte Seite aus dem Cache, JS abgeschaltet,
+ // Formular von Hand nachgebaut. Gleiche Session, gleicher Betrag, innerhalb von
+ // fuenf Minuten. Die Uhrzeit kommt aus der Session und ist damit eine echte
+ // Uhrzeit, anders als beim alten Guard auf der DATE-Spalte.
+ if ($nonce === '' && !empty($_SESSION['easyshop']['last_order'])) {
+ $letzte = $_SESSION['easyshop']['last_order'];
+ $summe = round((float)(isset($this->shoppingcart_data['order_total'])
+ ? $this->shoppingcart_data['order_total'] : 0), 2);
+
+ if (abs($summe - (float)$letzte['total']) < 0.01
+ && (time() - (int)$letzte['time']) < 300) {
+ return $letzte;
+ }
+ }
+
+ return false;
+ }
+
+ // end bestellvorgang_bereits_erledigt
+
+
+ /**
+ * Bestellvorgang als erledigt vermerken (25.08.2026).
+ *
+ * Laeuft direkt nach der Order-Anlage. Der verbrauchte Nonce bleibt in der Session
+ * stehen, damit ein spaeter nachgereichter POST mit demselben Wert erkannt wird; fuer
+ * die naechste Bestellung wird beim Rendern des Formulars ein frischer erzeugt.
+ */
+ private function bestellvorgang_vermerken() {
+ $nonce = isset($_POST['checkout_nonce']) ? trim((string)$_POST['checkout_nonce']) : '';
+
+ $eintrag = array(
+ 'order_id' => $this->order_id,
+ 'order_number' => $this->order_number,
+ 'total' => round((float)(isset($this->shoppingcart_data['order_total'])
+ ? $this->shoppingcart_data['order_total'] : 0), 2),
+ 'time' => time()
+ );
+
+ if ($nonce !== '') {
+ if (!isset($_SESSION['easyshop']['checkout_done'])
+ || !is_array($_SESSION['easyshop']['checkout_done'])) {
+ $_SESSION['easyshop']['checkout_done'] = array();
+ }
+
+ $_SESSION['easyshop']['checkout_done'][$nonce] = $eintrag;
+
+ // Nicht unbegrenzt mitschleppen -- die letzten fuenf reichen.
+ if (count($_SESSION['easyshop']['checkout_done']) > 5) {
+ $_SESSION['easyshop']['checkout_done'] =
+ array_slice($_SESSION['easyshop']['checkout_done'], -5, 5, true);
+ }
+
+ unset($_SESSION['easyshop']['checkout_nonce']);
+ }
+
+ $_SESSION['easyshop']['last_order'] = $eintrag;
+ }
+
+ // end bestellvorgang_vermerken
+
+
+ /**
+ * Ist zu dieser Order bereits Geld eingezogen? (24.08.2026)
+ *
+ * Entscheidet, ob ein wiederholter Bestell-POST auf den offenen PayPal-Vorgang
+ * zurueckgefuehrt wird oder als neuer Einkauf durchlaeuft.
+ *
+ * @param int $order_id
+ * @return bool
+ */
+ private function paypal_order_is_paid($order_id) {
+ $order_id = (int)$order_id;
+ if ($order_id <= 0) {
+ return false;
+ }
+
+ $result = $this->base_object->db->query("
+ SELECT
+ paypal_transaction_id
+ FROM
+ orders
+ WHERE
+ id = ".$this->base_object->db->real_escape_string($order_id)."
+ LIMIT 1
+ ");
+
+ if ($result && $result->num_rows > 0) {
+ $row = $result->fetch_object();
+ return !empty($row->paypal_transaction_id);
+ }
+
+ // Order nicht gefunden -> nicht blockieren, normal weiterbestellen lassen.
+ return false;
+ }
+
+ // end paypal_order_is_paid
+
private function change_order($param) {
$path = $_SERVER['DOCUMENT_ROOT'].'/core/paymentmethods/';
$module_name = ucfirst($param).'PaymentMethod';
diff --git a/themes/easyshop_advanced/templates/content_cash_desk.tpl b/themes/easyshop_advanced/templates/content_cash_desk.tpl
index 4031fea..6ae90ff 100644
--- a/themes/easyshop_advanced/templates/content_cash_desk.tpl
+++ b/themes/easyshop_advanced/templates/content_cash_desk.tpl
@@ -38,6 +38,12 @@
+ {* 25.08.2026: Kennung dieses Bestellvorgangs. Wird derselbe Wert ein zweites Mal
+ abgeschickt (Doppelklick, Zurueck-Taste, zweiter Tab), erkennt
+ website_cashdesk::bestellvorgang_bereits_erledigt() das und legt keine zweite
+ Bestellung an, sondern fuehrt zum Ergebnis des ersten Versuchs zurueck. *}
+
+
diff --git a/themes/easyshop_advanced/templates/content_cash_desk/paypal.tpl b/themes/easyshop_advanced/templates/content_cash_desk/paypal.tpl
index 8726247..8e8d48e 100644
--- a/themes/easyshop_advanced/templates/content_cash_desk/paypal.tpl
+++ b/themes/easyshop_advanced/templates/content_cash_desk/paypal.tpl
@@ -48,8 +48,17 @@
zu dürfen.
*}
{/if}
+ {* 24.08.2026: Der Warenkorb bleibt beim Abbruch jetzt erhalten -- deshalb hier
+ ein Weg zurueck. Vorher war der Korb geleert und die Seite eine Sackgasse. *}
{if $call == 'payment_cancelled'}
-
Ihre Bestellung wurde storniert.
+
Sie haben die Zahlung bei PayPal abgebrochen – es wurde nichts abgebucht.
+
Ihr Warenkorb ist noch da. Sie können die Bestellung
+ erneut aufgeben oder eine andere Zahlungsart wählen.