494 lines
19 KiB
PHP
494 lines
19 KiB
PHP
<?php
|
|
/*
|
|
* @version $Id: index.php 10381 2008-06-01 03:35:53Z $
|
|
* @package Carteasy
|
|
* @copyright Copyright (C) 2005 - 2011 Wlanium
|
|
* @license proprietary
|
|
* @author Thomas Bartelt
|
|
* Carteasy is a web shop system
|
|
*
|
|
* MODIFIED: PayPal v1 Session Fix - PFC Claude
|
|
* Added cart session management to eliminate session dependency during PayPal redirects
|
|
*/
|
|
|
|
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 {
|
|
|
|
|
|
private $base_object;
|
|
private $layout_object;
|
|
private $cart_session_manager;
|
|
|
|
|
|
private $customer_data;
|
|
private $shipping_address;
|
|
private $billing_address;
|
|
private $shoppingcart;
|
|
private $payment_method;
|
|
private $shipping_method;
|
|
private $gift_certificate;
|
|
private $order_id;
|
|
|
|
|
|
private $return_url;
|
|
private $cancel_url;
|
|
|
|
|
|
function __construct($base_object, $layout_object) {
|
|
$this->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 .= "<br />SHORTMESSAGE: ".$error_data['SHORTMESSAGE'];
|
|
$message .= "<br />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 */ |