sync: Zahlungsstrecke, PG-Events, Fatal-Log und PayPal-v2-Blaupause vom Live-Stand 2026-09-13
Deploy to Dev / deploy (push) Successful in 1s
Deploy to Dev / deploy (push) Successful in 1s
This commit is contained in:
+311
-40
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user