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
+70 -8
View File
@@ -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=<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
{
$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<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