feat(payments): Anbieter-Gerüst core/payments mit PayPal Orders v2

- 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
This commit is contained in:
Thomas Bartelt
2026-09-13 19:51:52 +02:00
parent af81e8f63a
commit 397fb5944a
30 changed files with 2002 additions and 1104 deletions
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'paypal_v2'.
*
* Duenn mit Absicht. Der Cashdesk erwartet diese Klasse (ucfirst(module).'PaymentMethod')
* mit set_data/before_order/during_order/after_order; alles Fachliche liegt im Provider
* unter core/payments/providers/paypal_v2/. Ein weiterer Anbieter bekommt eine Kopie
* dieser Datei mit anderem Modulnamen -- mehr nicht.
*/
require_once __DIR__ . '/../payments/Registry.php';
class Paypal_v2PaymentMethod
{
private const MODULE = 'paypal_v2';
private $base_object;
private $layout_object;
private $customer_data;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->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 &ndash; 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 '';
}
}
@@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
/**
* Zahlungsart "PayPal Checkout (Orders v2 / REST)".
*
* Neues, eigenstaendiges Modul – laeuft parallel zum Legacy-NVP-Modul
* (PaypalPaymentMethod) und teilt sich mit ihm KEINEN Code.
*
* Aktivierung ueber einen eigenen Eintrag in `payment_methods` mit
* module = 'paypalrest'. Der Cashdesk instanziiert dann automatisch diese
* Klasse (ucfirst('paypalrest').'PaymentMethod' => PaypalrestPaymentMethod).
*
* Flow: after_order() legt die PayPal-Order an und leitet den Kunden zur
* Approve-URL weiter. Der Rueckweg laeuft NICHT ueber den Cashdesk (der ist auf
* das Legacy-Modul verdrahtet), sondern ueber /core/paypal_v2/return.php.
*/
require_once __DIR__ . '/../paypal_v2/Config.php';
require_once __DIR__ . '/../paypal_v2/PayPalClient.php';
require_once __DIR__ . '/../paypal_v2/Orders.php';
require_once __DIR__ . '/../paypal_v2/Gateway.php';
class PaypalrestPaymentMethod
{
private $base_object;
private $layout_object;
private $customer_data;
private $shipping_address;
private $billing_address;
private $shoppingcart;
private $payment_method;
private $shipping_method;
private $gift_certificate;
private $order_id;
public function __construct($base_object, $layout_object)
{
$this->base_object = $base_object;
$this->layout_object = $layout_object;
$this->customer_data = false;
$this->shipping_address = false;
$this->billing_address = false;
$this->shoppingcart = false;
$this->payment_method = false;
$this->shipping_method = false;
$this->gift_certificate = false;
$this->order_id = false;
}
public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false)
{
$this->customer_data = $customer;
$this->shipping_address = $shipping;
$this->billing_address = $billing;
$this->shoppingcart = $shoppingcart;
$this->payment_method = $delivery;
$this->shipping_method = $payment;
$this->order_id = $order_id;
}
public function set_gift_certificate($gift_certificate)
{
$this->gift_certificate = $gift_certificate;
}
public function set_order_id($order_id)
{
$this->order_id = $order_id;
}
/** Vom Cashdesk vor dem Speichern aufgerufen. Kein Fehler => leere Rueckgabe. */
public function before_order($argument = null)
{
return '';
}
public function during_order($argument = null)
{
return '';
}
/**
* Nach dem Speichern des Auftrags: PayPal-Order anlegen und weiterleiten.
*
* @param float|false $order_total Bruttosumme des Auftrags
* @return string HTML-Fallback (nur, wenn keine Weiterleitung moeglich war)
*/
public function after_order($order_total = false)
{
$charged = (float) $order_total;
// Gutschein wie im Legacy-Modul vom zu zahlenden Betrag abziehen
if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) {
$charged -= (float) $this->gift_certificate->with_vat;
}
try {
$gateway = new \PaypalV2\Gateway($this->base_object);
$order_number = $this->resolve_order_number((int) $this->order_id);
$approve_url = $gateway->createOrderAndGetApprovalUrl((int) $this->order_id, $charged, $order_number);
} catch (\Throwable $e) {
\error_log('PayPal v2 after_order: ' . $e->getMessage());
return $this->render_error('Beim Initialisieren von PayPal ist ein Fehler aufgetreten.');
}
// Bevorzugt harte Weiterleitung (an dieser Stelle sind noch keine Header gesendet).
if (!\headers_sent()) {
\header('Location: ' . $approve_url);
exit;
}
// Fallback: Button/Meta-Refresh im Template
$this->layout_object->assign('call', 'payment_open');
$this->layout_object->assign('paypal_link', $approve_url);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
/** Wird vom Cashdesk fuer dieses Modul NICHT aufgerufen (Return laeuft ueber return.php). */
public function change_order()
{
return '';
}
// ---------------------------------------------------------------- intern
private function resolve_order_number(int $order_id): string
{
$db = $this->base_object->db;
$res = $db->query('SELECT order_number FROM orders WHERE id=' . $order_id . ' LIMIT 1');
if ($res && $res->num_rows > 0) {
return (string) $res->fetch_object()->order_number;
}
return (string) $order_id;
}
private function render_error(string $message): string
{
$this->layout_object->assign('call', 'payment_error');
$this->layout_object->assign('message', $message);
return $this->layout_object->_fetch('content_cash_desk/paypalrest.tpl');
}
}