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

This commit is contained in:
Thomas Bartelt
2026-09-13 19:20:24 +02:00
parent 20d55f77b8
commit af81e8f63a
22 changed files with 1963 additions and 58 deletions
+14
View File
@@ -0,0 +1,14 @@
# Nur die beiden Endpunkte duerfen direkt aufgerufen werden.
# Klassen/Bootstrap werden ausschliesslich per PHP-include genutzt.
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
<FilesMatch "^(return|webhook)\.php$">
Require all granted
</FilesMatch>
# Token-Cache o.ae. niemals ausliefern
<FilesMatch "^\.">
Require all denied
</FilesMatch>
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Laedt die geschuetzte PayPal-v2-Konfiguration von ausserhalb des Webroots.
*/
final class Config
{
/** Kandidaten-Pfade fuer die Config (erster lesbarer gewinnt). */
private const PATHS = [
'/var/www/vhosts/intelectra.de/import/paypal_v2/config.php',
];
private static ?array $data = null;
private static ?string $resolvedPath = null;
public static function load(): array
{
if (self::$data !== null) {
return self::$data;
}
foreach (self::PATHS as $path) {
if (is_file($path) && is_readable($path)) {
$cfg = include $path;
if (!is_array($cfg)) {
throw new \RuntimeException('PayPal v2: Config liefert kein Array: ' . $path);
}
self::$data = $cfg;
self::$resolvedPath = $path;
return self::$data;
}
}
throw new \RuntimeException('PayPal v2: Keine lesbare Config-Datei gefunden.');
}
public static function configPath(): string
{
self::load();
return (string) self::$resolvedPath;
}
/** Verzeichnis der Config – dient auch als Ablage fuer den Token-Cache. */
public static function dir(): string
{
return \dirname(self::configPath());
}
public static function mode(): string
{
$cfg = self::load();
return (($cfg['mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox';
}
/** @return array{client_id:string,secret:string,webhook_id:string} */
public static function creds(): array
{
$cfg = self::load();
$mode = self::mode();
$c = $cfg[$mode] ?? [];
$clientId = (string) ($c['client_id'] ?? '');
$secret = (string) ($c['secret'] ?? '');
if ($clientId === '' || $secret === '' || \str_starts_with($clientId, 'HIER_')) {
throw new \RuntimeException('PayPal v2: Zugangsdaten fuer Modus "' . $mode . '" fehlen oder sind Platzhalter.');
}
return [
'client_id' => $clientId,
'secret' => $secret,
'webhook_id' => (string) ($c['webhook_id'] ?? ''),
];
}
public static function apiBase(): string
{
return self::mode() === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
}
public static function webhookId(): string
{
$cfg = self::load();
$mode = self::mode();
return (string) ($cfg[$mode]['webhook_id'] ?? '');
}
/** @return mixed */
public static function get(string $key, $default = null)
{
$cfg = self::load();
return $cfg[$key] ?? $default;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Bindeglied zwischen PayPal-Orders-v2 und dem Shop-Datenmodell.
*
* Zentralisiert:
* - Order-Anlage + Ablage der PayPal-Order-ID (in orders.paypal_token wiederverwendet)
* - Capture inkl. Betrags-/Waehrungsabgleich (Manipulationsschutz)
* - idempotentes Setzen des Zahlstatus (F5, Doppel-Return, Webhook-Race)
*
* Erwartet das Shop-"base"-Objekt ($base->db = mysqli).
*/
final class Gateway
{
/** @var object Shop-base-Objekt mit ->db (mysqli) */
private object $base;
public function __construct(object $base)
{
$this->base = $base;
}
/**
* Legt eine PayPal-Order an, speichert die Order-ID am Shop-Auftrag und
* gibt die Approve-URL zurueck, zu der der Kunde weitergeleitet wird.
*/
public function createOrderAndGetApprovalUrl(int $shopOrderId, float $charged, string $orderNumber): string
{
$shopBase = \rtrim((string) Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
$returnUrl = $shopBase . '/core/paypal_v2/return.php';
$cancelUrl = $shopBase . '/core/paypal_v2/return.php?cancel=1';
$orders = new Orders();
$res = $orders->create($charged, $orderNumber, (string) $shopOrderId, $returnUrl, $cancelUrl);
// PayPal-Order-ID am Auftrag ablegen (vorhandene Spalte wiederverwendet)
$order = new \Order($this->base);
$order->set_order_paypal_token($res['id'], $shopOrderId);
// Exakt berechneten Betrag fuer den spaeteren Abgleich sichern (best effort;
// funktioniert nur, wenn die Spalte paypal_v2_expected_amount existiert).
$this->storeExpectedAmount($shopOrderId, $charged);
return $res['approve_url'];
}
/**
* Schliesst die Zahlung anhand der PayPal-Order-ID ab (Return-Flow).
*
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
public function finalizeByPaypalOrderId(string $paypalOrderId): array
{
$shopOrder = $this->findOrderByToken($paypalOrderId);
if ($shopOrder === null) {
return $this->result(false, '', 0, 'Bestellung zu dieser Zahlung nicht gefunden.');
}
$orderId = (int) $shopOrder->id;
// Bereits bezahlt? -> idempotent OK
if ((string) $shopOrder->payment_status === '2') {
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'bereits bezahlt');
}
$cap = (new Orders())->capture($paypalOrderId);
if ($cap['capture_status'] !== 'COMPLETED') {
return $this->result(
false,
(string) $shopOrder->order_number,
$orderId,
'Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ').'
);
}
if (!$this->amountMatches($shopOrder, $cap['amount'], $cap['currency'], $orderId, $paypalOrderId)) {
return $this->result(false, (string) $shopOrder->order_number, $orderId, 'Betrag stimmt nicht ueberein.');
}
$this->markPaid($orderId, $cap);
return $this->result(true, (string) $shopOrder->order_number, $orderId, 'ok');
}
/**
* Bestaetigt eine Zahlung aus einem verifizierten Webhook-Event
* (PAYMENT.CAPTURE.COMPLETED). Idempotent.
*
* @param array<mixed> $resource Das "resource"-Objekt des Webhook-Events (die Capture)
*/
public function confirmFromWebhookCapture(array $resource): bool
{
$shopOrderId = (int) ($resource['custom_id'] ?? 0);
if ($shopOrderId <= 0) {
return false;
}
$shopOrder = $this->findOrderById($shopOrderId);
if ($shopOrder === null) {
return false;
}
if ((string) $shopOrder->payment_status === '2') {
return true; // schon bezahlt
}
if (($resource['status'] ?? '') !== 'COMPLETED') {
return false;
}
$amount = (string) ($resource['amount']['value'] ?? '');
$currency = (string) ($resource['amount']['currency_code'] ?? '');
if (!$this->amountMatches($shopOrder, $amount, $currency, $shopOrderId, 'webhook')) {
return false;
}
$this->markPaid($shopOrderId, [
'capture_id' => (string) ($resource['id'] ?? ''),
'payer_email' => '',
'payer_id' => '',
]);
return true;
}
// ----------------------------------------------------------------- intern
/** @param array{capture_id?:string,payer_email?:string,payer_id?:string} $cap */
private function markPaid(int $orderId, array $cap): void
{
$db = $this->base->db;
$order = new \Order($this->base);
// payment_status = 2 (bezahlt) – identisch zur Legacy-Semantik
$order->set_billing_status('2', $orderId);
$order->set_payment_date(\date('Y-m-d'), $orderId);
$capId = $db->real_escape_string((string) ($cap['capture_id'] ?? ''));
$email = $db->real_escape_string((string) ($cap['payer_email'] ?? ''));
$payer = $db->real_escape_string((string) ($cap['payer_id'] ?? ''));
$sql = "UPDATE orders SET paypal_transaction_id='$capId'";
if ($email !== '') {
$sql .= ", paypal_email='$email'";
}
if ($payer !== '') {
$sql .= ", paypal_payerid='$payer'";
}
$sql .= ' WHERE id=' . $orderId;
$db->query($sql);
}
/**
* Prueft, ob der gezahlte Betrag zum erwarteten Betrag des Auftrags passt.
* Erwartet wird primaer paypal_v2_expected_amount, ersatzweise orders.order_total.
*/
private function amountMatches(object $shopOrder, string $paidAmount, string $paidCurrency, int $orderId, string $ref): bool
{
$expectedRaw = $shopOrder->paypal_v2_expected_amount ?? null;
if ($expectedRaw === null || $expectedRaw === '') {
$expectedRaw = $shopOrder->order_total;
}
$expected = \number_format((float) $expectedRaw, 2, '.', '');
$paid = \number_format((float) $paidAmount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$ok = ($paid === $expected) && (\strtoupper($paidCurrency) === \strtoupper($currency));
if (!$ok) {
\error_log(
"PayPal v2 BETRAGS-MISMATCH (order $orderId, ref $ref): "
. "erwartet $expected $currency, erhalten $paid $paidCurrency"
);
}
return $ok;
}
private function findOrderByToken(string $paypalOrderId): ?object
{
$db = $this->base->db;
$token = $db->real_escape_string($paypalOrderId);
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE paypal_token = '$token' LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
private function findOrderById(int $orderId): ?object
{
$db = $this->base->db;
$res = $db->query(
"SELECT id, order_number, order_total, payment_status, "
. $this->expectedAmountSelect()
. " FROM orders WHERE id = $orderId LIMIT 1"
);
return ($res && $res->num_rows > 0) ? $res->fetch_object() : null;
}
/**
* Liefert das SELECT-Fragment fuer den erwarteten Betrag. Existiert die
* optionale Spalte nicht, wird NULL selektiert (Fallback auf order_total).
*/
private function expectedAmountSelect(): string
{
return $this->columnExists('paypal_v2_expected_amount')
? 'paypal_v2_expected_amount'
: 'NULL AS paypal_v2_expected_amount';
}
private function storeExpectedAmount(int $orderId, float $charged): void
{
if (!$this->columnExists('paypal_v2_expected_amount')) {
return;
}
$db = $this->base->db;
$val = \number_format($charged, 2, '.', '');
$db->query("UPDATE orders SET paypal_v2_expected_amount='$val' WHERE id=$orderId");
}
/** @var array<string,bool> */
private static array $colCache = [];
private function columnExists(string $column): bool
{
if (isset(self::$colCache[$column])) {
return self::$colCache[$column];
}
$db = $this->base->db;
$col = $db->real_escape_string($column);
$res = $db->query("SHOW COLUMNS FROM orders LIKE '$col'");
return self::$colCache[$column] = ($res && $res->num_rows > 0);
}
/**
* @return array{ok:bool,order_number:string,order_id:int,message:string}
*/
private function result(bool $ok, string $orderNumber, int $orderId, string $message): array
{
return ['ok' => $ok, 'order_number' => $orderNumber, 'order_id' => $orderId, 'message' => $message];
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Kapselt die Orders-v2-Endpunkte: create / get / capture.
*/
final class Orders
{
private PayPalClient $client;
public function __construct(?PayPalClient $client = null)
{
$this->client = $client ?? new PayPalClient();
}
/**
* Erstellt eine PayPal-Order (intent=CAPTURE) und liefert Order-ID + Approve-URL.
*
* @return array{id:string,approve_url:string}
*/
public function create(
float $amount,
string $orderNumber,
string $shopOrderId,
string $returnUrl,
string $cancelUrl
): array {
$value = \number_format($amount, 2, '.', '');
$currency = (string) Config::get('currency', 'EUR');
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => $currency,
'value' => $value,
],
'custom_id' => $shopOrderId,
'invoice_id' => $orderNumber . '-' . $shopOrderId,
]],
'application_context' => [
'brand_name' => (string) Config::get('brand_name', 'Shop'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
'return_url' => $returnUrl,
'cancel_url' => $cancelUrl,
],
];
// Idempotenz: gleicher Request-Id => PayPal legt keine Doppel-Order an.
$requestId = 'create-' . $shopOrderId . '-' . \substr(\hash('sha256', $orderNumber . '|' . $value), 0, 16);
[$status, $body] = $this->client->request('POST', '/v2/checkout/orders', $payload, [
'PayPal-Request-Id: ' . $requestId,
]);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal v2: Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
$rel = $link['rel'] ?? '';
if ($rel === 'approve' || $rel === 'payer-action') {
$approve = (string) ($link['href'] ?? '');
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal v2: Kein Approve-Link in der Antwort.');
}
return ['id' => (string) $body['id'], 'approve_url' => $approve];
}
/** @return array<mixed> */
public function get(string $paypalOrderId): array
{
[$status, $body] = $this->client->request('GET', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId));
if ($status !== 200) {
throw new \RuntimeException('PayPal v2: Order-Abruf fehlgeschlagen (HTTP ' . $status . ').');
}
return $body;
}
/**
* Captured eine vom Kunden freigegebene Order. Idempotent:
* Ist bereits gecaptured (Doppel-Return / Webhook-Race), wird der bestehende
* Zustand aus der Order gelesen statt einen Fehler zu werfen.
*
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public function capture(string $paypalOrderId): array
{
[$status, $body] = $this->client->request(
'POST',
'/v2/checkout/orders/' . \rawurlencode($paypalOrderId) . '/capture',
null,
[
'PayPal-Request-Id: capture-' . $paypalOrderId,
'Prefer: return=representation',
]
);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
return $this->normalize($this->get($paypalOrderId));
}
throw new \RuntimeException('PayPal v2: Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body));
}
/**
* Vereinheitlicht Capture- bzw. Order-Antwort auf die Felder, die der Shop braucht.
*
* @param array<mixed> $body
* @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array}
*/
public function normalize(array $body): array
{
$cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
return [
'order_status' => (string) ($body['status'] ?? ''),
'capture_id' => (string) ($cap['id'] ?? ''),
'capture_status' => (string) ($cap['status'] ?? ''),
'amount' => (string) ($cap['amount']['value'] ?? ''),
'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
'raw' => $body,
];
}
/** @param array<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
*
* Sicherheit:
* - TLS-Verifikation ist ERZWUNGEN (kein CURLOPT_SSL_VERIFYPEER=false wie im Legacy-NVP).
* - Access-Token wird ausserhalb des Webroots zwischengespeichert (chmod 600).
*/
final class PayPalClient
{
private string $base;
public function __construct()
{
$this->base = Config::apiBase();
}
/** OAuth2 client_credentials Access-Token, gecacht bis kurz vor Ablauf. */
public function accessToken(): string
{
$cacheFile = self::tokenCacheFile();
if (\is_file($cacheFile)) {
$c = \json_decode((string) @\file_get_contents($cacheFile), true);
if (
\is_array($c)
&& ($c['mode'] ?? '') === Config::mode()
&& isset($c['token'], $c['expires'])
&& (int) $c['expires'] > \time() + 60
) {
return (string) $c['token'];
}
}
$creds = Config::creds();
[$status, $body] = $this->raw(
'POST',
'/v1/oauth2/token',
'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'],
$creds['client_id'] . ':' . $creds['secret']
);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal v2: OAuth2 fehlgeschlagen (HTTP ' . $status . ').');
}
@\file_put_contents($cacheFile, \json_encode([
'token' => (string) $data['access_token'],
'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'mode' => Config::mode(),
]), LOCK_EX);
@\chmod($cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* Authentifizierter JSON-Request.
*
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>} [HTTP-Status, dekodierter Body]
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(
[
'Content-Type: application/json',
'Authorization: Bearer ' . $this->accessToken(),
],
$extraHeaders
);
$bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
}
/**
* Roher cURL-Aufruf.
*
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
{
$ch = \curl_init($this->base . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
if ($basicAuth !== null) {
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
}
if ($body !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('PayPal v2: HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
private static function tokenCacheFile(): string
{
return Config::dir() . '/.token_cache.json';
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace PaypalV2;
/**
* Verifiziert PayPal-Webhook-Signaturen serverseitig gegen die PayPal-API.
* Nur so ist ein eingehendes Event vertrauenswuerdig.
*/
final class Webhook
{
/**
* @param array<string,string> $headers Request-Header (Schluessel lowercase)
* @param string $rawBody Roher Request-Body (unveraendert!)
*/
public function verify(array $headers, string $rawBody): bool
{
$webhookId = Config::webhookId();
if ($webhookId === '') {
\error_log('PayPal v2 Webhook: keine webhook_id konfiguriert – Event abgelehnt.');
return false;
}
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return false;
}
$payload = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
'webhook_event' => $event,
];
foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
if ($payload[$k] === '') {
return false;
}
}
[$status, $body] = (new PayPalClient())->request(
'POST',
'/v1/notifications/verify-webhook-signature',
$payload
);
return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Standalone-Bootstrap fuer die PayPal-v2-Endpunkte (return.php, webhook.php).
*
* Bindet den Shop-Kern (DB + Config) ein, OHNE den normalen index.php-Router
* und ohne das Legacy-PayPal-Modul zu beruehren.
*/
declare(strict_types=1);
// Fehler nicht an den Browser ausgeben (Produktivbetrieb)
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
// Leeres/falsches DOCUMENT_ROOT (z. B. CLI) -> aus dem Dateipfad ableiten
$docroot = \dirname(__DIR__, 2);
}
// base.class.php nutzt relative Includes (./config.inc.php etc.) -> ins Docroot wechseln
\chdir($docroot);
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/PayPalClient.php';
require_once __DIR__ . '/Orders.php';
require_once __DIR__ . '/Webhook.php';
require_once __DIR__ . '/Gateway.php';
/** @var object $base_object Shop-base mit ->db (mysqli) und ->config */
$base_object = new base();
Registry::set('base', $base_object);
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================================
-- PayPal Orders v2 (REST) – Aktivierung
-- Neues Modul, parallel zum Legacy-NVP-PayPal (payment_methods.id = 4).
--
-- In Schritten ausfuehren – nichts wird an der Legacy-Zahlart geaendert,
-- bis DU es in SCHRITT 4 bewusst tust.
-- VORHER: DB-Dump ziehen.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- SCHRITT 1 – Schema: Spalte fuer den Betragsabgleich (Manipulationsschutz).
-- Additiv, nullable, keine Datenaenderung an bestehenden Auftraegen.
-- ----------------------------------------------------------------------------
ALTER TABLE `orders`
ADD COLUMN `paypal_v2_expected_amount` DECIMAL(10,2) NULL DEFAULT NULL;
-- ----------------------------------------------------------------------------
-- SCHRITT 2 – Zahlart anlegen (klont die Legacy-PayPal-Zeile id=4).
-- helperjoinkey='pp' => identischer PayPal-Aufschlag (shipping_area.add_pp).
-- active=1, ABER noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- ----------------------------------------------------------------------------
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`,
`invoice_text`, `order_text`, `order_email_text`,
`helperjoinkey`, `configuration_group_id`)
VALUES
('PayPal', 'paypalrest', '', 1, 2,
'Bezahlung erfolgte per PayPal',
'Sie werden zur Bezahlung Ihrer Bestellung zu PayPal weitergeleitet.',
'Der zu zahlende Betrag wird per PayPal ueberwiesen.',
'pp', 'PayPalRest');
-- Neue ID merken (fuer die folgenden Schritte):
-- SELECT id FROM payment_methods WHERE module='paypalrest';
-- ----------------------------------------------------------------------------
-- SCHRITT 3 – TESTEN (Sandbox): Zahlart NUR fuer eine Testgruppe sichtbar.
-- <NEUE_ID> ersetzen. <TEST_GRUPPE> = Kundengruppe eines Test-Accounts.
-- (Kundengruppen der Legacy-Methode: 1, 101, 103 aktiv; 100 inaktiv.)
-- ----------------------------------------------------------------------------
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (<TEST_GRUPPE>, <NEUE_ID>, 1, 1);
-- ----------------------------------------------------------------------------
-- SCHRITT 4 – GO-LIVE (erst nach erfolgreichem Sandbox-Test,
-- config.php auf mode='live' + Live-Credentials!):
-- a) Neue Methode fuer die echten Gruppen freischalten (wie Legacy):
-- INSERT INTO `customer_group_payment_method_configuration`
-- (`group_id`, `payment_method_id`, `shipping_area_id`, `active`)
-- VALUES
-- (1, <NEUE_ID>, 1, 1),
-- (101, <NEUE_ID>, 1, 1),
-- (103, <NEUE_ID>, 1, 1);
--
-- b) Alte NVP-PayPal-Zahlart (id=4) deaktivieren, damit sie NICHT mehr
-- auswaehlbar ist (Bestellungen/Historie bleiben unberuehrt):
-- UPDATE `payment_methods` SET `active` = 0 WHERE `id` = 4;
-- Alternativ nur die Gruppen-Sichtbarkeit abschalten:
-- UPDATE `customer_group_payment_method_configuration`
-- SET `active` = 0 WHERE `payment_method_id` = 4;
-- ----------------------------------------------------------------------------
-- ROLLBACK (falls noetig): neue Methode wieder verstecken
-- ----------------------------------------------------------------------------
-- DELETE FROM `customer_group_payment_method_configuration`
-- WHERE `payment_method_id` = (SELECT id FROM payment_methods WHERE module='paypalrest');
-- UPDATE `payment_methods` SET `active` = 0 WHERE `module` = 'paypalrest';
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Return-Endpunkt fuer den PayPal-v2-Redirect-Flow.
*
* Ablauf:
* - Erfolg: PayPal leitet mit ?token=<PayPal-Order-ID>&PayerID=... hierher zurueck.
* -> Capture + Betragsabgleich + Zahlstatus setzen -> Weiterleitung auf die
* bestehende Shop-Erfolgsseite /Kasse/?success=1&order=<Nr>.
* - Abbruch: PayPal leitet mit ?cancel=1 hierher -> zurueck zur Kasse.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Gateway;
$shopBase = \rtrim((string) \PaypalV2\Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/');
/** Weiterleitung + sauberes Ende. */
$redirect = static function (string $url): never {
\header('Location: ' . $url);
exit;
};
// --- Abbruch durch den Kunden -------------------------------------------------
if (isset($_GET['cancel'])) {
$redirect($shopBase . '/Kasse/?paypal_cancel=1');
}
$paypalOrderId = isset($_GET['token']) ? (string) $_GET['token'] : '';
if ($paypalOrderId === '') {
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
try {
$gateway = new Gateway($base_object);
$result = $gateway->finalizeByPaypalOrderId($paypalOrderId);
} catch (\Throwable $e) {
\error_log('PayPal v2 return.php: ' . $e->getMessage());
$redirect($shopBase . '/Kasse/?paypal_error=1');
}
if ($result['ok']) {
// Bestehende Erfolgsseite des Shops wiederverwenden (completed.tpl)
$redirect($shopBase . '/Kasse/?success=1&order=' . \rawurlencode($result['order_number']));
}
\error_log('PayPal v2 return.php: Abschluss fehlgeschlagen (order ' . $result['order_id'] . '): ' . $result['message']);
$redirect($shopBase . '/Kasse/?paypal_error=1');
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Webhook-Endpunkt fuer PayPal-v2.
*
* Dient als verlaessliche Quelle des Zahlstatus (server-zu-server), unabhaengig
* davon, ob der Kunde nach der Zahlung tatsaechlich zum Shop zurueckkehrt.
*
* PayPal-Dashboard: Webhook auf diese URL anlegen, mindestens Event
* PAYMENT.CAPTURE.COMPLETED
* abonnieren und die entstehende Webhook-ID in die Config eintragen.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use PaypalV2\Webhook;
use PaypalV2\Gateway;
$rawBody = (string) \file_get_contents('php://input');
// Header einsammeln (lowercase Keys)
$headers = [];
foreach ($_SERVER as $key => $value) {
if (\strncmp($key, 'HTTP_', 5) === 0) {
$name = \strtolower(\str_replace('_', '-', \substr($key, 5)));
$headers[$name] = (string) $value;
}
}
// Signatur verifizieren – sonst sofort ablehnen
try {
if (!(new Webhook())->verify($headers, $rawBody)) {
\http_response_code(400);
echo 'invalid signature';
exit;
}
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook verify: ' . $e->getMessage());
\http_response_code(500);
exit;
}
$event = \json_decode($rawBody, true);
$type = \is_array($event) ? (string) ($event['event_type'] ?? '') : '';
try {
if ($type === 'PAYMENT.CAPTURE.COMPLETED') {
$resource = $event['resource'] ?? [];
if (\is_array($resource)) {
(new Gateway($base_object))->confirmFromWebhookCapture($resource);
}
}
// Weitere Event-Typen (Refund/Denied) koennen hier ergaenzt werden.
} catch (\Throwable $e) {
\error_log('PayPal v2 webhook handle (' . $type . '): ' . $e->getMessage());
\http_response_code(500);
exit;
}
// PayPal erwartet 2xx, sonst wird das Event erneut zugestellt.
\http_response_code(200);
echo 'ok';