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:
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user