فهرست منبع

paypal webhook未调试

chengwl 4 روز پیش
والد
کامیت
e80b869a59

+ 9 - 0
packages/Webkul/Admin/src/Config/system.php

@@ -2127,6 +2127,15 @@ return [
                 'channel_based' => true,
                 'locale_based'  => false,
                 'default'       => 'CLIENT_SECRET',
+            ], [
+                'name'          => 'webhook_id',
+                'title'         => 'admin::app.configuration.index.sales.payment-methods.webhook-id',
+                'info'          => 'admin::app.configuration.index.sales.payment-methods.webhook-id-info',
+                'type'          => 'text',
+                'depends'       => 'active:1',
+                'validation'    => 'required_if:active,1',
+                'channel_based' => true,
+                'locale_based'  => false,
             ], [
                 'name'          => 'accepted_currencies',
                 'title'         => 'admin::app.configuration.index.sales.payment-methods.accepted-currencies',

+ 2 - 0
packages/Webkul/Admin/src/Resources/lang/en/app.php

@@ -4624,6 +4624,8 @@ return [
                     'sort-order'                     => 'Sort Order',
                     'status'                         => 'Status',
                     'title'                          => 'Title',
+                    'webhook-id'                     => 'Webhook ID',
+                    'webhook-id-info'                => 'Add the ID generated for the PayPal Smart Button webhook.',
                 ],
 
                 'order-settings' => [

+ 2 - 0
packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php

@@ -4517,6 +4517,8 @@ return [
                     'sort-order'                     => '排序',
                     'status'                         => '状态',
                     'title'                          => '标题',
+                    'webhook-id'                     => 'Webhook ID',
+                    'webhook-id-info'                => '填写 PayPal Smart Button Webhook 创建后生成的 ID。',
                 ],
 
                 'order-settings' => [

+ 2 - 0
packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php

@@ -27,6 +27,8 @@ class PaymentAttempt extends Model implements PaymentAttemptContract
 
     public const ACTION_CALLBACK = 'callback';
 
+    public const ACTION_WEBHOOK = 'webhook';
+
     /**
      * Status of the attempt.
      */

+ 146 - 18
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -577,6 +577,116 @@ class PaymentService
         }
     }
 
+    /**
+     * Settle a capture reported by a verified PayPal webhook.
+     *
+     * Unlike callback(), this path must never call captureOrder() again:
+     * PAYMENT.CAPTURE.COMPLETED means PayPal has already moved the money.
+     */
+    public function completeWebhookCapture(
+        $order,
+        string $gatewayOrderId,
+        array $capture,
+        string $eventId,
+    ): array {
+        if (! $this->isPending($order)) {
+            return $this->successResponse($order, 'already_processed');
+        }
+
+        $method = $order->payment?->method;
+        $isExpress = $this->expressFlag($order);
+
+        try {
+            $result = DB::transaction(function () use ($order, $gatewayOrderId, $capture, $method, $isExpress) {
+                $orderModelClass = OrderProxy::modelClass();
+                $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
+
+                $order->refresh();
+
+                if (! $this->isPending($order)) {
+                    return [
+                        'skipped'  => true,
+                        'capture'  => null,
+                        'response' => $this->successResponse($order, 'already_processed'),
+                    ];
+                }
+
+                if ($method !== 'paypal_smart_button' || $order->payment?->method !== 'paypal_smart_button') {
+                    throw new OperationFailedException('The order does not use PayPal Smart Button.');
+                }
+
+                $storedGatewayOrderId = $this->gatewayOrderIdFromOrder($order);
+
+                if (
+                    ! $storedGatewayOrderId
+                    || ! hash_equals((string) $storedGatewayOrderId, $gatewayOrderId)
+                    || ! hash_equals((string) ($capture['gateway_order_id'] ?? ''), $gatewayOrderId)
+                ) {
+                    throw new OperationFailedException('The PayPal order id does not match the local order.');
+                }
+
+                if (
+                    empty($capture['transaction_id'])
+                    || strtoupper((string) ($capture['capture_status'] ?? '')) !== 'COMPLETED'
+                    || ! array_key_exists('amount', $capture)
+                    || $capture['amount'] === null
+                    || empty($capture['currency'])
+                ) {
+                    throw new OperationFailedException('The PayPal capture is incomplete or not completed.');
+                }
+
+                $this->assertAmountMatches($order, $capture);
+
+                if ($isExpress && $this->isPlaceholderAddress($order->shipping_address)) {
+                    Log::warning('PayPal webhook settled an express order with a placeholder address', [
+                        'order_id'         => $order->id,
+                        'gateway_order_id' => $gatewayOrderId,
+                    ]);
+                }
+
+                return $this->finalizeCapturedOrder($order, $capture);
+            });
+        } catch (OperationFailedException $e) {
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $this->resolveOldCartId($order),
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_WEBHOOK,
+                'status'           => PaymentAttempt::STATUS_FAILED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'response_payload' => [
+                    'event_id' => $eventId,
+                    'error'    => $e->getMessage(),
+                ],
+            ]);
+
+            throw $e;
+        }
+
+        if (empty($result['skipped'])) {
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $this->resolveOldCartId($order),
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_WEBHOOK,
+                'status'           => PaymentAttempt::STATUS_CAPTURED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'idempotency_key'  => 'paypal-webhook:'.$eventId,
+                'response_payload' => $result['capture'] ?? null,
+            ]);
+
+            Event::dispatch('bagistoapi.payment.success', $result['response']['order']);
+        }
+
+        return $result['response'];
+    }
+
     /**
      * Success branch: capture the gateway order, verify the captured
      * amount, fill in express addresses, flip the order to processing
@@ -626,25 +736,8 @@ class PaymentService
                         $capture = $this->captureAndVerify($order, $gatewayOrderId);
                     }
                 }
-                if ($isExpress) {
-                    $this->fillAddressesFromCallback($order, $input);
-                }
-
-                if ($capture && ! empty($capture['transaction_id'])) {
-                    $this->writeTransactionId($order, (string) $capture['transaction_id']);
-                }
-
-                $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
-
-                $invoice = $this->createInvoiceIfPossible($order);
-
-                if ($capture) {
-                    $this->recordOrderTransaction($order, $invoice, $capture);
-                }
 
-                $order->refresh();
-
-                return ['skipped' => false, 'capture' => $capture, 'response' => $this->successResponse($order, 'captured')];
+                return $this->finalizeCapturedOrder($order, $capture, $isExpress ? $input : null);
             });
         } catch (OperationFailedException $e) {
             /*
@@ -688,6 +781,41 @@ class PaymentService
         return $result['response'];
     }
 
+    /**
+     * Apply the local side effects for an already completed capture.
+     *
+     * Callers must hold the order row lock before invoking this method.
+     */
+    protected function finalizeCapturedOrder(
+        $order,
+        ?array $capture,
+        ?PaymentCallbackInput $input = null,
+    ): array {
+        if ($input) {
+            $this->fillAddressesFromCallback($order, $input);
+        }
+
+        if ($capture && ! empty($capture['transaction_id'])) {
+            $this->writeTransactionId($order, (string) $capture['transaction_id']);
+        }
+
+        $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
+
+        $invoice = $this->createInvoiceIfPossible($order);
+
+        if ($capture) {
+            $this->recordOrderTransaction($order, $invoice, $capture);
+        }
+
+        $order->refresh();
+
+        return [
+            'skipped'  => false,
+            'capture'  => $capture,
+            'response' => $this->successResponse($order, 'captured'),
+        ];
+    }
+
     /**
      * Cancel/failure branch: keep the order in PENDING and let the
      * caller decide whether to actually cancel it via the dedicated

+ 97 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookControllerTest.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Illuminate\Http\Request;
+use Mockery;
+use Tests\TestCase;
+use Webkul\Paypal\Http\Controllers\WebhookController;
+use Webkul\Paypal\Services\WebhookService;
+use Webkul\Paypal\Services\WebhookVerifier;
+
+class PaypalWebhookControllerTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function request(array $event): Request
+    {
+        return Request::create(
+            '/paypal/smart-button/webhook',
+            'POST',
+            [],
+            [],
+            [],
+            [
+                'CONTENT_TYPE'                   => 'application/json',
+                'HTTP_PAYPAL_AUTH_ALGO'          => 'SHA256withRSA',
+                'HTTP_PAYPAL_CERT_URL'           => 'https://api.paypal.com/cert.pem',
+                'HTTP_PAYPAL_TRANSMISSION_ID'    => 'transmission-1',
+                'HTTP_PAYPAL_TRANSMISSION_SIG'   => 'signature',
+                'HTTP_PAYPAL_TRANSMISSION_TIME'  => '2026-08-10T08:00:00Z',
+            ],
+            json_encode($event, JSON_THROW_ON_ERROR),
+        );
+    }
+
+    public function test_it_acknowledges_a_verified_unknown_event(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnTrue();
+
+        $service = Mockery::mock(WebhookService::class);
+        $service->shouldNotReceive('handleCaptureCompleted');
+
+        $controller = new WebhookController($verifier, $service);
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-IGNORED',
+            'event_type' => 'CHECKOUT.ORDER.APPROVED',
+        ]));
+
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('ignored', $response->getData(true)['status']);
+    }
+
+    public function test_it_rejects_an_invalid_signature(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnFalse();
+
+        $controller = new WebhookController(
+            $verifier,
+            Mockery::mock(WebhookService::class),
+        );
+
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-BAD-SIGNATURE',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+        ]));
+
+        $this->assertSame(400, $response->getStatusCode());
+        $this->assertSame('invalid_signature', $response->getData(true)['status']);
+    }
+
+    public function test_it_returns_a_retryable_error_when_processing_fails(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnTrue();
+
+        $service = Mockery::mock(WebhookService::class);
+        $service->shouldReceive('handleCaptureCompleted')
+            ->once()
+            ->andThrow(new \RuntimeException('temporary database failure'));
+
+        $controller = new WebhookController($verifier, $service);
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-RETRY',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+            'resource'   => [],
+        ]));
+
+        $this->assertSame(500, $response->getStatusCode());
+        $this->assertSame('processing_failed', $response->getData(true)['status']);
+    }
+}

+ 167 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookServiceTest.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Models\PaymentAttempt;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Paypal\Services\WebhookService;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class PaypalWebhookServiceTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function event(array $overrides = []): array
+    {
+        return array_replace_recursive([
+            'id'         => 'WH-EVENT-1',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+            'resource'   => [
+                'id'     => 'CAPTURE-1',
+                'status' => 'COMPLETED',
+                'amount' => [
+                    'value'         => '100.00',
+                    'currency_code' => 'USD',
+                ],
+                'supplementary_data' => [
+                    'related_ids' => [
+                        'order_id' => 'PAYPAL-ORDER-1',
+                    ],
+                ],
+            ],
+        ], $overrides);
+    }
+
+    public function test_it_resolves_and_settles_a_completed_capture(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'PAYPAL-ORDER-1'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')
+            ->once()
+            ->with('PAYPAL-ORDER-1')
+            ->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->once()->with(10)->andReturn($order);
+
+        $paymentService = Mockery::mock(PaymentService::class);
+        $paymentService->shouldReceive('completeWebhookCapture')
+            ->once()
+            ->withArgs(function ($actualOrder, $gatewayOrderId, array $capture, $eventId) use ($order) {
+                return $actualOrder === $order
+                    && $gatewayOrderId === 'PAYPAL-ORDER-1'
+                    && $capture['transaction_id'] === 'CAPTURE-1'
+                    && $capture['capture_status'] === 'COMPLETED'
+                    && $capture['amount'] === 100.0
+                    && $capture['currency'] === 'USD'
+                    && $eventId === 'WH-EVENT-1';
+            })
+            ->andReturn(['gatewayStatus' => 'captured', 'order' => $order]);
+
+        $service = new WebhookService($attempts, $orders, $paymentService);
+        $result = $service->handleCaptureCompleted($this->event());
+
+        $this->assertSame('captured', $result['gatewayStatus']);
+    }
+
+    public function test_it_rejects_an_event_without_a_related_paypal_order(): void
+    {
+        $service = new WebhookService(
+            Mockery::mock(PaymentAttemptRepository::class),
+            Mockery::mock(OrderRepository::class),
+            Mockery::mock(PaymentService::class),
+        );
+
+        $event = $this->event();
+        unset($event['resource']['supplementary_data']);
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('PayPal order id is missing');
+
+        $service->handleCaptureCompleted($event);
+    }
+
+    public function test_it_rejects_a_gateway_order_mismatch(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'ANOTHER-ORDER'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->andReturn($order);
+
+        $service = new WebhookService(
+            $attempts,
+            $orders,
+            Mockery::mock(PaymentService::class),
+        );
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('does not match');
+
+        $service->handleCaptureCompleted($this->event());
+    }
+
+    public function test_it_rejects_a_non_completed_capture(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'PAYPAL-ORDER-1'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->andReturn($order);
+
+        $service = new WebhookService(
+            $attempts,
+            $orders,
+            Mockery::mock(PaymentService::class),
+        );
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('not completed');
+
+        $service->handleCaptureCompleted($this->event([
+            'resource' => ['status' => 'PENDING'],
+        ]));
+    }
+}

+ 111 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookVerifierTest.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Http;
+use Tests\TestCase;
+use Webkul\Paypal\Services\WebhookVerifier;
+
+class PaypalWebhookVerifierTest extends TestCase
+{
+    private function verifier(array $config): WebhookVerifier
+    {
+        return new class($config) extends WebhookVerifier
+        {
+            public function __construct(protected array $config) {}
+
+            protected function getConfigData(string $key): mixed
+            {
+                return $this->config[$key] ?? null;
+            }
+        };
+    }
+
+    private function headers(): array
+    {
+        return [
+            'PayPal-Auth-Algo'         => 'SHA256withRSA',
+            'PayPal-Cert-Url'          => 'https://api.paypal.com/cert.pem',
+            'PayPal-Transmission-Id'   => 'transmission-1',
+            'PayPal-Transmission-Sig'  => 'signature',
+            'PayPal-Transmission-Time' => '2026-08-10T08:00:00Z',
+        ];
+    }
+
+    public function test_it_verifies_a_sandbox_webhook_with_oauth(): void
+    {
+        Http::fake([
+            'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([
+                'access_token' => 'access-token',
+            ]),
+            'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
+                'verification_status' => 'SUCCESS',
+            ]),
+        ]);
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-123',
+            'sandbox'       => true,
+        ]);
+
+        $event = ['id' => 'WH-EVENT-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED'];
+
+        $this->assertTrue($verifier->verify($this->headers(), $event));
+
+        Http::assertSentCount(2);
+        Http::assertSent(function (Request $request) use ($event) {
+            return $request->url() === 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature'
+                && $request['webhook_id'] === 'WH-123'
+                && $request['transmission_id'] === 'transmission-1'
+                && $request['webhook_event'] === $event
+                && $request->hasHeader('Authorization', 'Bearer access-token');
+        });
+    }
+
+    public function test_it_uses_the_live_api_when_sandbox_is_disabled(): void
+    {
+        Http::fake([
+            'https://api-m.paypal.com/v1/oauth2/token'                           => Http::response(['access_token' => 'live-token']),
+            'https://api-m.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
+                'verification_status' => 'FAILURE',
+            ]),
+        ]);
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-LIVE',
+            'sandbox'       => false,
+        ]);
+
+        $this->assertFalse($verifier->verify($this->headers(), ['id' => 'WH-EVENT-2']));
+
+        Http::assertSent(fn (Request $request) => str_starts_with($request->url(), 'https://api-m.paypal.com/'));
+    }
+
+    public function test_it_rejects_missing_signature_headers_without_calling_paypal(): void
+    {
+        Http::fake();
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-123',
+            'sandbox'       => true,
+        ]);
+
+        $headers = $this->headers();
+        unset($headers['PayPal-Transmission-Sig']);
+
+        $this->expectException(\InvalidArgumentException::class);
+
+        try {
+            $verifier->verify($headers, ['id' => 'WH-EVENT-3']);
+        } finally {
+            Http::assertNothingSent();
+        }
+    }
+}

+ 1 - 0
packages/Webkul/Paypal/src/Config/paymentmethods.php

@@ -6,6 +6,7 @@ return [
         'title'            => 'PayPal Smart Button',
         'description'      => 'PayPal',
         'client_id'        => 'sb',
+        'webhook_id'       => '',
         'class'            => 'Webkul\Paypal\Payment\SmartButton',
         'sandbox'          => true,
         'active'           => true,

+ 114 - 0
packages/Webkul/Paypal/src/Http/Controllers/WebhookController.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace Webkul\Paypal\Http\Controllers;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use JsonException;
+use Webkul\Paypal\Services\WebhookService;
+use Webkul\Paypal\Services\WebhookVerifier;
+
+class WebhookController extends Controller
+{
+    public function __construct(
+        protected WebhookVerifier $webhookVerifier,
+        protected WebhookService $webhookService,
+    ) {}
+
+    public function hook(Request $request): JsonResponse
+    {
+        try {
+            $event = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
+        } catch (JsonException) {
+            return response()->json(['status' => 'invalid_json'], 400);
+        }
+
+        if (! is_array($event)) {
+            return response()->json(['status' => 'invalid_payload'], 400);
+        }
+
+        $eventId = (string) ($event['id'] ?? '');
+        $eventType = (string) ($event['event_type'] ?? '');
+        $headers = $this->paypalHeaders($request);
+
+        Log::channel('payment')->info('PayPal webhook received', [
+            'event_id'   => $eventId ?: null,
+            'event_type' => $eventType ?: null,
+        ]);
+
+        try {
+            if (! $this->webhookVerifier->verify($headers, $event)) {
+                Log::channel('payment')->warning('PayPal webhook signature rejected', [
+                    'event_id'   => $eventId ?: null,
+                    'event_type' => $eventType ?: null,
+                ]);
+
+                return response()->json(['status' => 'invalid_signature'], 400);
+            }
+        } catch (\InvalidArgumentException $e) {
+            Log::channel('payment')->warning('PayPal webhook request is incomplete', [
+                'event_id' => $eventId ?: null,
+                'error'    => $e->getMessage(),
+            ]);
+
+            return response()->json(['status' => 'invalid_request'], 400);
+        } catch (\Throwable $e) {
+            Log::channel('payment')->error('PayPal webhook verification failed', [
+                'event_id' => $eventId ?: null,
+                'error'    => $e->getMessage(),
+            ]);
+
+            return response()->json(['status' => 'verification_unavailable'], 500);
+        }
+
+        if ($eventType !== 'PAYMENT.CAPTURE.COMPLETED') {
+            Log::channel('payment')->info('PayPal webhook event ignored', [
+                'event_id'   => $eventId ?: null,
+                'event_type' => $eventType ?: null,
+            ]);
+
+            return response()->json(['status' => 'ignored']);
+        }
+
+        try {
+            $result = $this->webhookService->handleCaptureCompleted($event);
+        } catch (\InvalidArgumentException $e) {
+            Log::channel('payment')->warning('PayPal webhook payload rejected', [
+                'event_id' => $eventId ?: null,
+                'error'    => $e->getMessage(),
+            ]);
+
+            return response()->json(['status' => 'invalid_payload'], 400);
+        } catch (\Throwable $e) {
+            Log::channel('payment')->error('PayPal webhook processing failed', [
+                'event_id' => $eventId ?: null,
+                'error'    => $e->getMessage(),
+            ]);
+
+            return response()->json(['status' => 'processing_failed'], 500);
+        }
+
+        Log::channel('payment')->info('PayPal webhook processed', [
+            'event_id'       => $eventId,
+            'gateway_status' => $result['gatewayStatus'] ?? null,
+            'order_id'       => data_get($result, 'order.id'),
+        ]);
+
+        return response()->json([
+            'status'         => 'success',
+            'gateway_status' => $result['gatewayStatus'] ?? null,
+        ]);
+    }
+
+    protected function paypalHeaders(Request $request): array
+    {
+        return [
+            'paypal-auth-algo'         => $request->header('PayPal-Auth-Algo'),
+            'paypal-cert-url'          => $request->header('PayPal-Cert-Url'),
+            'paypal-transmission-id'   => $request->header('PayPal-Transmission-Id'),
+            'paypal-transmission-sig'  => $request->header('PayPal-Transmission-Sig'),
+            'paypal-transmission-time' => $request->header('PayPal-Transmission-Time'),
+        ];
+    }
+}

+ 6 - 1
packages/Webkul/Paypal/src/Http/routes.php

@@ -3,6 +3,7 @@
 use Illuminate\Support\Facades\Route;
 use Webkul\Paypal\Http\Controllers\SmartButtonController;
 use Webkul\Paypal\Http\Controllers\StandardController;
+use Webkul\Paypal\Http\Controllers\WebhookController;
 
 Route::group(['middleware' => ['web']], function () {
     Route::prefix('paypal/standard')->group(function () {
@@ -38,5 +39,9 @@ if (class_exists(\Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey::class))
 }
 
 Route::post('paypal/standard/ipn', [StandardController::class, 'ipn'])
-    ->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class)
+    ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)
     ->name('paypal.standard.ipn');
+
+Route::post('paypal/smart-button/webhook', [WebhookController::class, 'hook'])
+    ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)
+    ->name('paypal.smart-button.webhook');

+ 90 - 0
packages/Webkul/Paypal/src/Services/WebhookService.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Webkul\Paypal\Services;
+
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class WebhookService
+{
+    public function __construct(
+        protected PaymentAttemptRepository $paymentAttemptRepository,
+        protected OrderRepository $orderRepository,
+        protected PaymentService $paymentService,
+    ) {}
+
+    public function handleCaptureCompleted(array $event): array
+    {
+        $eventId = (string) ($event['id'] ?? '');
+        $resource = $event['resource'] ?? null;
+
+        if ($eventId === '' || ! is_array($resource)) {
+            throw new \InvalidArgumentException('Invalid PayPal webhook event payload.');
+        }
+
+        $gatewayOrderId = (string) data_get(
+            $resource,
+            'supplementary_data.related_ids.order_id',
+            ''
+        );
+
+        if ($gatewayOrderId === '') {
+            throw new \InvalidArgumentException('PayPal order id is missing from the webhook event.');
+        }
+
+        $attempt = $this->paymentAttemptRepository->findByGatewayOrderId($gatewayOrderId);
+
+        if (! $attempt || ! $attempt->order_id) {
+            throw new \RuntimeException('No local payment attempt matches the PayPal order.');
+        }
+
+        $order = $this->orderRepository->find($attempt->order_id);
+
+        if (! $order) {
+            throw new \RuntimeException('The local order for the PayPal payment was not found.');
+        }
+
+        if ($attempt->payment_method !== 'paypal_smart_button' || $order->payment?->method !== 'paypal_smart_button') {
+            throw new \InvalidArgumentException('The PayPal order is linked to a different payment method.');
+        }
+
+        $additional = $order->payment?->additional ?? [];
+        $storedGatewayOrderId = $additional['paypal_order_id']
+            ?? $additional['gateway_order_id']
+            ?? null;
+
+        if (! $storedGatewayOrderId || ! hash_equals((string) $storedGatewayOrderId, $gatewayOrderId)) {
+            throw new \InvalidArgumentException('The PayPal order id does not match the local order.');
+        }
+
+        $capture = $this->normalizeCapture($resource, $gatewayOrderId);
+
+        if (strtoupper((string) $capture['capture_status']) !== 'COMPLETED') {
+            throw new \InvalidArgumentException('The PayPal capture is not completed.');
+        }
+
+        return $this->paymentService->completeWebhookCapture(
+            $order,
+            $gatewayOrderId,
+            $capture,
+            $eventId,
+        );
+    }
+
+    public function normalizeCapture(array $resource, string $gatewayOrderId): array
+    {
+        $amount = data_get($resource, 'amount.value');
+
+        return [
+            'transaction_id'   => $resource['id'] ?? null,
+            'gateway_order_id' => $gatewayOrderId,
+            'order_status'     => null,
+            'capture_status'   => $resource['status'] ?? null,
+            'intent'           => 'CAPTURE',
+            'amount'           => $amount !== null ? (float) $amount : null,
+            'currency'         => data_get($resource, 'amount.currency_code'),
+            'raw'              => $resource,
+        ];
+    }
+}

+ 81 - 0
packages/Webkul/Paypal/src/Services/WebhookVerifier.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Webkul\Paypal\Services;
+
+use Illuminate\Support\Facades\Http;
+use RuntimeException;
+
+class WebhookVerifier
+{
+    protected const REQUIRED_HEADERS = [
+        'paypal-auth-algo',
+        'paypal-cert-url',
+        'paypal-transmission-id',
+        'paypal-transmission-sig',
+        'paypal-transmission-time',
+    ];
+
+    public function verify(array $headers, array $event): bool
+    {
+        $headers = array_change_key_case($headers, CASE_LOWER);
+
+        foreach (self::REQUIRED_HEADERS as $header) {
+            if (empty($headers[$header])) {
+                throw new \InvalidArgumentException("Missing PayPal webhook header: {$header}");
+            }
+        }
+
+        $clientId = (string) $this->getConfigData('client_id');
+        $clientSecret = (string) $this->getConfigData('client_secret');
+        $webhookId = (string) $this->getConfigData('webhook_id');
+
+        if ($clientId === '' || $clientSecret === '' || $webhookId === '') {
+            throw new RuntimeException('PayPal webhook credentials are not configured.');
+        }
+
+        $baseUrl = $this->isSandbox()
+            ? 'https://api-m.sandbox.paypal.com'
+            : 'https://api-m.paypal.com';
+
+        $tokenResponse = Http::asForm()
+            ->acceptJson()
+            ->withBasicAuth($clientId, $clientSecret)
+            ->timeout(15)
+            ->post($baseUrl.'/v1/oauth2/token', [
+                'grant_type' => 'client_credentials',
+            ])
+            ->throw();
+
+        $accessToken = (string) $tokenResponse->json('access_token');
+
+        if ($accessToken === '') {
+            throw new RuntimeException('PayPal did not return an OAuth access token.');
+        }
+
+        $verificationResponse = Http::acceptJson()
+            ->withToken($accessToken)
+            ->timeout(15)
+            ->post($baseUrl.'/v1/notifications/verify-webhook-signature', [
+                'auth_algo'         => $headers['paypal-auth-algo'],
+                'cert_url'          => $headers['paypal-cert-url'],
+                'transmission_id'   => $headers['paypal-transmission-id'],
+                'transmission_sig'  => $headers['paypal-transmission-sig'],
+                'transmission_time' => $headers['paypal-transmission-time'],
+                'webhook_id'        => $webhookId,
+                'webhook_event'     => $event,
+            ])
+            ->throw();
+
+        return strtoupper((string) $verificationResponse->json('verification_status')) === 'SUCCESS';
+    }
+
+    protected function getConfigData(string $key): mixed
+    {
+        return core()->getConfigData('sales.payment_methods.paypal_smart_button.'.$key);
+    }
+
+    protected function isSandbox(): bool
+    {
+        return (bool) $this->getConfigData('sandbox');
+    }
+}

+ 3 - 0
packages/Webkul/Product/src/Models/ProductReviewAttachment.php

@@ -53,6 +53,9 @@ class ProductReviewAttachment extends Model implements ProductReviewAttachmentCo
      */
     public function url(): string
     {
+        if (str_starts_with($this->path, 'reviewimages')) {
+            return config('media-library.cdn_url') . 'media/' . $this->path;
+        }
         return Storage::url($this->path);
     }