sync: Zahlungsstrecke, PG-Events, Fatal-Log und PayPal-v2-Blaupause vom Live-Stand 2026-09-13
Deploy to Dev / deploy (push) Successful in 1s

This commit is contained in:
Thomas Bartelt
2026-09-13 19:20:24 +02:00
parent 20d55f77b8
commit af81e8f63a
22 changed files with 1963 additions and 58 deletions
+311 -40
View File
@@ -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';