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
+20 -5
View File
@@ -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);
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Absturzprotokoll (25.08.2026).
*
* Der Shop schaltet in index.php bewusst alles ab:
* ini_set('display_errors', 0); error_reporting(0);
* Das ist fuer die Anzeige richtig -- nur wurde damit auch das Protokollieren still
* gelegt. Ueber 130 HTTP-500 in zehn Tagen standen nirgends: nicht in error_log.txt,
* nicht im Apache-Log. Gefunden wurden sie erst, als Google sich beschwerte.
*
* DIESE DATEI AENDERT NICHTS AM VERHALTEN DES SHOPS.
* register_shutdown_function() und error_get_last() arbeiten unabhaengig von
* error_reporting -- kein ini_set, kein eigener Exception-Handler (der wuerde aus einem
* sauberen HTTP 500 eine halb gerenderte 200er-Seite machen). Ungefangene Exceptions
* erzeugen ihrerseits einen E_ERROR und landen deshalb ebenfalls hier.
*
* Geschrieben wird in eine EIGENE Datei, nicht in error_log.txt -- dort gehen zwischen
* ein paar tausend Bot-Meldungen pro Tag genau die Zeilen unter, um die es geht.
*
* Auswertung:
* tail -n 40 logs/fatal.log
* grep -c FATAL logs/fatal.log
* awk -F' | ' '{print $4}' logs/fatal.log | sort | uniq -c | sort -rn # haeufigste Stellen
*/
if (!function_exists('intelectra_fatal_log_init')) {
function intelectra_fatal_log_init() {
register_shutdown_function('intelectra_fatal_log_shutdown');
}
function intelectra_fatal_log_shutdown() {
$fehler = error_get_last();
if (!is_array($fehler)) {
return;
}
// Nur echte Abstuerze. Warnungen und Notices interessieren hier nicht -- der
// letzte error_get_last() waere sonst bei fast jedem Request gefuellt.
$fatal = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING,
E_COMPILE_ERROR, E_COMPILE_WARNING, E_USER_ERROR);
if (!in_array($fehler['type'], $fatal, true)) {
return;
}
try {
$datei = __DIR__ . '/../logs/fatal.log';
// Gleiche Stelle nicht im Sekundentakt mitschreiben: eine Bot-Welle auf eine
// kaputte Seite wuerde das Log sonst in Minuten unbrauchbar machen.
if (intelectra_fatal_log_schon_gesehen($fehler)) {
return;
}
// Bei 5 MB einmal wegrotieren. Mehr als zwei Generationen braucht es nicht --
// wer hier nachsieht, sucht den Fehler von heute.
if (@filesize($datei) > 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 */
@@ -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 */
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* Zahlungsart "PayPal Checkout (Orders v2 / REST)".
*
* Neues, eigenstaendiges Modul – laeuft parallel zum Legacy-NVP-Modul
* (PaypalPaymentMethod) und teilt sich mit ihm KEINEN Code.
*
* Aktivierung ueber einen eigenen Eintrag in `payment_methods` mit
* module = 'paypalrest'. Der Cashdesk instanziiert dann automatisch diese
* Klasse (ucfirst('paypalrest').'PaymentMethod' => 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');
}
}
+6 -2
View File
@@ -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"
+14
View File
@@ -0,0 +1,14 @@
# Nur die beiden Endpunkte duerfen direkt aufgerufen werden.
# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt.
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
<FilesMatch "^(return|webhook)\.php$">
Require all granted
</FilesMatch>
# Token-Cache o.ae. niemals ausliefern
<FilesMatch "^\.">
Require all denied
</FilesMatch>
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Laedt die geschuetzte PayPal-v2-Konfiguration von ausserhalb des Webroots.
*/
final class Config
{
/** Kandidaten-Pfade fuer die Config (erster lesbarer gewinnt). */
private const PATHS = [
'/var/www/vhosts/intelectra.de/import/paypal_v2/config.php',
];
private static ?array $data = null;
private static ?string $resolvedPath = null;
public static function load(): array
{
if (self::$data !== null) {
return self::$data;
}
foreach (self::PATHS as $path) {
if (is_file($path) && is_readable($path)) {
$cfg = include $path;
if (!is_array($cfg)) {
throw new \RuntimeException('PayPal v2: Config liefert kein Array: ' . $path);
}
self::$data = $cfg;
self::$resolvedPath = $path;
return self::$data;
}
}
throw new \RuntimeException('PayPal v2: Keine lesbare Config-Datei gefunden.');
}
public static function configPath(): string
{
self::load();
return (string) self::$resolvedPath;
}
/** Verzeichnis der Config – dient auch als Ablage fuer den Token-Cache. */
public static function dir(): string
{
return \dirname(self::configPath());
}
public static function mode(): string
{
$cfg = self::load();
return (($cfg['mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox';
}
/** @return array{client_id:string,secret:string,webhook_id:string} */
public static function creds(): array
{
$cfg = self::load();
$mode = self::mode();
$c = $cfg[$mode] ?? [];
$clientId = (string) ($c['client_id'] ?? '');
$secret = (string) ($c['secret'] ?? '');
if ($clientId === '' || $secret === '' || \str_starts_with($clientId, 'HIER_')) {
throw new \RuntimeException('PayPal v2: Zugangsdaten fuer Modus "' . $mode . '" fehlen oder sind Platzhalter.');
}
return [
'client_id' => $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;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Bindeglied zwischen PayPal-Orders-v2 und dem Shop-Datenmodell.
*
* Zentralisiert:
* - Order-Anlage + Ablage der PayPal-Order-ID (in orders.paypal_token wiederverwendet)
* - Capture inkl. Betrags-/Waehrungsabgleich (Manipulationsschutz)
* - idempotentes Setzen des Zahlstatus (F5, Doppel-Return, Webhook-Race)
*
* Erwartet das Shop-"base"-Objekt ($base->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<mixed> $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<string,bool> */
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];
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Kapselt die Orders-v2-Endpunkte: create / get / capture.
*/
final class Orders
{
private PayPalClient $client;
public function __construct(?PayPalClient $client = null)
{
$this->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<mixed> */
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<mixed> $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<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
*
* Sicherheit:
* - TLS-Verifikation ist ERZWUNGEN (kein CURLOPT_SSL_VERIFYPEER=false wie im Legacy-NVP).
* - Access-Token wird ausserhalb des Webroots zwischengespeichert (chmod 600).
*/
final class PayPalClient
{
private string $base;
public function __construct()
{
$this->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<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>} [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<string> $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';
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Verifiziert PayPal-Webhook-Signaturen serverseitig gegen die PayPal-API.
* Nur so ist ein eingehendes Event vertrauenswuerdig.
*/
final class Webhook
{
/**
* @param array<string,string> $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');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Standalone-Bootstrap fuer die PayPal-v2-Endpunkte (return.php, webhook.php).
*
* Bindet den Shop-Kern (DB + Config) ein, OHNE den normalen index.php-Router
* und ohne das Legacy-PayPal-Modul zu beruehren.
*/
declare(strict_types=1);
// Fehler nicht an den Browser ausgeben (Produktivbetrieb)
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
// Leeres/falsches DOCUMENT_ROOT (z. B. CLI) -> 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);
+74
View File
@@ -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.
-- <NEUE_ID> ersetzen. <TEST_GRUPPE> = 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
-- (<TEST_GRUPPE>, <NEUE_ID>, 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, <NEUE_ID>, 1, 1),
-- (101, <NEUE_ID>, 1, 1),
-- (103, <NEUE_ID>, 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';
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Return-Endpunkt fuer den PayPal-v2-Redirect-Flow.
*
* Ablauf:
* - Erfolg: PayPal leitet mit ?token=<PayPal-Order-ID>&PayerID=... hierher zurueck.
* -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die
* bestehende Shop-Erfolgsseite /Kasse/?success=1&order=<Nr>.
* - 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');
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Webhook-Endpunkt fuer PayPal-v2.
*
* Dient als verlaessliche Quelle des Zahlstatus (server-zu-server), unabhaengig
* davon, ob der Kunde nach der Zahlung tatsaechlich zum Shop zurueckkehrt.
*
* PayPal-Dashboard: Webhook auf diese URL anlegen, mindestens Event
* PAYMENT.CAPTURE.COMPLETED
* abonnieren und die entstehende Webhook-ID in die Config eintragen.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Webhook;
use PaypalV2\Gateway;
$rawBody = (string) \file_get_contents('php://input');
// Header einsammeln (lowercase Keys)
$headers = [];
foreach ($_SERVER as $key => $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';
+8
View File
@@ -10,3 +10,11 @@
Deny from all
</FilesMatch>
</IfModule>
# 06.08.2026 geprueft: Ein <FilesMatch "\.php$"> 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/<domain>/conf/vhost_nginx.conf.
# Die config.ini ist ueber die Regel oben zuverlaessig gesperrt (verifiziert: HTTP 403).
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* Ereignis-Log fuer Bestellvorgaenge (25.08.2026).
*
* Warum PostgreSQL und nicht der Shop selbst:
* Die MySQL-Tabelle `orders` hat keine einzige Spalte mit Uhrzeit -- `order_date` und alle
* anderen Datumsfelder sind Typ DATE, und `order_history.timestamp` wird bei der
* Bestellanlage nicht gefuellt. Damit laesst sich weder eine Abbruchquote sauber messen
* noch belegen, ob eine Aenderung am Bezahlweg etwas gebracht hat. Genau daran ist die
* Analyse der PayPal-Abbrueche zuerst haengengeblieben.
*
* Grundregel dieser Datei: **Der Bestellvorgang darf hier NIEMALS haengenbleiben.**
* Ist Postgres weg, langsam oder die Tabelle fehlt, wird still weitergemacht. Ein
* Messwerkzeug, das den Kaufvorgang gefaehrdet, ist sein Geld nicht wert. Deshalb
* connect_timeout, deshalb ueberall @ und Rueckgabepruefung statt Exceptions.
*
* Ereignisse:
* order_created Bestellung in MySQL angelegt (bei PayPal: noch nicht bezahlt!)
* payment_started Kunde wurde zu PayPal geschickt, Token steht
* paid Geld eingezogen (DoExpressCheckoutPayment erfolgreich)
* cancelled Kunde hat bei PayPal abgebrochen
* failed PayPal hat den Abschluss abgelehnt
* duplicate_blocked zweiter Versuch desselben Vorgangs abgefangen
* mail_sent Bestellbestaetigung ausgeloest
*
* Auswertung z. B.:
* SELECT date_trunc('day', zeitpunkt) AS tag,
* count(*) FILTER (WHERE ereignis = 'payment_started') AS gestartet,
* count(*) FILTER (WHERE ereignis = 'paid') AS bezahlt
* FROM shop_order_events GROUP BY 1 ORDER BY 1;
*/
if (!function_exists('shop_event')) {
/**
* Schreibt ein Ereignis. Schlaegt das fehl, passiert nichts weiter.
*
* @param string $ereignis siehe Liste oben
* @param array $daten order_id, order_number, payment_method, betrag,
* paypal_token, transaction_id, customer_id, detail
* @return bool true wenn geschrieben, sonst false
*/
function shop_event($ereignis, $daten = array()) {
static $verbindung = null;
static $tot = false;
// Einmal gescheitert -> 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 */
+4 -1
View File
@@ -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');
+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 &ndash; 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';
@@ -38,6 +38,12 @@
<input type="hidden" id="cashdesk_action" name="cashdesk_action" value="" />
{* 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. *}
<input type="hidden" name="checkout_nonce" value="{$checkout_nonce|escape}" />
</form>
</div>
@@ -48,8 +48,17 @@
zu d&uuml;rfen.</p>*}
{/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'}
<p>Ihre Bestellung wurde storniert.</p>
<p>Sie haben die Zahlung bei PayPal abgebrochen &ndash; es wurde nichts abgebucht.</p>
<p><strong>Ihr Warenkorb ist noch da.</strong> Sie k&ouml;nnen die Bestellung
erneut aufgeben oder eine andere Zahlungsart w&auml;hlen.</p>
<p>
<a class="button" style="width:200px;" href="/Kasse/">Zur&uuml;ck zur Kasse</a>
&nbsp;
<a href="/Warenkorb/">Warenkorb ansehen</a>
</p>
{/if}
{if $call == 'payment_error'}
@@ -0,0 +1,30 @@
<!-- content_cash_desk/paypalrest.tpl (PayPal Orders v2) -->
<div class="full_box">
<div class="title_wrapper">
<div class="title"><h2>PayPal</h2></div>
<div class="clear"></div>
</div>
<div class="box_content">
<div class="box_padder cashdesk_paypal_info">
{if $call == 'payment_open'}
{if $paypal_link}
<p>Vielen Dank f&uuml;r Ihre Bestellung.<br /><br />
Sie werden zu PayPal weitergeleitet. Falls das nicht automatisch
geschieht, klicken Sie bitte auf den folgenden Button.</p>
<meta http-equiv="refresh" content="0;url={$paypal_link}">
<a href="{$paypal_link}" class="button" style="width:220px;">Weiter zu PayPal</a>
<br /><br />
{else}
<p>Mit PayPal ist ein Fehler aufgetreten.</p>
{/if}
{/if}
{if $call == 'payment_error'}
<p>{$message}</p>
{/if}
</div>
</div>
</div>