From af81e8f63abaa66ab22d5888a52d6de5d949e00c Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Sun, 13 Sep 2026 19:20:24 +0200 Subject: [PATCH] sync: Zahlungsstrecke, PG-Events, Fatal-Log und PayPal-v2-Blaupause vom Live-Stand 2026-09-13 --- core/cart_session_manager.class.php | 25 +- core/fatal_log.php | 142 +++++++ .../paypalpaymentmethod.class.php | 240 +++++++++++- .../paypalrestpaymentmethod.class.php | 145 ++++++++ core/paypal/expresscheckout.class.php | 8 +- core/paypal_v2/.htaccess | 14 + core/paypal_v2/Config.php | 99 +++++ core/paypal_v2/Gateway.php | 250 +++++++++++++ core/paypal_v2/Orders.php | 152 ++++++++ core/paypal_v2/PayPalClient.php | 133 +++++++ core/paypal_v2/Webhook.php | 53 +++ core/paypal_v2/bootstrap.php | 35 ++ core/paypal_v2/install.sql | 74 ++++ core/paypal_v2/return.php | 49 +++ core/paypal_v2/webhook.php | 62 ++++ core/postgres/.htaccess | 8 + core/postgres/shop_events.php | 129 +++++++ index.php | 5 +- modules/website_cashdesk.php | 351 ++++++++++++++++-- .../templates/content_cash_desk.tpl | 6 + .../templates/content_cash_desk/paypal.tpl | 11 +- .../content_cash_desk/paypalrest.tpl | 30 ++ 22 files changed, 1963 insertions(+), 58 deletions(-) create mode 100644 core/fatal_log.php create mode 100644 core/paymentmethods/paypalrestpaymentmethod.class.php create mode 100644 core/paypal_v2/.htaccess create mode 100644 core/paypal_v2/Config.php create mode 100644 core/paypal_v2/Gateway.php create mode 100644 core/paypal_v2/Orders.php create mode 100644 core/paypal_v2/PayPalClient.php create mode 100644 core/paypal_v2/Webhook.php create mode 100644 core/paypal_v2/bootstrap.php create mode 100644 core/paypal_v2/install.sql create mode 100644 core/paypal_v2/return.php create mode 100644 core/paypal_v2/webhook.php create mode 100644 core/postgres/shop_events.php create mode 100644 themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl 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.

+

+ Zurück zur Kasse +   + Warenkorb ansehen +

{/if} {if $call == 'payment_error'} diff --git a/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl b/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl new file mode 100644 index 0000000..0dd2bc3 --- /dev/null +++ b/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl @@ -0,0 +1,30 @@ + +
+
+

PayPal

+
+
+ +
+
+ {if $call == 'payment_open'} + {if $paypal_link} +

Vielen Dank für Ihre Bestellung.

+ Sie werden zu PayPal weitergeleitet. Falls das nicht automatisch + geschieht, klicken Sie bitte auf den folgenden Button.

+ + + + Weiter zu PayPal +

+ {else} +

Mit PayPal ist ein Fehler aufgetreten.

+ {/if} + {/if} + + {if $call == 'payment_error'} +

{$message}

+ {/if} +
+
+