feat(payments/mollie): Next-Gen-Webhooks (JSON, HMAC-SHA256 X-Mollie-Signature) neben klassischem id=tr_; fix: Bestellbestaetigung aus Webhook-/CLI-Kontext (Layout-Objekt im Bootstrap); markCancelled idempotent

This commit is contained in:
Thomas Bartelt
2026-09-27 21:51:11 +02:00
parent b8111a1e7f
commit 5f3bbc2c45
3 changed files with 94 additions and 8 deletions
+5
View File
@@ -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).
+19
View File
@@ -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());
}
+65 -3
View File
@@ -70,6 +70,7 @@ final class Provider extends AbstractProvider
return [
'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=<hex>"), 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
{
$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<string,string> $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