From 397fb5944a8a5e773fa5b47954c6096b0b7dad3f Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Sun, 13 Sep 2026 19:51:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(payments):=20Anbieter-Ger=C3=BCst=20core/p?= =?UTF-8?q?ayments=20mit=20PayPal=20Orders=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderInterface/AbstractProvider: Vorgang mit Uhrzeit+Session (payment_transactions), Mail und Warenkorb erst nach Zahlung, idempotentes markPaid, Betragsabgleich, shop_event + PG payment_log - Secrets verschluesselt (libsodium) in MySQL, Schluessel ausserhalb Docroot - Cashdesk: ?payment_return/?payment_cancel, Pending-Riegel, zahlung_erst_spaeter() - Backend Zahlart-Editor: Einstellungen speichern (war ohne Empfaenger), Secret-Felder - payment_webhook.php (Standalone), cli.php (init-key, secret, reconcile, status) - PayPal-v2-Blaupause vom Juli entfernt --- .../paypal_v2paymentmethod.class.php | 111 +++++ .../paypalrestpaymentmethod.class.php | 145 ------ core/payments/AbstractProvider.php | 429 ++++++++++++++++++ core/payments/Log.php | 103 +++++ core/payments/ProviderInterface.php | 70 +++ core/payments/Registry.php | 82 ++++ core/payments/Result.php | 43 ++ core/payments/Secrets.php | 158 +++++++ core/payments/bootstrap.php | 32 ++ core/payments/cli.php | 106 +++++ core/payments/install.sql | 66 +++ core/payments/install_pg.sql | 20 + core/payments/providers/paypal_v2/Client.php | 84 ++++ .../payments/providers/paypal_v2/Provider.php | 388 ++++++++++++++++ core/paypal_v2/.htaccess | 14 - core/paypal_v2/Config.php | 99 ---- core/paypal_v2/Gateway.php | 250 ---------- core/paypal_v2/Orders.php | 152 ------- core/paypal_v2/PayPalClient.php | 133 ------ core/paypal_v2/Webhook.php | 53 --- core/paypal_v2/bootstrap.php | 35 -- core/paypal_v2/install.sql | 74 --- core/paypal_v2/return.php | 49 -- core/paypal_v2/webhook.php | 62 --- modules/admin_payment_method_editor.php | 81 ++++ modules/website_cashdesk.php | 132 +++++- payment_webhook.php | 37 ++ .../templates/admin_payment_method_editor.tpl | 31 +- .../content_cash_desk/payment_result.tpl | 37 ++ .../content_cash_desk/paypalrest.tpl | 30 -- 30 files changed, 2002 insertions(+), 1104 deletions(-) create mode 100644 core/paymentmethods/paypal_v2paymentmethod.class.php delete mode 100644 core/paymentmethods/paypalrestpaymentmethod.class.php create mode 100644 core/payments/AbstractProvider.php create mode 100644 core/payments/Log.php create mode 100644 core/payments/ProviderInterface.php create mode 100644 core/payments/Registry.php create mode 100644 core/payments/Result.php create mode 100644 core/payments/Secrets.php create mode 100644 core/payments/bootstrap.php create mode 100644 core/payments/cli.php create mode 100644 core/payments/install.sql create mode 100644 core/payments/install_pg.sql create mode 100644 core/payments/providers/paypal_v2/Client.php create mode 100644 core/payments/providers/paypal_v2/Provider.php delete mode 100644 core/paypal_v2/.htaccess delete mode 100644 core/paypal_v2/Config.php delete mode 100644 core/paypal_v2/Gateway.php delete mode 100644 core/paypal_v2/Orders.php delete mode 100644 core/paypal_v2/PayPalClient.php delete mode 100644 core/paypal_v2/Webhook.php delete mode 100644 core/paypal_v2/bootstrap.php delete mode 100644 core/paypal_v2/install.sql delete mode 100644 core/paypal_v2/return.php delete mode 100644 core/paypal_v2/webhook.php create mode 100644 payment_webhook.php create mode 100644 themes/easyshop_advanced/templates/content_cash_desk/payment_result.tpl delete mode 100644 themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl diff --git a/core/paymentmethods/paypal_v2paymentmethod.class.php b/core/paymentmethods/paypal_v2paymentmethod.class.php new file mode 100644 index 0000000..551870f --- /dev/null +++ b/core/paymentmethods/paypal_v2paymentmethod.class.php @@ -0,0 +1,111 @@ +base_object = $base_object; + $this->layout_object = $layout_object; + } + + public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false) + { + $this->customer_data = $customer; + $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; + } + + /** Vor dem Speichern: Anbieter einsatzbereit? Sonst Fehlertext fuer die Kasse. */ + public function before_order($argument = null) + { + $provider = \Payments\Registry::get(self::MODULE, $this->base_object); + if (!$provider) { + return 'PayPal ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.'; + } + return ''; + } + + public function during_order($argument = null) + { + return ''; + } + + /** + * Nach dem Speichern: Zahlung anlegen, Kunde per 302 zum Anbieter. + * Der POST bekommt damit einen Redirect als Antwort -- die Zurueck-Taste landet auf + * dem Kassenformular, nicht auf einem erneut abschickbaren POST. + */ + public function after_order($order_total = false) + { + $amount = (float) $order_total; + if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) { + $amount -= (float) $this->gift_certificate->with_vat; + } + $amount = \round($amount, 2); + + try { + $provider = \Payments\Registry::get(self::MODULE, $this->base_object); + if (!$provider) { + throw new \RuntimeException('Provider paypal_v2 nicht installiert'); + } + $url = $provider->start((int) $this->order_id, $amount); + } catch (\Throwable $e) { + \error_log('PayPal v2 after_order (Order ' . $this->order_id . '): ' . $e->getMessage()); + if (\function_exists('shop_event')) { + \shop_event('failed', [ + 'order_id' => $this->order_id, 'betrag' => $amount, + 'customer_id' => $this->customer_data->id ?? null, + 'detail' => 'paypal_v2: start fehlgeschlagen -- ' . $e->getMessage(), + ]); + } + $this->layout_object->assign('call', 'payment_error'); + $this->layout_object->assign('provider_label', 'PayPal'); + $this->layout_object->assign('message', 'PayPal konnte nicht gestartet werden. Ihr Warenkorb ist noch da – bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.'); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + + if (!\headers_sent()) { + \header('Location: ' . $url, true, 302); + exit; + } + // Es wurde schon Output gesendet: Weiterleitungsseite mit Link + Meta-Refresh. + $this->layout_object->assign('call', 'payment_open'); + $this->layout_object->assign('provider_label', 'PayPal'); + $this->layout_object->assign('payment_link', $url); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + + /** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=paypal_v2), nicht hier. */ + public function change_order() + { + return ''; + } +} diff --git a/core/paymentmethods/paypalrestpaymentmethod.class.php b/core/paymentmethods/paypalrestpaymentmethod.class.php deleted file mode 100644 index 468f054..0000000 --- a/core/paymentmethods/paypalrestpaymentmethod.class.php +++ /dev/null @@ -1,145 +0,0 @@ - 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'); - } -} diff --git a/core/payments/AbstractProvider.php b/core/payments/AbstractProvider.php new file mode 100644 index 0000000..86cda75 --- /dev/null +++ b/core/payments/AbstractProvider.php @@ -0,0 +1,429 @@ +|null */ + private ?array $settings = null; + + public function __construct(object $base) + { + $this->base = $base; + $this->db = $base->db; + } + + public function paysAfterRedirect(): bool + { + return true; + } + + // ------------------------------------------------------------ Einstellungen + + /** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */ + protected function setting(string $name, string $default = ''): string + { + if ($this->settings === null) { + $this->settings = []; + $g = $this->db->real_escape_string($this->module()); + $res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'"); + while ($res && ($row = $res->fetch_assoc())) { + $this->settings[(string) $row['name']] = (string) $row['setting']; + } + } + $full = $this->module() . '_' . $name; + $v = $this->settings[$full] ?? $this->settings[$name] ?? ''; + return $v !== '' ? $v : $default; + } + + protected function secret(string $name): string + { + $v = Secrets::get($this->db, $this->module(), $name); + if ($v === null || $v === '') { + throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).'); + } + return $v; + } + + protected function shopBaseUrl(): string + { + if (\defined('MAIN_URL') && MAIN_URL !== '') { + return \rtrim((string) MAIN_URL, '/'); + } + return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost'); + } + + /** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */ + protected function returnUrl(): string + { + return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module(); + } + + protected function cancelUrl(): string + { + return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module(); + } + + /** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */ + protected function webhookUrl(): string + { + return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module(); + } + + // ---------------------------------------------------------------- Vorgang + + /** + * Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL. + * Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an). + */ + protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void + { + $order = $this->orderById($orderId); + $stmt = $this->db->prepare( + 'INSERT INTO payment_transactions + (provider, order_id, order_number, reference, expected_amount, currency, status, + approve_url, session_id, customer_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW()) + ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount), + status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()' + ); + if (!$stmt) { + throw new \RuntimeException('payment_transactions: ' . $this->db->error); + } + $module = $this->module(); + $number = (string) ($order->order_number ?? ''); + $currency = $this->currency(); + $status = 'open'; + $sid = \function_exists('session_id') ? (string) \session_id() : ''; + $cid = (int) ($order->customer_id ?? 0); + $stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid); + $stmt->execute(); + $stmt->close(); + + $ref = $this->db->real_escape_string($reference); + $this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId"); + + $this->pendingSet($orderId, $reference, $amount, $approveUrl); + $this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid); + Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw); + } + + protected function transactionByReference(string $reference): ?object + { + $stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1'); + if (!$stmt) { + return null; + } + $m = $this->module(); + $stmt->bind_param('ss', $m, $reference); + $stmt->execute(); + $row = $stmt->get_result()->fetch_object(); + $stmt->close(); + return $row ?: null; + } + + protected function transactionByOrder(int $orderId): ?object + { + $stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1'); + if (!$stmt) { + return null; + } + $m = $this->module(); + $stmt->bind_param('si', $m, $orderId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_object(); + $stmt->close(); + return $row ?: null; + } + + /** Offene Vorgaenge fuer den Abgleicher. @return list */ + protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array + { + $stmt = $this->db->prepare( + 'SELECT * FROM payment_transactions + WHERE provider=? AND status IN ("open","pending") + AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE) + AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY id' + ); + if (!$stmt) { + return []; + } + $m = $this->module(); + $stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + return \array_map(static fn($r) => (object) $r, $rows); + } + + protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void + { + $stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?'); + if ($stmt) { + $m = $this->module(); + $stmt->bind_param('ssss', $status, $detail, $m, $reference); + $stmt->execute(); + $stmt->close(); + } + } + + // ------------------------------------------------------------- bezahlt + + /** + * Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab. + * Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist. + * + * Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid + * schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher + * laufen dann ins Leere statt in Doppelmails. + */ + protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool + { + $tx = $this->transactionByReference($reference); + if (!$tx) { + \error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')'); + return false; + } + $orderId = (int) $tx->order_id; + + if ($tx->status === 'paid') { + return true; + } + + if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) { + $msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency"; + \error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg"); + $this->setTransactionStatus($reference, 'mismatch', $msg); + $this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg); + Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg); + return false; + } + + // Der eine Schreiber gewinnt. + $stmt = $this->db->prepare( + 'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=? + WHERE provider=? AND reference=? AND status IN ("open","pending")' + ); + if (!$stmt) { + return false; + } + $m = $this->module(); + $amt = (float) $paidAmount; + $stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference); + $stmt->execute(); + $won = $stmt->affected_rows === 1; + $stmt->close(); + if (!$won) { + return $this->transactionByReference($reference)?->status === 'paid'; + } + + $cap = $this->db->real_escape_string($captureId); + $eml = $this->db->real_escape_string($payerEmail); + $pid = $this->db->real_escape_string($payerId); + $this->db->query( + "UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'" + . ($payerEmail !== '' ? ", paypal_email='$eml'" : '') + . ($payerId !== '' ? ", paypal_payerid='$pid'" : '') + . " WHERE id=$orderId" + ); + + $this->clearCart((string) $tx->session_id); + $this->sendOrderConfirmation($orderId, (int) $tx->customer_id); + $this->pendingClear(); + $this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source); + return true; + } + + /** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */ + protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void + { + $tx = $this->transactionByReference($reference); + if (!$tx || $tx->status === 'paid') { + return; + } + $this->setTransactionStatus($reference, 'cancelled', $why); + $orderId = (int) $tx->order_id; + // 6 = storniert (wie v1-PayPal im Cancel-Zweig) + $this->db->query("UPDATE orders SET order_status=6 WHERE id=$orderId AND payment_status<>2"); + $this->pendingClear(); + $this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')'); + Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why); + } + + protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool + { + return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '') + && \strtoupper($expCur) === \strtoupper($paidCur); + } + + protected function currency(): string + { + return \strtoupper($this->setting('currency', 'EUR')); + } + + // --------------------------------------------------------- Shop-Anbindung + + protected function orderById(int $orderId): object + { + $res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1"); + $row = $res ? $res->fetch_object() : null; + if (!$row) { + throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.'); + } + return $row; + } + + /** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */ + protected function shippingAddress(object $order): array + { + $iso = 'DE'; + $cid = (int) ($order->shipping_country ?? 0); + if ($cid > 0) { + $res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid"); + $r = $res ? $res->fetch_object() : null; + if ($r && \strlen((string) $r->iso_code_2) === 2) { + $iso = \strtoupper((string) $r->iso_code_2); + } + } + return [ + 'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))), + 'company' => (string) ($order->shipping_company ?? ''), + 'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))), + 'zip' => (string) ($order->shipping_zip_code ?? ''), + 'city' => (string) ($order->shipping_city ?? ''), + 'country' => $iso, + 'email' => (string) ($order->customer_email ?? ''), + ]; + } + + /** + * Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann + * mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine. + */ + protected function clearCart(string $originalSessionId): void + { + $ids = []; + if ($originalSessionId !== '') { + $ids[$originalSessionId] = true; + } + if (\function_exists('session_id') && \session_id() !== '') { + $ids[\session_id()] = true; + } + foreach (\array_keys($ids) as $sid) { + $s = $this->db->real_escape_string((string) $sid); + $this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'"); + } + } + + /** + * Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und + * geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine + * Fehlerseite fuer eine erfolgreiche Bestellung zeigen. + */ + protected function sendOrderConfirmation(int $orderId, int $customerId): void + { + try { + if (!\class_exists('Cs_ticket')) { + $docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2); + require_once $docroot . '/core/cs_ticket.class.php'; + } + $ticket = new \Cs_ticket($this->base); + $ticket->event(3, $customerId, $orderId); + Log::write($this->module(), 'mail', $orderId, null, null, 'sent'); + } catch (\Throwable $e) { + \error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage()); + Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage()); + } + } + + // ------------------------------------------------------ Session-Riegel + + /** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */ + protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void + { + if (\session_status() !== PHP_SESSION_ACTIVE) { + return; + } + $_SESSION['easyshop']['payment_pending'] = [ + 'module' => $this->module(), + 'reference' => $reference, + 'order_id' => $orderId, + 'total' => \round($amount, 2), + 'approve_url' => $approveUrl, + 'time' => \time(), + ]; + } + + protected function pendingClear(): void + { + if (\session_status() === PHP_SESSION_ACTIVE) { + unset($_SESSION['easyshop']['payment_pending']); + } + } + + /** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */ + public function isPaid(int $orderId): bool + { + $res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId"); + $r = $res ? $res->fetch_object() : null; + return $r && (string) $r->payment_status === '2'; + } + + // --------------------------------------------------------------- Events + + protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void + { + if (!\function_exists('shop_event')) { + return; + } + \shop_event($name, [ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'payment_method' => $this->paymentMethodId(), + 'betrag' => $amount, + 'paypal_token' => $reference, + 'transaction_id' => $transactionId, + 'customer_id' => $customerId, + 'detail' => \trim($this->module() . ': ' . (string) $detail), + ]); + } + + private ?int $pmId = null; + + protected function paymentMethodId(): ?int + { + if ($this->pmId === null) { + $m = $this->db->real_escape_string($this->module()); + $res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1"); + $r = $res ? $res->fetch_object() : null; + $this->pmId = $r ? (int) $r->id : 0; + } + return $this->pmId ?: null; + } + + public function secretFields(): array + { + return []; + } +} diff --git a/core/payments/Log.php b/core/payments/Log.php new file mode 100644 index 0000000..c720cb7 --- /dev/null +++ b/core/payments/Log.php @@ -0,0 +1,103 @@ +|null $payload wird als JSON abgelegt (Secrets vorher entfernen!) + */ + public static function write( + string $provider, + string $action, + ?int $orderId = null, + ?string $orderNumber = null, + ?string $reference = null, + ?string $status = null, + ?int $httpStatus = null, + ?array $payload = null, + ?string $detail = null + ): bool { + $c = self::conn(); + if (!$c) { + return false; + } + $json = $payload === null ? null : \json_encode(self::scrub($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $ok = @\pg_query_params( + $c, + 'INSERT INTO payment_log + (provider, action, order_id, order_number, reference, status, http_status, payload, detail, host) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)', + [ + $provider, $action, $orderId, $orderNumber, $reference, $status, $httpStatus, + $json, $detail, (string) ($_SERVER['SERVER_NAME'] ?? \gethostname()), + ] + ); + if (!$ok) { + self::$dead = true; + \error_log('payment_log: INSERT fehlgeschlagen -- ' . @\pg_last_error($c)); + return false; + } + return true; + } + + /** @return resource|\PgSql\Connection|false */ + private static function conn() + { + if (self::$dead) { + return false; + } + if (self::$conn !== null) { + return self::$conn; + } + if (!\function_exists('pg_connect')) { + self::$dead = true; + return false; + } + $ini = @\parse_ini_file(\dirname(__DIR__) . '/postgres/config.ini', true); + if (!$ini || empty($ini['postgresql'])) { + self::$dead = true; + return false; + } + $p = $ini['postgresql']; + $dsn = \sprintf( + 'host=%s port=%s dbname=%s user=%s password=%s connect_timeout=2', + $p['host'] ?? 'localhost', $p['port'] ?? '5432', $p['database'] ?? '', $p['user'] ?? '', $p['password'] ?? '' + ); + $c = @\pg_connect($dsn); + if (!$c) { + self::$dead = true; + \error_log('payment_log: keine PG-Verbindung -- Protokoll uebersprungen'); + return false; + } + return self::$conn = $c; + } + + /** Entfernt Schluessel, die nach Zugangsdaten aussehen, rekursiv. */ + private static function scrub(array $data): array + { + foreach ($data as $k => $v) { + if (\is_string($k) && \preg_match('/secret|password|token|authorization|client_id/i', $k)) { + $data[$k] = '***'; + } elseif (\is_array($v)) { + $data[$k] = self::scrub($v); + } + } + return $data; + } +} diff --git a/core/payments/ProviderInterface.php b/core/payments/ProviderInterface.php new file mode 100644 index 0000000..dbe6f28 --- /dev/null +++ b/core/payments/ProviderInterface.php @@ -0,0 +1,70 @@ + Zahlung beim Anbieter anlegen, Kunde per 302 weiterleiten. + * 3. onReturn() -> Kunde kommt zurueck: Zahlung abschliessen, Bestellung bezahlt setzen. + * onCancel() -> Kunde hat abgebrochen: Bestellung stornieren, Warenkorb bleibt. + * 4. onWebhook() -> Anbieter meldet serverseitig (unabhaengig vom Kunden). + * 5. reconcile() -> Cron: haengende Vorgaenge beim Anbieter nachfragen. + * + * Alles, was fuer alle Anbieter gleich ist (Mail erst nach Zahlung, Warenkorb erst + * nach Zahlung, Ereignisprotokoll, Betragsabgleich), lebt in AbstractProvider. + */ +interface ProviderInterface +{ + /** Technischer Name = payment_methods.module = Konfigurationsgruppe, z.B. 'paypal_v2'. */ + public function module(): string; + + /** Anzeigename fuers Backend und Protokoll. */ + public function label(): string; + + /** true: die Bestellung ist erst nach Rueckkehr vom Anbieter bezahlt (Mail/Warenkorb warten). */ + public function paysAfterRedirect(): bool; + + /** + * Zahlung anlegen und Weiterleitungs-URL liefern. + * + * @param int $orderId Shop-Bestellung (orders.id) + * @param float $amount zu zahlender Bruttobetrag (nach Gutschein) + * @return string URL, zu der der Kunde geschickt wird + * @throws \RuntimeException wenn der Anbieter nicht erreichbar ist oder ablehnt + */ + public function start(int $orderId, float $amount): string; + + /** + * Rueckkehr des Kunden vom Anbieter. + * @param array $query $_GET der Rueckkehr-URL + */ + public function onReturn(array $query): Result; + + /** @param array $query */ + public function onCancel(array $query): Result; + + /** + * Server-zu-Server-Nachricht des Anbieters. + * @param array $headers Request-Header, Schluessel kleingeschrieben + * @return int HTTP-Status, den der Endpunkt zurueckgeben soll (2xx = angenommen) + */ + public function onWebhook(array $headers, string $rawBody): int; + + /** + * Haengende Vorgaenge nachziehen (Cron). Liefert eine kurze Zusammenfassung je Vorgang. + * @return list + */ + public function reconcile(bool $dryRun = true): array; + + /** + * Felder fuer den Zahlart-Editor im Backend, die NICHT in `configuration` liegen, + * sondern verschluesselt (Secrets). name => Beschriftung. + * @return array + */ + public function secretFields(): array; +} diff --git a/core/payments/Registry.php b/core/payments/Registry.php new file mode 100644 index 0000000..292d141 --- /dev/null +++ b/core/payments/Registry.php @@ -0,0 +1,82 @@ +/Provider.php mit Klasse + * Payments\Providers\\Provider + * Beispiel: module 'paypal_v2' -> Payments\Providers\PaypalV2\Provider + * + * Neuer Anbieter = neues Verzeichnis, sonst nichts. + */ +final class Registry +{ + /** @var array */ + private static array $cache = []; + + public static function has(string $module): bool + { + $module = self::clean($module); + return $module !== '' && \is_file(self::dir($module) . '/Provider.php'); + } + + public static function get(string $module, object $base): ?ProviderInterface + { + $module = self::clean($module); + if ($module === '') { + return null; + } + if (\array_key_exists($module, self::$cache)) { + return self::$cache[$module]; + } + $file = self::dir($module) . '/Provider.php'; + if (!\is_file($file)) { + return self::$cache[$module] = null; + } + self::requireCore(); + require_once $file; + $class = 'Payments\\Providers\\' . self::studly($module) . '\\Provider'; + if (!\class_exists($class)) { + \error_log('Payments: Provider-Klasse fehlt: ' . $class); + return self::$cache[$module] = null; + } + $p = new $class($base); + return self::$cache[$module] = ($p instanceof ProviderInterface ? $p : null); + } + + /** Alle installierten Anbieter (fuer Backend-Liste und Abgleicher). @return list */ + public static function all(): array + { + $out = []; + foreach (\glob(__DIR__ . '/providers/*/Provider.php') ?: [] as $f) { + $out[] = \basename(\dirname($f)); + } + \sort($out); + return $out; + } + + public static function requireCore(): void + { + foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'AbstractProvider'] as $c) { + require_once __DIR__ . '/' . $c . '.php'; + } + } + + private static function dir(string $module): string + { + return __DIR__ . '/providers/' . $module; + } + + private static function clean(string $module): string + { + return \preg_match('/^[a-z0-9_]+$/', $module) ? $module : ''; + } + + private static function studly(string $module): string + { + return \str_replace('_', '', \ucwords($module, '_')); + } +} diff --git a/core/payments/Result.php b/core/payments/Result.php new file mode 100644 index 0000000..58839c0 --- /dev/null +++ b/core/payments/Result.php @@ -0,0 +1,43 @@ + Erfolgsseite + public const CANCELLED = 'cancelled'; // Kunde hat abgebrochen -> Kasse mit Hinweis + public const PENDING = 'pending'; // Anbieter noch nicht fertig -> Hinweis, Abgleicher zieht nach + public const ERROR = 'error'; // Fehler -> Hinweis, Warenkorb bleibt + + public function __construct( + public readonly string $status, + public readonly int $orderId = 0, + public readonly string $orderNumber = '', + public readonly string $message = '' + ) { + } + + public static function paid(int $orderId, string $orderNumber): self + { + return new self(self::PAID, $orderId, $orderNumber); + } + + public static function cancelled(int $orderId = 0, string $orderNumber = ''): self + { + return new self(self::CANCELLED, $orderId, $orderNumber); + } + + public static function pending(int $orderId, string $orderNumber, string $message): self + { + return new self(self::PENDING, $orderId, $orderNumber, $message); + } + + public static function error(string $message, int $orderId = 0, string $orderNumber = ''): self + { + return new self(self::ERROR, $orderId, $orderNumber, $message); + } +} diff --git a/core/payments/Secrets.php b/core/payments/Secrets.php new file mode 100644 index 0000000..3956018 --- /dev/null +++ b/core/payments/Secrets.php @@ -0,0 +1,158 @@ +/payments/secrets.key (chmod 600, nur der Site-User) + * Ein DB-Dump ohne die Datei ist damit wertlos, und die Datei ohne DB auch. + * + * Im Backend werden Secrets nie angezeigt, nur "hinterlegt seit ..." und ein Feld + * zum Ueberschreiben. + */ +final class Secrets +{ + private static ?string $key = null; + + public static function keyPath(): string + { + $docroot = $_SERVER['DOCUMENT_ROOT'] ?? ''; + if ($docroot === '' || !\is_dir($docroot)) { + $docroot = \dirname(__DIR__, 2); + } + return \dirname(\rtrim($docroot, '/')) . '/payments/secrets.key'; + } + + /** Legt den Schluessel an, falls er fehlt (nur per CLI/Installation gedacht). */ + public static function ensureKey(): string + { + $path = self::keyPath(); + if (\is_file($path)) { + return $path; + } + $dir = \dirname($path); + if (!\is_dir($dir) && !@\mkdir($dir, 0700, true)) { + throw new \RuntimeException('Secrets: Verzeichnis nicht anlegbar: ' . $dir); + } + $key = \sodium_crypto_secretbox_keygen(); + if (@\file_put_contents($path, \sodium_bin2hex($key), LOCK_EX) === false) { + throw new \RuntimeException('Secrets: Schluessel nicht schreibbar: ' . $path); + } + @\chmod($path, 0600); + return $path; + } + + private static function key(): string + { + if (self::$key !== null) { + return self::$key; + } + $path = self::keyPath(); + if (!\is_readable($path)) { + throw new \RuntimeException('Secrets: Schluesseldatei fehlt oder nicht lesbar: ' . $path); + } + $hex = \trim((string) \file_get_contents($path)); + $key = \sodium_hex2bin($hex); + if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + throw new \RuntimeException('Secrets: Schluesseldatei hat falsche Laenge.'); + } + return self::$key = $key; + } + + public static function keyAvailable(): bool + { + return \is_readable(self::keyPath()); + } + + public static function encrypt(string $plain): string + { + $nonce = \random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $box = \sodium_crypto_secretbox($plain, $nonce, self::key()); + return 'v1:' . \sodium_bin2base64($nonce . $box, SODIUM_BASE64_VARIANT_ORIGINAL); + } + + public static function decrypt(string $stored): string + { + if (\strncmp($stored, 'v1:', 3) !== 0) { + throw new \RuntimeException('Secrets: unbekanntes Format.'); + } + $raw = \sodium_base642bin(\substr($stored, 3), SODIUM_BASE64_VARIANT_ORIGINAL); + $nonce = \substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $box = \substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $plain = \sodium_crypto_secretbox_open($box, $nonce, self::key()); + if ($plain === false) { + throw new \RuntimeException('Secrets: Entschluesselung fehlgeschlagen (falscher Schluessel?).'); + } + return $plain; + } + + // ------------------------------------------------------------------ Ablage + + /** @return string|null Klartext oder null, wenn nicht hinterlegt */ + public static function get(\mysqli $db, string $provider, string $name): ?string + { + $stmt = $db->prepare('SELECT value_enc FROM payment_provider_secrets WHERE provider=? AND name=?'); + if (!$stmt) { + return null; + } + $stmt->bind_param('ss', $provider, $name); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$row || $row['value_enc'] === '') { + return null; + } + return self::decrypt((string) $row['value_enc']); + } + + public static function set(\mysqli $db, string $provider, string $name, string $plain): void + { + $enc = self::encrypt($plain); + $stmt = $db->prepare( + 'INSERT INTO payment_provider_secrets (provider, name, value_enc, updated_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_at = NOW()' + ); + if (!$stmt) { + throw new \RuntimeException('Secrets: ' . $db->error); + } + $stmt->bind_param('sss', $provider, $name, $enc); + $stmt->execute(); + $stmt->close(); + } + + public static function delete(\mysqli $db, string $provider, string $name): void + { + $stmt = $db->prepare('DELETE FROM payment_provider_secrets WHERE provider=? AND name=?'); + if ($stmt) { + $stmt->bind_param('ss', $provider, $name); + $stmt->execute(); + $stmt->close(); + } + } + + /** + * Fuers Backend: welche Secrets liegen vor und seit wann (ohne Klartext). + * @return array name => updated_at + */ + public static function status(\mysqli $db, string $provider): array + { + $out = []; + $stmt = $db->prepare('SELECT name, updated_at FROM payment_provider_secrets WHERE provider=? AND value_enc<>""'); + if (!$stmt) { + return $out; + } + $stmt->bind_param('s', $provider); + $stmt->execute(); + $res = $stmt->get_result(); + while ($row = $res->fetch_assoc()) { + $out[(string) $row['name']] = (string) $row['updated_at']; + } + $stmt->close(); + return $out; + } +} diff --git a/core/payments/bootstrap.php b/core/payments/bootstrap.php new file mode 100644 index 0000000..0744b1a --- /dev/null +++ b/core/payments/bootstrap.php @@ -0,0 +1,32 @@ +> logs/payments_reconcile.log + * + * ... cli.php init-key + * Schluesseldatei fuer die Secrets anlegen (einmalig je Server). + * + * ... cli.php secret [wert] + * Secret setzen (wert leer = von STDIN lesen, damit es nicht in der Shell-History landet). + * + * ... cli.php status + * Installierte Anbieter, Modus, hinterlegte Secrets, offene Vorgaenge. + */ +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + \http_response_code(403); + exit('cli only'); +} + +require __DIR__ . '/bootstrap.php'; + +use Payments\Registry as PR; +use Payments\Secrets; + +$args = $argv; +\array_shift($args); +$cmd = $args[0] ?? 'status'; + +switch ($cmd) { + case 'init-key': + $path = Secrets::ensureKey(); + echo "Schluessel: $path\n"; + break; + + case 'secret': + [$module, $name] = [$args[1] ?? '', $args[2] ?? '']; + if ($module === '' || $name === '') { + exit("usage: secret [wert]\n"); + } + $value = $args[3] ?? ''; + if ($value === '') { + echo "Wert (Eingabe unsichtbar, Enter zum Beenden): "; + \system('stty -echo'); + $value = \trim((string) \fgets(STDIN)); + \system('stty echo'); + echo "\n"; + } + Secrets::set($base_object->db, $module, $name, $value); + echo "gespeichert: $module/$name (" . \strlen($value) . " Zeichen)\n"; + break; + + case 'reconcile': + $apply = \in_array('--apply', $args, true); + $modules = \array_values(\array_filter(\array_slice($args, 1), static fn($a) => $a !== '--apply')); + if (!$modules) { + $modules = PR::all(); + } + foreach ($modules as $m) { + $p = PR::get($m, $base_object); + if (!$p) { + echo "[$m] nicht installiert\n"; + continue; + } + try { + $lines = $p->reconcile(!$apply); + } catch (\Throwable $e) { + echo '[' . \date('Y-m-d H:i:s') . "] [$m] FEHLER " . $e->getMessage() . "\n"; + continue; + } + foreach ($lines as $l) { + echo '[' . \date('Y-m-d H:i:s') . "] [$m] $l\n"; + } + if (!$lines) { + echo '[' . \date('Y-m-d H:i:s') . "] [$m] nichts offen\n"; + } + } + break; + + case 'status': + default: + echo 'Schluesseldatei: ' . Secrets::keyPath() . ' -> ' . (Secrets::keyAvailable() ? 'vorhanden' : 'FEHLT') . "\n"; + foreach (PR::all() as $m) { + $p = PR::get($m, $base_object); + if (!$p) { + continue; + } + $secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : []; + $res = $base_object->db->query("SELECT id, name, active FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'"); + $pm = $res ? $res->fetch_object() : null; + echo "\n[$m] " . $p->label() . ($p instanceof \Payments\Providers\PaypalV2\Provider ? ' Modus ' . $p->mode() : '') . "\n"; + echo ' Zahlart: ' . ($pm ? "id {$pm->id} '{$pm->name}' " . ($pm->active ? 'aktiv' : 'inaktiv') : 'NICHT angelegt') . "\n"; + foreach ($p->secretFields() as $name => $label) { + echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n"; + } + $res = $base_object->db->query("SELECT status, COUNT(*) c FROM payment_transactions WHERE provider='" . $base_object->db->real_escape_string($m) . "' GROUP BY status"); + while ($res && ($r = $res->fetch_object())) { + echo " Vorgaenge {$r->status}: {$r->c}\n"; + } + } + break; +} diff --git a/core/payments/install.sql b/core/payments/install.sql new file mode 100644 index 0000000..b6c441f --- /dev/null +++ b/core/payments/install.sql @@ -0,0 +1,66 @@ +-- ============================================================================ +-- core/payments -- Installation (MySQL, Shop-DB) +-- Additiv: bestehende Zahlarten und Bestellungen werden nicht angefasst. +-- Reihenfolge: 1) Tabellen 2) Zahlart + Einstellungen 3) Kundengruppen (Test) +-- ============================================================================ + +-- 1a) Vorgaenge je Anbieter -- mit echter Uhrzeit, Session (Warenkorb) und Sollbetrag. +CREATE TABLE IF NOT EXISTS `payment_transactions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `provider` VARCHAR(40) NOT NULL, + `order_id` BIGINT UNSIGNED NOT NULL, + `order_number` VARCHAR(50) NULL, + `reference` VARCHAR(191) NOT NULL COMMENT 'ID beim Anbieter (PayPal Order-ID)', + `expected_amount` DECIMAL(13,2) NOT NULL, + `currency` CHAR(3) NOT NULL DEFAULT 'EUR', + `status` ENUM('open','pending','paid','cancelled','mismatch') NOT NULL DEFAULT 'open', + `approve_url` TEXT NULL, + `session_id` VARCHAR(191) NULL, + `customer_id` INT UNSIGNED NULL, + `capture_id` VARCHAR(191) NULL, + `paid_amount` DECIMAL(13,2) NULL, + `paid_at` DATETIME NULL, + `detail` VARCHAR(500) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_provider_reference` (`provider`,`reference`), + KEY `idx_order` (`order_id`), + KEY `idx_status_created` (`provider`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 1b) Verschluesselte Zugangsdaten (Schluessel liegt ausserhalb des Docroots). +CREATE TABLE IF NOT EXISTS `payment_provider_secrets` ( + `provider` VARCHAR(40) NOT NULL, + `name` VARCHAR(60) NOT NULL, + `value_enc` TEXT NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`provider`,`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2a) Einstellungen der Zahlart (erscheinen im Zahlart-Editor unter "Einstellungen"). +INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES + ('paypal_v2_mode', '', 'paypal_v2', 'sandbox', 1, 'sandbox oder live'), + ('paypal_v2_brand_name', '', 'paypal_v2', 'intelectra', 1, 'Anzeigename auf der PayPal-Seite'), + ('paypal_v2_send_address', '', 'paypal_v2', '1', 1, '1 = Lieferadresse an PayPal uebergeben (Verkaeuferschutz), 0 = nicht'), + ('paypal_v2_currency', '', 'paypal_v2', 'EUR', 1, 'Waehrung (ISO 4217)'); + +-- 2b) Zahlart anlegen. active=1, aber noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar. +-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp). +INSERT INTO `payment_methods` + (`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`) +SELECT 'PayPal', 'paypal_v2', '', 1, 2, + 'Bezahlung erfolgte per PayPal', + 'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.', + 'Der zu zahlende Betrag wurde per PayPal beglichen.', + 'pp', 'paypal_v2' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module` = 'paypal_v2'); + +-- 3) TEST: Zahlart nur fuer eine Kundengruppe sichtbar machen (Gruppen: 1 Endverbraucher, +-- 100 Besucher, 101 Haendler, 103 Gast). Beispiel Endverbraucher: +-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`) +-- SELECT 1, id, 1, 1 FROM payment_methods WHERE module='paypal_v2'; + +-- GO-LIVE (spaeter): alle Gruppen wie bei id=4 freischalten und die alte Zahlart verstecken: +-- UPDATE `customer_group_payment_method_configuration` SET active=0 WHERE payment_method_id=4; +-- UPDATE `payment_methods` SET active=0 WHERE id=4; diff --git a/core/payments/install_pg.sql b/core/payments/install_pg.sql new file mode 100644 index 0000000..7ef1c5f --- /dev/null +++ b/core/payments/install_pg.sql @@ -0,0 +1,20 @@ +-- core/payments -- Rohprotokoll in PostgreSQL (krempl_data) +-- Jede Anfrage/Antwort eines Anbieters mit Zeitstempel. Die Beweiskette. +CREATE TABLE IF NOT EXISTS payment_log ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + action TEXT NOT NULL, -- start, create, capture, get, webhook, mail, cancel, mismatch + order_id INTEGER, + order_number TEXT, + reference TEXT, -- ID beim Anbieter + status TEXT, + http_status INTEGER, + payload JSONB, + detail TEXT, + host TEXT, -- dev.intelectra.de / intelectra-shop.de + zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_payment_log_order ON payment_log (order_id); +CREATE INDEX IF NOT EXISTS idx_payment_log_reference ON payment_log (reference); +CREATE INDEX IF NOT EXISTS idx_payment_log_zeit ON payment_log (zeitpunkt); +CREATE INDEX IF NOT EXISTS idx_payment_log_provider ON payment_log (provider, action, zeitpunkt); diff --git a/core/payments/providers/paypal_v2/Client.php b/core/payments/providers/paypal_v2/Client.php new file mode 100644 index 0000000..528b55b --- /dev/null +++ b/core/payments/providers/paypal_v2/Client.php @@ -0,0 +1,84 @@ +cacheFile)) { + $c = \json_decode((string) @\file_get_contents($this->cacheFile), true); + if (\is_array($c) && ($c['base'] ?? '') === $this->apiBase && ($c['cid'] ?? '') === \substr($this->clientId, 0, 8) + && isset($c['token'], $c['expires']) && (int) $c['expires'] > \time() + 60) { + return (string) $c['token']; + } + } + [$status, $body] = $this->raw('POST', '/v1/oauth2/token', 'grant_type=client_credentials', + ['Content-Type: application/x-www-form-urlencoded'], $this->clientId . ':' . $this->secret); + $data = \json_decode($body, true); + if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) { + throw new \RuntimeException('PayPal OAuth2 fehlgeschlagen (HTTP ' . $status . ')'); + } + @\file_put_contents($this->cacheFile, \json_encode([ + 'token' => $data['access_token'], 'expires' => \time() + (int) ($data['expires_in'] ?? 300), + 'base' => $this->apiBase, 'cid' => \substr($this->clientId, 0, 8), + ]), LOCK_EX); + @\chmod($this->cacheFile, 0600); + return (string) $data['access_token']; + } + + /** + * @param array|null $payload + * @param list $extraHeaders + * @return array{0:int,1:array} + */ + 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]]; + } + + /** @param list $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->apiBase . $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 HTTP-Transportfehler: ' . $err); + } + $status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE); + \curl_close($ch); + return [$status, (string) $resp]; + } +} diff --git a/core/payments/providers/paypal_v2/Provider.php b/core/payments/providers/paypal_v2/Provider.php new file mode 100644 index 0000000..8cf2559 --- /dev/null +++ b/core/payments/providers/paypal_v2/Provider.php @@ -0,0 +1,388 @@ + 'Sandbox Client-ID', + 'sandbox_secret' => 'Sandbox Secret', + 'live_client_id' => 'Live Client-ID', + 'live_secret' => 'Live Secret', + 'webhook_id' => 'Webhook-ID (aus dem PayPal-Dashboard, fuer den aktiven Modus)', + ]; + } + + public function mode(): string + { + return $this->setting('mode', 'sandbox') === 'live' ? 'live' : 'sandbox'; + } + + private function apiBase(): string + { + return $this->mode() === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; + } + + private function client(): Client + { + if ($this->client === null) { + $m = $this->mode(); + $this->client = new Client( + $this->apiBase(), + $this->secret($m . '_client_id'), + $this->secret($m . '_secret'), + \dirname(Secrets::keyPath()) . '/paypal_v2_' . $m . '.token' + ); + } + return $this->client; + } + + // ------------------------------------------------------------------ start + + public function start(int $orderId, float $amount): string + { + $order = $this->orderById($orderId); + $number = (string) $order->order_number; + $value = \number_format($amount, 2, '.', ''); + + $unit = [ + 'reference_id' => (string) $orderId, + 'custom_id' => (string) $orderId, + 'invoice_id' => $number . '-' . $orderId, + 'description' => 'Bestellung ' . $number, + 'amount' => ['currency_code' => $this->currency(), 'value' => $value], + ]; + + $context = [ + 'brand_name' => $this->setting('brand_name', 'intelectra'), + 'locale' => 'de-DE', + 'landing_page' => 'NO_PREFERENCE', + 'user_action' => 'PAY_NOW', + 'return_url' => $this->returnUrl(), + 'cancel_url' => $this->cancelUrl(), + 'shipping_preference' => 'NO_SHIPPING', + ]; + + // Lieferadresse mitgeben: Voraussetzung fuer PayPal-Verkaeuferschutz bei Warenversand. + if ($this->setting('send_address', '1') === '1') { + $a = $this->shippingAddress($order); + if ($a['street'] !== '' && $a['zip'] !== '' && $a['city'] !== '') { + $context['shipping_preference'] = 'SET_PROVIDED_ADDRESS'; + $unit['shipping'] = [ + 'name' => ['full_name' => $a['name'] !== '' ? $a['name'] : $a['company']], + 'address' => [ + 'address_line_1' => $a['street'], + 'address_line_2' => $a['company'], + 'postal_code' => $a['zip'], + 'admin_area_2' => $a['city'], + 'country_code' => $a['country'], + ], + ]; + } + } + + $payload = ['intent' => 'CAPTURE', 'purchase_units' => [$unit], 'application_context' => $context]; + $requestId = 'create-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $value), 0, 16); + + [$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload, ['PayPal-Request-Id: ' . $requestId]); + Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body); + + if ($status !== 201 || empty($body['id'])) { + throw new \RuntimeException('PayPal Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body)); + } + $approve = ''; + foreach (($body['links'] ?? []) as $link) { + if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) { + $approve = (string) $link['href']; + break; + } + } + if ($approve === '') { + throw new \RuntimeException('PayPal: kein Approve-Link in der Antwort.'); + } + + $this->openTransaction($orderId, (string) $body['id'], $amount, $approve); + return $approve; + } + + // ----------------------------------------------------------------- return + + public function onReturn(array $query): Result + { + $ref = (string) ($query['token'] ?? ''); + if ($ref === '') { + return Result::error('Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.'); + } + $tx = $this->transactionByReference($ref); + if (!$tx) { + return Result::error('Der PayPal-Vorgang ist unbekannt. Ihr Warenkorb ist noch da.'); + } + $orderId = (int) $tx->order_id; + $number = (string) $tx->order_number; + + if ($tx->status === 'paid') { + $this->pendingClear(); + return Result::paid($orderId, $number); + } + + try { + $cap = $this->capture($ref, $orderId, $number); + } catch (\Throwable $e) { + \error_log('PayPal v2 onReturn: ' . $e->getMessage()); + $this->event('failed', $orderId, $number, null, $ref, null, (int) $tx->customer_id, $e->getMessage()); + return Result::error('Beim Abschluss der PayPal-Zahlung ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da, bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.', $orderId, $number); + } + + if ($cap['capture_status'] === 'COMPLETED') { + if ($this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'return')) { + return Result::paid($orderId, $number); + } + return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number); + } + if ($cap['capture_status'] === 'PENDING') { + $this->setTransactionStatus($ref, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? '')); + return Result::pending($orderId, $number, 'PayPal prueft die Zahlung noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.'); + } + $this->event('failed', $orderId, $number, null, $ref, $cap['capture_id'], (int) $tx->customer_id, 'capture ' . $cap['capture_status']); + return Result::error('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . '). Ihr Warenkorb ist noch da.', $orderId, $number); + } + + public function onCancel(array $query): Result + { + $ref = (string) ($query['token'] ?? ''); + if ($ref === '') { + $p = $_SESSION['easyshop']['payment_pending'] ?? null; + if (\is_array($p) && ($p['module'] ?? '') === $this->module()) { + $ref = (string) $p['reference']; + } + } + if ($ref !== '') { + $tx = $this->transactionByReference($ref); + if ($tx && $tx->status === 'paid') { + return Result::paid((int) $tx->order_id, (string) $tx->order_number); + } + $this->markCancelled($ref, 'Kunde hat bei PayPal abgebrochen'); + return Result::cancelled((int) ($tx->order_id ?? 0), (string) ($tx->order_number ?? '')); + } + $this->pendingClear(); + return Result::cancelled(); + } + + // ---------------------------------------------------------------- webhook + + public function onWebhook(array $headers, string $rawBody): int + { + $event = \json_decode($rawBody, true); + if (!\is_array($event)) { + return 400; + } + $type = (string) ($event['event_type'] ?? ''); + + if (!$this->verifyWebhook($headers, $event)) { + Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null, ['event_type' => $type], 'Signatur ungueltig'); + return 400; + } + + $resource = \is_array($event['resource'] ?? null) ? $event['resource'] : []; + $orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0)); + Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event); + + switch ($type) { + case 'PAYMENT.CAPTURE.COMPLETED': + $tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null; + if ($tx && ($resource['status'] ?? '') === 'COMPLETED') { + $this->markPaid((string) $tx->reference, (string) ($resource['amount']['value'] ?? ''), + (string) ($resource['amount']['currency_code'] ?? ''), (string) ($resource['id'] ?? ''), '', '', 'webhook'); + } + break; + case 'CHECKOUT.ORDER.APPROVED': + // Kunde hat freigegeben, Capture macht die Rueckkehr oder der Abgleicher. + $tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null; + if ($tx && $tx->status === 'open') { + $this->setTransactionStatus((string) $tx->reference, 'open', 'APPROVED laut Webhook'); + } + break; + case 'PAYMENT.CAPTURE.DENIED': + case 'PAYMENT.CAPTURE.DECLINED': + $tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null; + if ($tx) { + $this->markCancelled((string) $tx->reference, 'PayPal: ' . $type, 'webhook'); + } + break; + case 'PAYMENT.CAPTURE.REFUNDED': + case 'PAYMENT.CAPTURE.REVERSED': + // Nur protokolliert; Erstattungen werden im Backend gepflegt. + break; + } + return 200; + } + + /** @param array $headers @param array $event */ + private function verifyWebhook(array $headers, array $event): bool + { + $webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? ''; + if ($webhookId === '') { + \error_log('PayPal v2 Webhook: keine webhook_id hinterlegt -- Event abgelehnt.'); + 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] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $payload); + return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS'); + } + + // -------------------------------------------------------------- reconcile + + public function reconcile(bool $dryRun = true): array + { + $out = []; + // Nach 10 Minuten nachschauen, nach 72 Stunden aufgeben (PayPal-Orders verfallen nach 3 Tagen). + foreach ($this->openTransactions(10, 72) as $tx) { + $ref = (string) $tx->reference; + try { + [$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref)); + } catch (\Throwable $e) { + $out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage(); + continue; + } + if ($status === 404) { + $out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : ''); + if (!$dryRun) { + $this->markCancelled($ref, 'PayPal kennt den Vorgang nicht (404)', 'reconcile'); + } + continue; + } + $pstatus = (string) ($body['status'] ?? ''); + $cap = $this->normalize($body); + + if ($pstatus === 'COMPLETED' && $cap['capture_status'] === 'COMPLETED') { + $out[] = "$ref (Order {$tx->order_id}): bei PayPal bezahlt, im Shop offen -> bezahlt setzen" . ($dryRun ? ' [dry-run]' : ''); + if (!$dryRun) { + $this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'reconcile'); + } + } elseif ($pstatus === 'APPROVED') { + $out[] = "$ref (Order {$tx->order_id}): Kunde hat freigegeben, Rueckkehr verloren -> Capture nachholen" . ($dryRun ? ' [dry-run]' : ''); + if (!$dryRun) { + try { + $c = $this->capture($ref, (int) $tx->order_id, (string) $tx->order_number); + if ($c['capture_status'] === 'COMPLETED') { + $this->markPaid($ref, $c['amount'], $c['currency'], $c['capture_id'], $c['payer_email'], $c['payer_id'], 'reconcile'); + } + } catch (\Throwable $e) { + $out[] = " Capture fehlgeschlagen: " . $e->getMessage(); + } + } + } elseif (\in_array($pstatus, ['VOIDED', 'EXPIRED'], true)) { + $out[] = "$ref (Order {$tx->order_id}): PayPal $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : ''); + if (!$dryRun) { + $this->markCancelled($ref, 'PayPal: ' . $pstatus, 'reconcile'); + } + } else { + // CREATED / SAVED / PAYER_ACTION_REQUIRED: Kunde ist noch unterwegs oder hat das Fenster zugemacht. + $age = (\time() - \strtotime((string) $tx->created_at)) / 3600; + if ($age >= 24) { + $out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : ''); + if (!$dryRun) { + $this->markCancelled($ref, 'PayPal ' . $pstatus . ' nach ' . \round($age) . ' h', 'reconcile'); + } + } else { + $out[] = "$ref (Order {$tx->order_id}): $pstatus, " . \round($age, 1) . " h alt -> warten"; + } + } + } + return $out; + } + + // ----------------------------------------------------------------- intern + + /** @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,pending_reason:string} */ + private function capture(string $ref, int $orderId, string $number): array + { + [$status, $body] = $this->client()->request('POST', '/v2/checkout/orders/' . \rawurlencode($ref) . '/capture', null, + ['PayPal-Request-Id: capture-' . $ref, 'Prefer: return=representation']); + Log::write($this->module(), 'capture', $orderId, $number, $ref, (string) ($body['status'] ?? ''), $status, $body); + + if ($status === 201 || $status === 200) { + return $this->normalize($body); + } + if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) { + [$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref)); + Log::write($this->module(), 'get', $orderId, $number, $ref, (string) ($b2['status'] ?? ''), $s2, $b2); + return $this->normalize($b2); + } + throw new \RuntimeException('Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body)); + } + + /** @param array $body */ + private 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'] ?? ''), + 'pending_reason' => (string) ($cap['status_details']['reason'] ?? ''), + ]; + } + + /** @param array $body */ + private function hasIssue(array $body, string $issue): bool + { + foreach (($body['details'] ?? []) as $d) { + if (($d['issue'] ?? '') === $issue) { + return true; + } + } + return false; + } +} diff --git a/core/paypal_v2/.htaccess b/core/paypal_v2/.htaccess deleted file mode 100644 index f499787..0000000 --- a/core/paypal_v2/.htaccess +++ /dev/null @@ -1,14 +0,0 @@ -# Nur die beiden Endpunkte duerfen direkt aufgerufen werden. -# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt. - - Require all denied - - - - Require all granted - - -# Token-Cache o.ae. niemals ausliefern - - Require all denied - diff --git a/core/paypal_v2/Config.php b/core/paypal_v2/Config.php deleted file mode 100644 index 24c1e9e..0000000 --- a/core/paypal_v2/Config.php +++ /dev/null @@ -1,99 +0,0 @@ - $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; - } -} diff --git a/core/paypal_v2/Gateway.php b/core/paypal_v2/Gateway.php deleted file mode 100644 index 0e5e1d9..0000000 --- a/core/paypal_v2/Gateway.php +++ /dev/null @@ -1,250 +0,0 @@ -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 $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 */ - 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]; - } -} diff --git a/core/paypal_v2/Orders.php b/core/paypal_v2/Orders.php deleted file mode 100644 index e44aa6c..0000000 --- a/core/paypal_v2/Orders.php +++ /dev/null @@ -1,152 +0,0 @@ -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 */ - 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 $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 $body */ - private function hasIssue(array $body, string $issue): bool - { - foreach (($body['details'] ?? []) as $d) { - if (($d['issue'] ?? '') === $issue) { - return true; - } - } - return false; - } -} diff --git a/core/paypal_v2/PayPalClient.php b/core/paypal_v2/PayPalClient.php deleted file mode 100644 index 51e3759..0000000 --- a/core/paypal_v2/PayPalClient.php +++ /dev/null @@ -1,133 +0,0 @@ -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|null $payload - * @param list $extraHeaders - * @return array{0:int,1:array} [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 $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'; - } -} diff --git a/core/paypal_v2/Webhook.php b/core/paypal_v2/Webhook.php deleted file mode 100644 index 8262567..0000000 --- a/core/paypal_v2/Webhook.php +++ /dev/null @@ -1,53 +0,0 @@ - $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'); - } -} diff --git a/core/paypal_v2/bootstrap.php b/core/paypal_v2/bootstrap.php deleted file mode 100644 index 69a0391..0000000 --- a/core/paypal_v2/bootstrap.php +++ /dev/null @@ -1,35 +0,0 @@ - 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); diff --git a/core/paypal_v2/install.sql b/core/paypal_v2/install.sql deleted file mode 100644 index f9edcb4..0000000 --- a/core/paypal_v2/install.sql +++ /dev/null @@ -1,74 +0,0 @@ --- ============================================================================ --- 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. --- ersetzen. = 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 --- (, , 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, , 1, 1), --- (101, , 1, 1), --- (103, , 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'; diff --git a/core/paypal_v2/return.php b/core/paypal_v2/return.php deleted file mode 100644 index dfafc51..0000000 --- a/core/paypal_v2/return.php +++ /dev/null @@ -1,49 +0,0 @@ -&PayerID=... hierher zurueck. - * -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die - * bestehende Shop-Erfolgsseite /Kasse/?success=1&order=. - * - 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'); diff --git a/core/paypal_v2/webhook.php b/core/paypal_v2/webhook.php deleted file mode 100644 index e7737f0..0000000 --- a/core/paypal_v2/webhook.php +++ /dev/null @@ -1,62 +0,0 @@ - $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'; diff --git a/modules/admin_payment_method_editor.php b/modules/admin_payment_method_editor.php index 0d71538..f5d6519 100644 --- a/modules/admin_payment_method_editor.php +++ b/modules/admin_payment_method_editor.php @@ -41,6 +41,14 @@ class admin_payment_method_editor { $is_submitted = false; } + // 13.09.2026: Reiter "Einstellungen" speichern (Konfigurationsgruppe + Secrets). + // Vorher hatte dieses Formular keinen Empfaenger -- es postete ins Leere. + if (isset($_POST['payment_config_save']) && $has_id) { + $this->save_provider_config((int)$_POST['id']); + $is_submitted = false; + $_GET['id'] = (int)$_POST['id']; + } + // decide what CRUD method to call and call it in ListAndEdit // create $payment_method_object = new Payment_method($this->base_object); @@ -60,6 +68,7 @@ class admin_payment_method_editor { $config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id); $this->layout_object->assign('config_data', $config_data); } + $this->assign_provider($payment_method_data); }// read elseif($has_id && !$is_submitted && $_GET['mod'] != 'delete') { // template variable name @@ -72,6 +81,7 @@ class admin_payment_method_editor { $config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id); $this->layout_object->assign('config_data', $config_data); } + $this->assign_provider($payment_method_data); } // update elseif($has_id && $is_submitted && $_GET['mod'] != 'delete') { $payment_method_object->id = $_POST['id']; @@ -92,6 +102,7 @@ class admin_payment_method_editor { $config_data = $this->config->get_config_elements($payment_method_data->configuration_group_id); $this->layout_object->assign('config_data', $config_data); } + $this->assign_provider($payment_method_data); } // delete elseif($has_id && isset($_GET['mod']) && $_GET['mod'] == 'delete'){ $payment_method_object->id = $_GET['id']; @@ -103,6 +114,76 @@ class admin_payment_method_editor { return $this->layout_object->fetch('admin_payment_method_editor.tpl'); } + + /** + * 13.09.2026 -- Zahlungsanbieter aus core/payments: Secret-Felder fuers Template. + * Klartext wird nie ausgeliefert, nur "hinterlegt seit". + */ + private function assign_provider($payment_method_data) { + if (!is_object($payment_method_data) || empty($payment_method_data->module)) { + return; + } + include_once './core/payments/Registry.php'; + if (!\Payments\Registry::has((string)$payment_method_data->module)) { + return; + } + try { + $provider = \Payments\Registry::get((string)$payment_method_data->module, $this->base_object); + } catch (Throwable $e) { + $this->layout_object->assign('provider_error', $e->getMessage()); + return; + } + if (!$provider) { + return; + } + $key_ok = \Payments\Secrets::keyAvailable(); + $status = $key_ok ? \Payments\Secrets::status($this->base_object->db, $provider->module()) : array(); + $fields = array(); + foreach ($provider->secretFields() as $name => $label) { + $fields[] = array( + 'name' => $name, + 'label' => $label, + 'since' => isset($status[$name]) ? $status[$name] : '', + ); + } + $this->layout_object->assign('provider_label', $provider->label()); + $this->layout_object->assign('provider_module', $provider->module()); + $this->layout_object->assign('provider_key_ok', $key_ok ? 1 : 0); + $this->layout_object->assign('provider_key_path', \Payments\Secrets::keyPath()); + $this->layout_object->assign('provider_secrets', $fields); + $this->layout_object->assign('provider_webhook_url', rtrim(MAIN_URL, '/').'/payment_webhook.php?p='.$provider->module()); + } + + /** Einstellungen (configuration-Gruppe) und Secrets aus dem Reiter "Einstellungen" speichern. */ + private function save_provider_config($payment_method_id) { + $pm_object = new Payment_method($this->base_object); + $pm_object->id = $payment_method_id; + $pm = $pm_object->get_data(); + if (!$pm) { + return; + } + if (!empty($pm->configuration_group_id) && isset($_POST['form_field']) && is_array($_POST['form_field'])) { + $clean = array(); + foreach ($_POST['form_field'] as $k => $v) { + $clean[$k] = $this->base_object->db->real_escape_string((string)$v); + } + $this->config->update($pm->configuration_group_id, $clean); + } + if (!empty($pm->module) && isset($_POST['secret_field']) && is_array($_POST['secret_field'])) { + include_once './core/payments/Registry.php'; + if (\Payments\Registry::has((string)$pm->module) && \Payments\Secrets::keyAvailable()) { + $provider = \Payments\Registry::get((string)$pm->module, $this->base_object); + $allowed = $provider ? $provider->secretFields() : array(); + foreach ($_POST['secret_field'] as $name => $value) { + $value = trim((string)$value); + if ($value === '' || !isset($allowed[$name])) { + continue; // leer = unveraendert lassen + } + \Payments\Secrets::set($this->base_object->db, $provider->module(), (string)$name, $value); + } + } + } + } } diff --git a/modules/website_cashdesk.php b/modules/website_cashdesk.php index 89d978a..0089b3b 100644 --- a/modules/website_cashdesk.php +++ b/modules/website_cashdesk.php @@ -13,6 +13,7 @@ include_once('./core/customer.class.php'); include_once('./core/customeraddress.class.php'); include_once('./core/shoppingcart.class.php'); include_once('./core/payment_method.class.php'); +include_once('./core/payments/Registry.php'); // 13.09.2026: Zahlungsanbieter mit Weiterleitung (PayPal v2, ...) include_once('./core/deliverer.class.php'); // should be called shipping methods include_once('./core/order.class.php'); include_once('./core/cs_ticket.class.php'); @@ -70,6 +71,15 @@ class website_cashdesk { return $this->layout_object->_fetch('content_cash_desk/completed.tpl'); } + // 13.09.2026 -- Rueckkehr von einem Zahlungsanbieter aus core/payments + // (?payment_return= bzw. ?payment_cancel=). Laeuft hier im Cashdesk, + // weil die Session gebraucht wird (Pending-Riegel, Warenkorb). Steht vor dem + // Kunden-/Login-Check: nach dem Sprung ueber den Anbieter ist kein ?guest_order=1 + // mehr in der URL, ein Gast haette sonst keine customer_id. + if (isset($_GET['payment_return']) || isset($_GET['payment_cancel'])) { + return $this->payment_provider_return(); + } + // 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. @@ -224,6 +234,16 @@ class website_cashdesk { exit; } + // 13.09.2026: dasselbe fuer Anbieter aus core/payments -- zurueck zur + // Approve-URL des offenen Vorgangs statt einer zweiten Bestellung. + $offen_pv = $this->payment_provider_pending(); + if ($offen_pv && (int)$offen_pv['order_id'] === (int)$erledigt['order_id']) { + error_log('Checkout: Vorgang '.$erledigt['order_number'] + . ' bereits bestellt, zurueck auf offenen '.$offen_pv['module'].'-Vorgang'); + header('Location: '.$offen_pv['approve_url']); + exit; + } + error_log('Checkout: Vorgang bereits bestellt (Order ' . $erledigt['order_number'].') -- keine zweite Order angelegt'); @@ -265,6 +285,21 @@ class website_cashdesk { exit; } + // 13.09.2026: derselbe Riegel fuer Anbieter aus core/payments. Der offene + // Vorgang liegt in $_SESSION['easyshop']['payment_pending'] (gesetzt vom + // Provider beim Start) -- gleiche Zahlart, gleicher Betrag, juenger als + // eine Stunde, nicht bezahlt: zurueck zur Approve-URL. + $pending_pv = $this->payment_provider_pending(); + if ($pending_pv + && $pending_pv['module'] === $pending_module + && abs(round((float)$this->shoppingcart_data['order_total'], 2) - (float)$pending_pv['total']) < 0.01) { + + error_log('Payments: doppelter ready2fly-POST abgefangen, zurueck auf offenen ' + . $pending_pv['module'].'-Vorgang '.$pending_pv['reference']); + header('Location: '.$pending_pv['approve_url']); + exit; + } + $is_saved = $this->save_order(); if ($is_saved) { @@ -313,7 +348,7 @@ class website_cashdesk { return $this->layout_object->_fetch('content_cash_desk.tpl'); - } else if ($_GET['token']) { + } else if (isset($_GET['token']) && $_GET['token']) { return $this->change_order('paypal'); } else { // login @@ -326,6 +361,93 @@ class website_cashdesk { // end run + /** + * 13.09.2026 -- Zahlungsanbieter aus core/payments. + * + * Bei diesen Zahlarten (und beim alten PayPal) ist die Bestellung erst nach der + * Rueckkehr vom Anbieter bezahlt: Bestellbestaetigung und Warenkorb-Leerung warten + * bis dahin. Alle anderen Zahlarten sind mit dem Abschicken verbindlich. + */ + private function zahlung_erst_spaeter($module) { + $module = strtolower((string)$module); + if ($module === 'paypal') { + return true; // Legacy-NVP-PayPal, raeumt in PaypalPaymentMethod::change_order() selbst auf + } + $provider = $this->payment_provider($module); + return $provider ? $provider->paysAfterRedirect() : false; + } + + /** @return \Payments\ProviderInterface|null */ + private function payment_provider($module) { + if (!class_exists('\Payments\Registry') || !\Payments\Registry::has((string)$module)) { + return null; + } + try { + return \Payments\Registry::get((string)$module, $this->base_object); + } catch (Throwable $e) { + error_log('Payments: Provider '.$module.' nicht ladbar: '.$e->getMessage()); + return null; + } + } + + /** + * Offener, unbezahlter Vorgang eines Anbieters in dieser Session (juenger als 1 h), + * sonst null. Grundlage der Riegel gegen Doppelbestellungen. + */ + private function payment_provider_pending() { + $p = isset($_SESSION['easyshop']['payment_pending']) ? $_SESSION['easyshop']['payment_pending'] : null; + if (!is_array($p) || empty($p['module']) || empty($p['approve_url']) || empty($p['order_id'])) { + return null; + } + if ((time() - (int)(isset($p['time']) ? $p['time'] : 0)) >= 3600) { + unset($_SESSION['easyshop']['payment_pending']); + return null; + } + $provider = $this->payment_provider($p['module']); + if (!$provider || $provider->isPaid((int)$p['order_id'])) { + unset($_SESSION['easyshop']['payment_pending']); + return null; + } + return $p; + } + + /** + * Rueckkehr vom Anbieter (?payment_return= | ?payment_cancel=). + * Bezahlt -> Redirect auf die Erfolgsseite (F5-sicher). Sonst Hinweisseite, der + * Warenkorb bleibt in jedem Fall stehen. + */ + private function payment_provider_return() { + $is_cancel = isset($_GET['payment_cancel']); + $module = strtolower(trim((string)($is_cancel ? $_GET['payment_cancel'] : $_GET['payment_return']))); + $provider = $this->payment_provider($module); + + if (!$provider) { + $this->layout_object->assign('call', 'payment_error'); + $this->layout_object->assign('message', 'Die Zahlungsart konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.'); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + + try { + $result = $is_cancel ? $provider->onCancel($_GET) : $provider->onReturn($_GET); + } catch (Throwable $e) { + error_log('Payments: '.$module.' return/cancel: '.$e->getMessage()); + $result = \Payments\Result::error('Bei der Rueckmeldung des Zahlungsanbieters ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da.'); + } + + if ($result->status === \Payments\Result::PAID) { + header('Location: /Kasse/?success=1&order='.urlencode($result->orderNumber)); + exit; + } + + $this->layout_object->assign('provider_label', $provider->label()); + $this->layout_object->assign('order_number', $result->orderNumber); + $this->layout_object->assign('message', $result->message); + $this->layout_object->assign('call', + $result->status === \Payments\Result::CANCELLED ? 'payment_cancelled' + : ($result->status === \Payments\Result::PENDING ? 'payment_pending' : 'payment_error')); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + private function setup_countries() { $country_object = new Country($this->base_object); @@ -842,7 +964,7 @@ class website_cashdesk { ? strtolower((string)$this->payment_methods[$this->payment_method_id]->module) : ''; - if ($mail_modul !== 'paypal') { + if (!$this->zahlung_erst_spaeter($mail_modul)) { // send email $ticket_object = new Cs_ticket($this->base_object); $ticket_object->event(3, $this->customer_data->id, $this->order_id); @@ -874,8 +996,8 @@ class website_cashdesk { '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' + 'detail' => $this->zahlung_erst_spaeter($mail_modul) + ? $mail_modul.': noch nicht bezahlt' : 'sofort verbindlich' )); } @@ -917,7 +1039,7 @@ class website_cashdesk { ? strtolower((string)$this->payment_methods[$this->payment_method_id]->module) : ''; - if ($payment_module !== 'paypal') { + if (!$this->zahlung_erst_spaeter($payment_module)) { $shoppingcart_object = new Shoppingcart($this->base_object); $shoppingcart_object->clear(1); } diff --git a/payment_webhook.php b/payment_webhook.php new file mode 100644 index 0000000..043d833 --- /dev/null +++ b/payment_webhook.php @@ -0,0 +1,37 @@ + + * + * Server-zu-Server, keine Session. Der Provider prueft die Signatur selbst und + * entscheidet, was mit dem Ereignis passiert. Antwort 2xx = angenommen, sonst + * stellt der Anbieter erneut zu. + */ +declare(strict_types=1); + +require __DIR__ . '/core/payments/bootstrap.php'; + +$module = isset($_GET['p']) ? (string) $_GET['p'] : ''; +$provider = \Payments\Registry::get($module, $base_object); +if (!$provider) { + \http_response_code(404); + echo 'unknown provider'; + exit; +} + +$rawBody = (string) \file_get_contents('php://input'); +$headers = []; +foreach ($_SERVER as $key => $value) { + if (\strncmp($key, 'HTTP_', 5) === 0) { + $headers[\strtolower(\str_replace('_', '-', \substr($key, 5)))] = (string) $value; + } +} + +try { + $status = $provider->onWebhook($headers, $rawBody); +} catch (\Throwable $e) { + \error_log('payment_webhook ' . $module . ': ' . $e->getMessage()); + $status = 500; +} + +\http_response_code($status); +echo $status < 300 ? 'ok' : 'rejected'; diff --git a/themes/admin/templates/admin_payment_method_editor.tpl b/themes/admin/templates/admin_payment_method_editor.tpl index 4827240..e7653e9 100644 --- a/themes/admin/templates/admin_payment_method_editor.tpl +++ b/themes/admin/templates/admin_payment_method_editor.tpl @@ -35,11 +35,36 @@ - {if $config_data} + {if $config_data || $provider_secrets}
- - + + + {if $provider_secrets} +
Zugangsdaten {$provider_label|escape} (verschlüsselt gespeichert)
+ {if !$provider_key_ok} +
+ Schlüsseldatei fehlt: {$provider_key_path|escape}
+ Anlegen per SSH: /opt/plesk/php/8.2/bin/php core/payments/cli.php init-key +
+
+ {/if} + {foreach from=$provider_secrets item=sec} +
+ + +
+
+
{if $sec.since}hinterlegt seit {$sec.since|escape}{else}fehlt{/if} +
+
+ {/foreach} +
+
Webhook-URL für das PayPal-Dashboard: {$provider_webhook_url|escape} +
+
+
Einstellungen
+ {/if} {foreach from=$config_data item=form_element} {if $form_element->text == 'multi'}
diff --git a/themes/easyshop_advanced/templates/content_cash_desk/payment_result.tpl b/themes/easyshop_advanced/templates/content_cash_desk/payment_result.tpl new file mode 100644 index 0000000..ee58e7f --- /dev/null +++ b/themes/easyshop_advanced/templates/content_cash_desk/payment_result.tpl @@ -0,0 +1,37 @@ + +
+
+

{if $provider_label}{$provider_label|escape}{else}Zahlung{/if}

+
+
+ +
+
+ {if $call == 'payment_open'} +

Vielen Dank für Ihre Bestellung.

+ Sie werden zur Bezahlung weitergeleitet. Falls das nicht automatisch geschieht, + klicken Sie bitte auf den folgenden Button.

+ + Weiter zur Bezahlung +

+ + {elseif $call == 'payment_cancelled'} +

Sie haben die Bezahlung abgebrochen.

+ Ihr Warenkorb ist unverändert – Sie können die Bestellung jederzeit + mit einer anderen Zahlart abschließen.

+ Zurück zur Kasse +

+ + {elseif $call == 'payment_pending'} +

{$message}

+ {if $order_number}

Ihre Bestellnummer: {$order_number|escape}

{/if} + + {else} +

{$message}

+ {if $order_number}

Bestellnummer: {$order_number|escape}

{/if} + Zurück zur Kasse +

+ {/if} +
+
+
diff --git a/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl b/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl deleted file mode 100644 index 0dd2bc3..0000000 --- a/themes/easyshop_advanced/templates/content_cash_desk/paypalrest.tpl +++ /dev/null @@ -1,30 +0,0 @@ - -
-
-

PayPal

-
-
- -
-
- {if $call == 'payment_open'} - {if $paypal_link} -

Vielen Dank für Ihre Bestellung.

- Sie werden zu PayPal weitergeleitet. Falls das nicht automatisch - geschieht, klicken Sie bitte auf den folgenden Button.

- - - - Weiter zu PayPal -

- {else} -

Mit PayPal ist ein Fehler aufgetreten.

- {/if} - {/if} - - {if $call == 'payment_error'} -

{$message}

- {/if} -
-
-