From 6850a00e767f90916f91dc0ca50564a481c7c73c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:52:29 +0100 Subject: [PATCH] update to provisioner code --- .../Api/PaymentWebhookController.php | 195 ++++++++++++++---- 1 file changed, 160 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index faf0ce8..0e59c89 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -14,52 +14,171 @@ use Illuminate\Support\Facades\DB; final class PaymentWebhookController extends Controller { - public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse - { + public function __invoke( + string $provider, + Request $request, + PurchaseProvisioner $provisioner, + LicenseDeliveryService $delivery + ): JsonResponse { $raw = (string) $request->getContent(); + $verifier = match ($provider) { - 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null + 'paddle' => app(PaddleWebhookVerifier::class), + 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), + default => null, }; + if (! $verifier) { - return response()->json(['message' => 'Unknown webhook provider.'], 404); + return response()->json([ + 'message' => 'Unknown webhook provider.', + ], 404); } + try { $verifier->verify($request, $raw); } catch (\Throwable $e) { report($e); - return response()->json(['message' => 'Invalid webhook signature.'], 400); - } - try { - $payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); - } catch (\Throwable) { - return response()->json(['message' => 'Invalid JSON.'], 400); - } - $externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? '')); - $eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown')); - if ($externalID === '') { - return response()->json(['message' => 'Webhook event has no stable identifier.'], 400); + return response()->json([ + 'message' => 'Invalid webhook signature.', + ], 400); } try { - DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) { - $event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]); - if ($event->processed_at) { - return; - } - if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) { - throw new \RuntimeException('Webhook ID was reused with a different payload.'); - } - try { - $provisioner->provision($provider, $payload); - $event->update(['processed_at' => now(), 'processing_error' => null]); - } catch (\Throwable $e) { - $event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]); - throw $e; - } - }); + $payload = json_decode( + $raw, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Invalid JSON.', + ], 400); + } + + $externalID = (string) ( + $provider === 'paddle' + ? ($payload['event_id'] ?? '') + : ($payload['meta']['event_name'] ?? '') + .':' + .($payload['data']['id'] ?? '') + ); + + $eventType = (string) ( + $provider === 'paddle' + ? ($payload['event_type'] ?? 'unknown') + : ($payload['meta']['event_name'] ?? 'unknown') + ); + + if ($externalID === '') { + return response()->json([ + 'message' => 'Webhook event has no stable identifier.', + ], 400); + } + + try { + /* + * Paddle can deliver several related webhook events at almost + * exactly the same time. + * + * Laravel will automatically retry the entire transaction when + * MySQL reports a deadlock / serialization failure. + * + * Five attempts gives short-lived webhook contention time to + * resolve without weakening any database constraints. + */ + DB::transaction( + function () use ( + $provider, + $externalID, + $eventType, + $payload, + $raw, + $provisioner + ): void { + /* + * firstOrCreate protects against Paddle retrying the exact + * same event ID. + * + * lockForUpdate prevents two workers handling the same + * webhook event concurrently after the row exists. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => hash('sha256', $raw), + 'payload' => $payload, + ] + ); + + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + /* + * Already successfully processed. + * + * Returning here makes duplicate delivery of the exact + * same Paddle event idempotent. + */ + if ($event->processed_at !== null) { + return; + } + + /* + * An external event ID must never be reused with different + * content. + */ + $incomingHash = hash('sha256', $raw); + + if (! hash_equals( + (string) $event->payload_sha256, + $incomingHash + )) { + throw new \RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->update([ + 'processed_at' => now(), + 'processing_error' => null, + ]); + } catch (\Throwable $e) { + /* + * This update is part of the same transaction. + * + * If processing fails and the transaction rolls back, + * Paddle can retry the webhook normally. + */ + $event->update([ + 'processing_error' => mb_substr( + $e->getMessage(), + 0, + 4000 + ), + ]); + + throw $e; + } + }, + 5 + ); } catch (\Throwable $e) { report($e); + return response()->json([ 'message' => 'Webhook could not be processed.', 'exception' => get_class($e), @@ -69,15 +188,21 @@ final class PaymentWebhookController extends Controller ], 500); } - // Delivery is intentionally outside the webhook transaction. A temporary mail - // outage must not roll back an otherwise valid Paddle event. Pending outbox - // rows remain available for portal resend or a later webhook delivery attempt. + /* + * Delivery is intentionally outside the webhook transaction. + * + * A temporary mail outage must not roll back an otherwise valid + * payment event. Pending outbox rows remain available for portal + * resend or a later webhook delivery attempt. + */ try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); } - return response()->json(['ok' => true]); + return response()->json([ + 'ok' => true, + ]); } }