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
+429
View File
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Gemeinsamer Unterbau aller Anbieter mit Weiterleitung.
*
* Hier liegt alles, was 2026 am v1-PayPal muehsam nachgeruestet wurde und fuer
* jeden weiteren Anbieter genauso gelten muss:
* - Vorgang in payment_transactions mit echter Uhrzeit, Session und Sollbetrag
* - Bestellbestaetigung ERST nach eingezogener Zahlung
* - Warenkorb ERST nach eingezogener Zahlung leeren (ueber die Session von damals)
* - idempotentes "bezahlt": zweite Rueckkehr, Webhook-Race, Abgleicher -- egal wer
* zuerst kommt, es passiert genau einmal
* - Betragsabgleich gegen den Sollbetrag (Manipulationsschutz)
* - Ereignisse fuer den Funnel (shop_event) und Rohprotokoll (Payments\Log)
*
* Einstellungen kommen aus der Konfigurationsgruppe = module() (Tabelle configuration,
* pflegbar im Zahlart-Editor), Zugangsdaten aus Payments\Secrets.
*/
abstract class AbstractProvider implements ProviderInterface
{
protected object $base;
protected \mysqli $db;
/** @var array<string,string>|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<object> */
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 [];
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Protokoll aller Anbieter-Antworten in PostgreSQL (krempl_data.payment_log).
*
* Warum PG: MySQL `orders` hat keine Uhrzeit und keinen Platz fuer JSON. Hier liegt
* jede Anfrage/Antwort mit Zeitstempel -- die Beweiskette bei Rueckfragen von
* Kunden oder Anbietern.
*
* Faellt STILL aus (wie shop_event): ein Protokoll darf den Checkout nie gefaehrden.
* Verbindung kommt aus core/postgres/config.ini, Sektion [postgresql].
*/
final class Log
{
/** @var resource|\PgSql\Connection|null|false */
private static $conn = null;
private static bool $dead = false;
/**
* @param array<mixed>|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;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Vertrag fuer Zahlungsanbieter mit Weiterleitung (PayPal v2, spaeter Amazon Pay,
* Mollie, Klarna Payments ...).
*
* Lebenszyklus einer Zahlung:
* 1. Cashdesk legt die Shop-Bestellung an (Zahlstatus offen).
* 2. start() -> 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<string,mixed> $query $_GET der Rueckkehr-URL
*/
public function onReturn(array $query): Result;
/** @param array<string,mixed> $query */
public function onCancel(array $query): Result;
/**
* Server-zu-Server-Nachricht des Anbieters.
* @param array<string,string> $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<string>
*/
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<string,string>
*/
public function secretFields(): array;
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Findet den Anbieter zu einem payment_methods.module.
*
* Konvention: core/payments/providers/<module>/Provider.php mit Klasse
* Payments\Providers\<Module in StudlyCase>\Provider
* Beispiel: module 'paypal_v2' -> Payments\Providers\PaypalV2\Provider
*
* Neuer Anbieter = neues Verzeichnis, sonst nichts.
*/
final class Registry
{
/** @var array<string,ProviderInterface|null> */
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<string> */
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, '_'));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Ergebnis von onReturn()/onCancel(): sagt dem Cashdesk, was er dem Kunden zeigt.
*/
final class Result
{
public const PAID = 'paid'; // bezahlt -> 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);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Verschluesselte Zugangsdaten der Zahlungsanbieter.
*
* Werte liegen in MySQL `payment_provider_secrets` (libsodium secretbox), der
* Schluessel liegt als Datei AUSSERHALB des Docroots:
* <vhost>/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<string,string> 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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
/**
* Standalone-Bootstrap fuer Endpunkte OHNE Shop-Router und OHNE Session:
* payment_webhook.php (Docroot) und core/payments/cli.php.
*
* Bindet den Shop-Kern (DB + Config, Konstanten ROOT_DIR/MAIN_URL) ein und stellt
* $base_object bereit. Session wird bewusst nicht gestartet.
*/
declare(strict_types=1);
\ini_set('display_errors', '0');
\error_reporting(E_ALL);
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docroot === '' || !\is_dir($docroot . '/core')) {
$docroot = \dirname(__DIR__, 2);
$_SERVER['DOCUMENT_ROOT'] = $docroot;
}
\chdir($docroot);
// base.class.php nutzt relative Includes und liest config.inc.php
require_once $docroot . '/core/base.class.php';
require_once $docroot . '/core/registry.class.php';
require_once $docroot . '/core/order.class.php';
if (\is_file($docroot . '/core/postgres/shop_events.php')) {
require_once $docroot . '/core/postgres/shop_events.php';
}
require_once __DIR__ . '/Registry.php';
\Payments\Registry::requireCore();
$base_object = new base();
Registry::set('base', $base_object);
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* Kommandozeile fuer die Zahlungsanbieter. Immer mit dem PHP des Shops aufrufen:
*
* /opt/plesk/php/8.2/bin/php core/payments/cli.php reconcile [modul] [--apply]
* Haengende Vorgaenge nachziehen. Ohne --apply nur Bericht.
* Cron-Vorschlag (alle 15 Minuten): ... cli.php reconcile --apply >> logs/payments_reconcile.log
*
* ... cli.php init-key
* Schluesseldatei fuer die Secrets anlegen (einmalig je Server).
*
* ... cli.php secret <modul> <name> [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 <modul> <name> [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;
}
+66
View File
@@ -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;
+20
View File
@@ -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);
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
* TLS-Pruefung erzwungen. Access-Token wird pro Modus im Cache-Verzeichnis neben dem
* Secrets-Schluessel abgelegt (ausserhalb des Docroots).
*/
final class Client
{
public function __construct(
private readonly string $apiBase,
private readonly string $clientId,
private readonly string $secret,
private readonly string $cacheFile
) {
}
public function accessToken(): string
{
if (\is_file($this->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<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
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<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->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];
}
}
@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
use Payments\Secrets;
require_once __DIR__ . '/Client.php';
/**
* PayPal Checkout, Orders API v2 (REST). Ersetzt das NVP-Express-Checkout von 2010.
*
* Ablauf: start() legt eine PayPal-Order (intent CAPTURE) an und leitet zur
* Approve-URL. onReturn() captured und setzt bezahlt. onWebhook() bestaetigt
* PAYMENT.CAPTURE.COMPLETED serverseitig. reconcile() zieht Vorgaenge nach, deren
* Rueckkehr verloren ging (APPROVED aber nie gecaptured).
*
* Einstellungen (configuration, Gruppe paypal_v2): mode, brand_name, send_address, currency
* Secrets (payment_provider_secrets): sandbox_client_id, sandbox_secret,
* live_client_id, live_secret, webhook_id
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
public function module(): string
{
return 'paypal_v2';
}
public function label(): string
{
return 'PayPal (v2)';
}
public function secretFields(): array
{
return [
'sandbox_client_id' => '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<string,string> $headers @param array<mixed> $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<mixed> $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<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}