From 8267b2bca3f745ecf1087cb9fc22bd4599e20b81 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 23 Dec 2025 08:53:00 +0100 Subject: [PATCH] paddle-logging verbessert --- app/Exceptions/Handler.php | 21 ++ .../Controllers/PaddleWebhookController.php | 190 +++++++++++++++--- .../Checkout/CheckoutAssignmentService.php | 2 +- .../Paddle/PaddleTransactionService.php | 6 +- tests/Feature/PaddleWebhookControllerTest.php | 22 +- tests/Unit/PaddleTransactionServiceTest.php | 66 ++++++ 6 files changed, 263 insertions(+), 44 deletions(-) create mode 100644 tests/Unit/PaddleTransactionServiceTest.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index e46fe5d..c3e06f6 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -10,7 +10,9 @@ use Illuminate\Http\Client\ConnectionException as HttpClientConnectionException; use Illuminate\Queue\InvalidQueueException; use Illuminate\Queue\MaxAttemptsExceededException; use Illuminate\Routing\Exceptions\InvalidSignatureException; +use Illuminate\Session\TokenMismatchException; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use League\Flysystem\FilesystemException; use PDOException; @@ -53,6 +55,10 @@ class Handler extends ExceptionHandler public function render($request, Throwable $e) { + if ($e instanceof TokenMismatchException) { + $this->logCsrfMismatch($request); + } + if ($request->expectsJson()) { if ($e instanceof ValidationException) { return ApiError::response( @@ -244,4 +250,19 @@ class Handler extends ExceptionHandler return $throwable instanceof $className; } + + private function logCsrfMismatch($request): void + { + if (! app()->environment('development')) { + return; + } + + Log::warning('[CSRF] Token mismatch', [ + 'method' => $request->method(), + 'path' => $request->path(), + 'full_url' => $request->fullUrl(), + 'ip' => $request->ip(), + 'user_id' => optional($request->user())->getAuthIdentifier(), + ]); + } } diff --git a/app/Http/Controllers/PaddleWebhookController.php b/app/Http/Controllers/PaddleWebhookController.php index 4c6390b..58c13cf 100644 --- a/app/Http/Controllers/PaddleWebhookController.php +++ b/app/Http/Controllers/PaddleWebhookController.php @@ -18,36 +18,58 @@ class PaddleWebhookController extends Controller public function handle(Request $request): JsonResponse { - if (! $this->verify($request)) { - Log::warning('Paddle webhook signature verification failed'); + try { + if (! $this->verify($request)) { + Log::warning('Paddle webhook signature verification failed'); - return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); + return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); + } + + $payload = $request->json()->all(); + + if (! is_array($payload)) { + return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); + } + + $eventType = $payload['event_type'] ?? null; + $handled = false; + + $this->logDev('Paddle webhook received', [ + 'event_type' => $eventType, + 'checkout_id' => data_get($payload, 'data.checkout_id'), + 'transaction_id' => data_get($payload, 'data.id'), + 'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '', + 'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '', + ]); + + if ($eventType) { + $handled = $this->webhooks->handlePaddleEvent($payload); + $handled = $this->addonWebhooks->handle($payload) || $handled; + } + + Log::info('Paddle webhook processed', [ + 'event_type' => $eventType, + 'handled' => $handled, + ]); + + $statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED; + + return response()->json([ + 'status' => $handled ? 'processed' : 'ignored', + ], $statusCode); + } catch (\Throwable $exception) { + $eventId = $this->captureWebhookException($exception); + + Log::error('Paddle webhook processing failed', [ + 'message' => $exception->getMessage(), + 'event_type' => (string) $request->json('event_type'), + 'sentry_event_id' => $eventId, + ]); + + $this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all())); + + return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR); } - - $payload = $request->json()->all(); - - if (! is_array($payload)) { - return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); - } - - $eventType = $payload['event_type'] ?? null; - $handled = false; - - if ($eventType) { - $handled = $this->webhooks->handlePaddleEvent($payload); - $handled = $this->addonWebhooks->handle($payload) || $handled; - } - - Log::info('Paddle webhook processed', [ - 'event_type' => $eventType, - 'handled' => $handled, - ]); - - $statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED; - - return response()->json([ - 'status' => $handled ? 'processed' : 'ignored', - ], $statusCode); } protected function verify(Request $request): bool @@ -59,15 +81,119 @@ class PaddleWebhookController extends Controller return true; } - $signature = (string) $request->headers->get('Paddle-Webhook-Signature', ''); + $billingSignature = (string) $request->headers->get('Paddle-Signature', ''); - if ($signature === '') { - return false; + if ($billingSignature !== '') { + $parts = $this->parseSignatureHeader($billingSignature); + $timestamp = $parts['ts'] ?? null; + $hash = $parts['h1'] ?? null; + + if (! $timestamp || ! $hash) { + $this->logDev('Paddle webhook signature missing parts', [ + 'has_timestamp' => (bool) $timestamp, + 'has_hash' => (bool) $hash, + ]); + + return false; + } + + $payload = $request->getContent(); + $expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret); + + $valid = hash_equals($expected, $hash); + if (! $valid) { + $this->logDev('Paddle webhook signature mismatch (billing)', [ + 'timestamp' => $timestamp, + ]); + } + + return $valid; } $payload = $request->getContent(); + $signature = (string) $request->headers->get('Paddle-Webhook-Signature', ''); + + if ($signature === '') { + $this->logDev('Paddle webhook missing signature header', [ + 'header' => 'Paddle-Webhook-Signature', + ]); + + return false; + } + $expected = hash_hmac('sha256', $payload, $secret); - return hash_equals($expected, $signature); + $valid = hash_equals($expected, $signature); + if (! $valid) { + $this->logDev('Paddle webhook signature mismatch (legacy)', []); + } + + return $valid; + } + + /** + * @return array + */ + protected function parseSignatureHeader(string $header): array + { + $parts = []; + + foreach (explode(',', $header) as $chunk) { + $chunk = trim($chunk); + if ($chunk === '' || ! str_contains($chunk, '=')) { + continue; + } + + [$key, $value] = array_map('trim', explode('=', $chunk, 2)); + if ($key !== '' && $value !== '') { + $parts[$key] = $value; + } + } + + return $parts; + } + + /** + * @param array $context + */ + protected function logDev(string $message, array $context = []): void + { + if (! app()->environment('development')) { + return; + } + + Log::info('[PaddleWebhook] '.$message, $context); + } + + /** + * @return array + */ + protected function reducePayload(array $payload): array + { + return array_filter([ + 'event_type' => $payload['event_type'] ?? null, + 'transaction_id' => data_get($payload, 'data.id'), + 'checkout_id' => data_get($payload, 'data.checkout_id'), + 'status' => data_get($payload, 'data.status'), + 'customer_id' => data_get($payload, 'data.customer_id'), + 'has_custom_data' => is_array(data_get($payload, 'data.custom_data')), + ], static fn ($value) => $value !== null); + } + + protected function captureWebhookException(\Throwable $exception): ?string + { + report($exception); + + if (! app()->bound('sentry') || empty(config('sentry.dsn'))) { + return null; + } + + try { + $eventId = app('sentry')->captureException($exception); + } catch (\Throwable) { + return null; + } + + return $eventId ? (string) $eventId : null; } } diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index e98f0de..3f3ad43 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -110,7 +110,7 @@ class CheckoutAssignmentService if ($package->type !== 'reseller') { $tenant->forceFill([ 'subscription_status' => 'active', - 'subscription_expires_at' => null, + 'subscription_expires_at' => $tenantPackage->expires_at, ])->save(); } diff --git a/app/Services/Paddle/PaddleTransactionService.php b/app/Services/Paddle/PaddleTransactionService.php index bf46b67..3bbac57 100644 --- a/app/Services/Paddle/PaddleTransactionService.php +++ b/app/Services/Paddle/PaddleTransactionService.php @@ -15,7 +15,7 @@ class PaddleTransactionService { $payload = array_filter(array_merge([ 'customer_id' => $customerId, - 'order_by' => '-created_at', + 'order_by' => 'created_at[desc]', ], $query), static fn ($value) => $value !== null && $value !== ''); $response = $this->client->get('/transactions', $payload); @@ -51,7 +51,7 @@ class PaddleTransactionService { $response = $this->client->get('/transactions', [ 'checkout_id' => $checkoutId, - 'order_by' => '-created_at', + 'order_by' => 'created_at[desc]', ]); $transactions = Arr::get($response, 'data', []); @@ -72,7 +72,7 @@ class PaddleTransactionService public function findByCustomData(array $criteria, int $limit = 20): ?array { $payload = array_filter([ - 'order_by' => '-created_at', + 'order_by' => 'created_at[desc]', 'per_page' => max(1, min($limit, 50)), ], static fn ($value) => $value !== null && $value !== ''); diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php index 68f4e8a..c81e246 100644 --- a/tests/Feature/PaddleWebhookControllerTest.php +++ b/tests/Feature/PaddleWebhookControllerTest.php @@ -47,9 +47,11 @@ class PaddleWebhookControllerTest extends TestCase ], ]; - $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); + $timestamp = time(); + $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); + $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - $response = $this->withHeader('Paddle-Webhook-Signature', $signature) + $response = $this->withHeader('Paddle-Signature', $header) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); @@ -108,14 +110,16 @@ class PaddleWebhookControllerTest extends TestCase ], ]; - $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); + $timestamp = time(); + $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); + $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - $first = $this->withHeader('Paddle-Webhook-Signature', $signature) + $first = $this->withHeader('Paddle-Signature', $header) ->postJson('/paddle/webhook', $payload); $first->assertOk()->assertJson(['status' => 'processed']); - $second = $this->withHeader('Paddle-Webhook-Signature', $signature) + $second = $this->withHeader('Paddle-Signature', $header) ->postJson('/paddle/webhook', $payload); $second->assertStatus(200)->assertJson(['status' => 'processed']); @@ -168,9 +172,11 @@ class PaddleWebhookControllerTest extends TestCase ], ]; - $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); + $timestamp = time(); + $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); + $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - $response = $this->withHeader('Paddle-Webhook-Signature', $signature) + $response = $this->withHeader('Paddle-Signature', $header) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); @@ -185,7 +191,7 @@ class PaddleWebhookControllerTest extends TestCase { config(['paddle.webhook_secret' => 'secret']); - $response = $this->withHeader('Paddle-Webhook-Signature', 'invalid') + $response = $this->withHeader('Paddle-Signature', 'invalid') ->postJson('/paddle/webhook', ['event_type' => 'transaction.completed']); $response->assertStatus(400)->assertJson(['status' => 'invalid']); diff --git a/tests/Unit/PaddleTransactionServiceTest.php b/tests/Unit/PaddleTransactionServiceTest.php new file mode 100644 index 0000000..7361f16 --- /dev/null +++ b/tests/Unit/PaddleTransactionServiceTest.php @@ -0,0 +1,66 @@ +shouldReceive('get') + ->once() + ->with('/transactions', Mockery::on(function (array $payload) { + return $payload['customer_id'] === 'ctm_123' + && $payload['order_by'] === 'created_at[desc]'; + })) + ->andReturn(['data' => [], 'meta' => ['pagination' => []]]); + + $this->app->instance(PaddleClient::class, $client); + + $service = $this->app->make(PaddleTransactionService::class); + $service->listForCustomer('ctm_123'); + + $this->assertTrue(true); + } + + public function test_find_by_checkout_id_uses_expected_order_by_format(): void + { + $client = Mockery::mock(PaddleClient::class); + $client->shouldReceive('get') + ->once() + ->with('/transactions', Mockery::on(function (array $payload) { + return $payload['checkout_id'] === 'chk_123' + && $payload['order_by'] === 'created_at[desc]'; + })) + ->andReturn(['data' => []]); + + $this->app->instance(PaddleClient::class, $client); + + $service = $this->app->make(PaddleTransactionService::class); + $this->assertNull($service->findByCheckoutId('chk_123')); + } + + public function test_find_by_custom_data_uses_expected_order_by_format(): void + { + $client = Mockery::mock(PaddleClient::class); + $client->shouldReceive('get') + ->once() + ->with('/transactions', Mockery::on(function (array $payload) { + return $payload['order_by'] === 'created_at[desc]' + && $payload['per_page'] === 20; + })) + ->andReturn(['data' => []]); + + $this->app->instance(PaddleClient::class, $client); + + $service = $this->app->make(PaddleTransactionService::class); + $this->assertNull($service->findByCustomData([ + 'checkout_session_id' => 'sess_123', + ])); + } +}