diff --git a/core/payments/AbstractProvider.php b/core/payments/AbstractProvider.php index 6659d68..dee51d9 100644 --- a/core/payments/AbstractProvider.php +++ b/core/payments/AbstractProvider.php @@ -273,6 +273,11 @@ abstract class AbstractProvider implements ProviderInterface if (!$tx || $tx->status === 'paid') { return; } + // 27.09.2026: idempotent -- Rueckkehr UND Webhook melden denselben Abbruch, der + // Stornotext wurde sonst zweimal an die Bestellung gehaengt (Mollie-Test 290617). + if ($tx->status === 'cancelled') { + return; + } $this->setTransactionStatus($reference, 'cancelled', $why); $orderId = (int) $tx->order_id; // 6 = storniert (wie v1-PayPal im Cancel-Zweig). diff --git a/core/payments/bootstrap.php b/core/payments/bootstrap.php index 0744b1a..ba377f8 100644 --- a/core/payments/bootstrap.php +++ b/core/payments/bootstrap.php @@ -30,3 +30,22 @@ require_once __DIR__ . '/Registry.php'; $base_object = new base(); Registry::set('base', $base_object); + +// 27.09.2026 -- Layout-Objekt (Smarty) auch hier: die Bestellbestaetigung rendert die +// Positionen ueber DeliveryItemsHTML, das Registry::get('layout_object') holt. Ohne das +// scheiterte die Mail, wenn der WEBHOOK (nicht die Rueckkehr) die Zahlung abschloss +// ("Call to a member function assign() on null", Mollie-Test 290616). Der Kunde +// bekam dann keine Bestaetigung. start() braucht SERVER_NAME (CLI: aus MAIN_URL). +if (empty($_SERVER['SERVER_NAME']) && \defined('MAIN_URL')) { + $_SERVER['SERVER_NAME'] = (string) \parse_url((string) MAIN_URL, PHP_URL_HOST); +} +$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; // layout.class.php liest sie beim Start +try { + require_once $docroot . '/libs/smarty/libs/Smarty.class.php'; + require_once $docroot . '/core/layout.class.php'; + $layout_object = new layout(); + $layout_object->start($base_object); + Registry::set('layout_object', $layout_object); +} catch (\Throwable $e) { + \error_log('payments bootstrap: Layout nicht initialisiert -- ' . $e->getMessage()); +} diff --git a/core/payments/providers/mollie/Provider.php b/core/payments/providers/mollie/Provider.php index 091ea05..3c31c16 100644 --- a/core/payments/providers/mollie/Provider.php +++ b/core/payments/providers/mollie/Provider.php @@ -68,8 +68,9 @@ final class Provider extends AbstractProvider public function secretFields(): array { return [ - 'test_api_key' => 'Test-API-Key (test_...)', - 'live_api_key' => 'Live-API-Key (live_...)', + 'test_api_key' => 'Test-API-Key (test_...)', + 'live_api_key' => 'Live-API-Key (live_...)', + 'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)', ]; } @@ -278,15 +279,48 @@ final class Provider extends AbstractProvider // ---------------------------------------------------------------- webhook /** - * Mollie schickt application/x-www-form-urlencoded mit genau einem Feld: id=tr_... - * Keine Signatur -- die Wahrheit kommt per GET von Mollie. Antwort 200 = angenommen, - * alles andere wird bis zu 10x in 26 h wiederholt. + * Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new): + * + * klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert. + * Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer + * Zahlungsstatus weiterhin. + * next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert + * mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature + * ("sha256="), Schluessel = Signing-Secret der Subscription (Dashboard > + * Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen; + * ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen). + * + * In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der + * Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h. */ public function onWebhook(array $headers, string $rawBody): int { - $params = []; - \parse_str($rawBody, $params); - $id = (string) ($params['id'] ?? ($_POST['id'] ?? '')); + $id = ''; + $body = \ltrim($rawBody); + if ($body !== '' && $body[0] === '{') { + $event = \json_decode($body, true); + if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') { + Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event'); + return 400; + } + $check = $this->verifySignature($headers, $rawBody); + if ($check !== 'ok') { + Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null, + ['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check); + return 400; + } + $type = (string) ($event['type'] ?? ''); + $id = (string) ($event['entityId'] ?? ''); + if (!\str_starts_with($type, 'payment.')) { + // andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren + Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis'); + return 200; + } + } else { + $params = []; + \parse_str($rawBody, $params); + $id = (string) ($params['id'] ?? ($_POST['id'] ?? '')); + } if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) { Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID'); return 400; @@ -310,6 +344,34 @@ final class Provider extends AbstractProvider return 200; } + /** + * HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation + * schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen. + * @param array $headers + */ + private function verifySignature(array $headers, string $rawBody): string + { + $secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? ''; + if ($secret === '') { + return 'kein webhook_secret hinterlegt'; + } + $header = (string) ($headers['x-mollie-signature'] ?? ''); + if ($header === '') { + return 'Header X-Mollie-Signature fehlt'; + } + $expected = \hash_hmac('sha256', $rawBody, $secret); + foreach (\explode(',', $header) as $sig) { + $sig = \strtolower(\trim($sig)); + if (\str_starts_with($sig, 'sha256=')) { + $sig = \substr($sig, 7); + } + if ($sig !== '' && \hash_equals($expected, $sig)) { + return 'ok'; + } + } + return 'Signatur passt nicht'; + } + // -------------------------------------------------------------- reconcile public function reconcile(bool $dryRun = true): array