base_object = $base_object; $this->layout_object = $layout_object; // PAYPAL FIX: Initialize cart session manager $this->cart_session_manager = new CartSessionManager($base_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; // 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 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; } // end set_data public function set_gift_certificate($gift_certificate) { $this->gift_certificate = $gift_certificate; } // end set_gift_certificate public function set_order_id($order_id) { $this->order_id = $order_id; } // end set_order_id public function before_order() { } // end before_order public function during_order() { } // end during_order public function after_order($order_total = false) { global $DEVMODE; if ($this->gift_certificate) { $order_total = $order_total - $this->gift_certificate->with_vat; } $order = new Order($this->base_object); $paypal = new ExpressCheckout($this->return_url, $this->cancel_url, $order_total, $this->shoppingcart); if ($paypal->set_express_checkout()) { $this->layout_object->assign('paypal_link', $paypal->get_link()); $token = $paypal->get_token(); $order->set_order_paypal_token($token, $this->order_id); // PAYPAL FIX: Store complete cart session data before redirect $cart_session_data = array( 'customer_data' => $this->customer_data, 'shipping_address' => $this->shipping_address, 'billing_address' => $this->billing_address, 'shoppingcart' => $this->shoppingcart, 'payment_method' => $this->payment_method, 'shipping_method' => $this->shipping_method, 'gift_certificate' => $this->gift_certificate, 'order_id' => $this->order_id, 'order_total' => $order_total, 'session_backup_time' => date('Y-m-d H:i:s'), 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'php_session_id' => session_id() ); // Store cart data with PayPal token as key $cart_stored = $this->cart_session_manager->storeCartSession($token, $cart_session_data, 'paypal'); if ($cart_stored) { // Update status to processing (user is being redirected) $this->cart_session_manager->updateCartStatus($token, 'processing'); error_log("PayPal Fix: Cart session stored successfully for token: {$token}"); } else { error_log("PayPal Fix: FAILED to store cart session for token: {$token}"); // 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.'; if ($DEVMODE) { $error_data = $paypal->get_error(); $message = "ERRORCODE: ".$error_data['ERRORCODE']; $message .= "
SHORTMESSAGE: ".$error_data['SHORTMESSAGE']; $message .= "
LONGMESSAGE: ".$error_data['LONGMESSAGE']; $rawdata = $error_data['RAWDATA']; $postfields = $error_data['POSTFIELDS']; $this->layout_object->assign('postfields', $postfields); $this->layout_object->assign('rawdata', $rawdata); } $this->layout_object->assign('message', $message); $call = 'payment_error'; } $this->layout_object->assign('call', $call); return $this->layout_object->_fetch('content_cash_desk/paypal.tpl'); } // end after_order public function change_order() { global $DEVMODE; $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']) { error_log("PayPal Fix: Cart session restored for token: {$token}"); // Restore all cart data to current object (critical for order processing) $cart_data = $cart_session['cart_data']; $this->customer_data = $cart_data['customer_data'] ?? $this->customer_data; $this->shipping_address = $cart_data['shipping_address'] ?? $this->shipping_address; $this->billing_address = $cart_data['billing_address'] ?? $this->billing_address; $this->shoppingcart = $cart_data['shoppingcart'] ?? $this->shoppingcart; $this->payment_method = $cart_data['payment_method'] ?? $this->payment_method; $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 { error_log("PayPal Fix: WARNING - No cart session found for token: {$token} - falling back to session/database"); } $paypal = new ExpressCheckout($this->return_url, $this->cancel_url, $order->get_order_total_by_id($order_data->id)); if ($paypal->do_express_checkout_payment($token, $payer_id)) { $paypal_raw_data = $paypal->get_return_array(); OrderHelper::set_paypal_transaction_data($order_data->id, $paypal_raw_data); // set payment status on payed $order->set_billing_status('2', $order_data->id); $date = getdate(); $today = $date['year'].'-'.$date['mon'].'-'.$date['mday']; $order->set_payment_date($today, $order_data->id); $this->layout_object->assign('order_number', $order_data->order_number); $this->layout_object->assign('customer_number', $order_data->customer_number); // 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'; } else if (isset($_GET['token'])) { $token = $_GET['token']; $payer_id = $_GET['PayerID']; $order_data = $order->get_order_by_paypal_token($token); // PAYPAL FIX: Restore cart session for confirmation step $cart_session = $this->cart_session_manager->retrieveCartSession($token); if ($cart_session && $cart_session['cart_data']) { $cart_data = $cart_session['cart_data']; $this->shoppingcart = $cart_data['shoppingcart'] ?? $this->shoppingcart; error_log("PayPal Fix: Cart restored for confirmation - token: {$token}"); } $paypal = new ExpressCheckout($this->return_url, $this->cancel_url, $order->get_order_total_by_id($order_data->id)); if ($paypal->get_express_checkout_details($token)) { $paypal_raw_data = $paypal->get_return_array(); OrderHelper::set_paypal_user_data($order_data->id, $paypal_raw_data); $confirm_url = $this->return_url.'?token='.$token.'&PayerId='.$payer_id.'&order_id='.$order_data->id.'&confirm='.true; $this->layout_object->assign('confirm_url', $confirm_url); $call = 'payment_confirm'; } else { $this->layout_object->assign('message', 'Bei der PayPal rückfrage ist ein Fehler aufgetreten.'); // PAYPAL FIX: Mark as abandoned on error $this->cart_session_manager->updateCartStatus($token, 'abandoned'); $call = 'payment_error'; } } $this->layout_object->assign('call', $call); return $this->layout_object->_fetch('content_cash_desk/paypal.tpl'); } // 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 */