diff --git a/app/Http/Controllers/PayPalCheckoutController.php b/app/Http/Controllers/PayPalCheckoutController.php index ea8ccb05..13e6f729 100644 --- a/app/Http/Controllers/PayPalCheckoutController.php +++ b/app/Http/Controllers/PayPalCheckoutController.php @@ -8,6 +8,7 @@ use App\Models\CheckoutSession; use App\Models\Package; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutSessionService; +use App\Services\Coupons\CouponRedemptionService; use App\Services\Coupons\CouponService; use App\Services\PayPal\Exceptions\PayPalException; use App\Services\PayPal\PayPalOrderService; @@ -24,6 +25,7 @@ class PayPalCheckoutController extends Controller private readonly CheckoutSessionService $sessions, private readonly CheckoutAssignmentService $assignment, private readonly CouponService $coupons, + private readonly CouponRedemptionService $couponRedemptions, ) {} public function create(PayPalCheckoutRequest $request): JsonResponse @@ -61,7 +63,7 @@ class PayPalCheckoutController extends Controller $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); if ($couponCode !== '') { - $preview = $this->coupons->preview($couponCode, $package, $tenant); + $preview = $this->coupons->preview($couponCode, $package, $tenant, CheckoutSession::PROVIDER_PAYPAL); $this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); } @@ -154,6 +156,7 @@ class PayPalCheckoutController extends Controller ]); $this->sessions->markFailed($session, 'paypal_capture_failed'); + $this->couponRedemptions->recordFailure($session, 'paypal_capture_failed'); return response()->json([ 'status' => CheckoutSession::STATUS_FAILED, @@ -191,6 +194,7 @@ class PayPalCheckoutController extends Controller ]); $this->sessions->markCompleted($session, now()); + $this->couponRedemptions->recordSuccess($session, $capture); return response()->json([ 'status' => CheckoutSession::STATUS_COMPLETED, @@ -209,6 +213,7 @@ class PayPalCheckoutController extends Controller } $this->sessions->markFailed($session, 'paypal_'.$status); + $this->couponRedemptions->recordFailure($session, 'paypal_'.$status); return response()->json([ 'status' => CheckoutSession::STATUS_FAILED, diff --git a/app/Http/Controllers/PayPalReturnController.php b/app/Http/Controllers/PayPalReturnController.php index 07aa4612..8f22b732 100644 --- a/app/Http/Controllers/PayPalReturnController.php +++ b/app/Http/Controllers/PayPalReturnController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\CheckoutSession; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutSessionService; +use App\Services\Coupons\CouponRedemptionService; use App\Services\PayPal\Exceptions\PayPalException; use App\Services\PayPal\PayPalOrderService; use Illuminate\Http\RedirectResponse; @@ -18,6 +19,7 @@ class PayPalReturnController extends Controller private readonly PayPalOrderService $orders, private readonly CheckoutSessionService $sessions, private readonly CheckoutAssignmentService $assignment, + private readonly CouponRedemptionService $couponRedemptions, ) {} public function __invoke(Request $request): RedirectResponse @@ -57,6 +59,7 @@ class PayPalReturnController extends Controller ]); $this->sessions->markFailed($session, 'paypal_capture_failed'); + $this->couponRedemptions->recordFailure($session, 'paypal_capture_failed'); return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback)); } @@ -89,11 +92,13 @@ class PayPalReturnController extends Controller ]); $this->sessions->markCompleted($session, now()); + $this->couponRedemptions->recordSuccess($session, $capture); return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback)); } $this->sessions->markFailed($session, 'paypal_'.$status); + $this->couponRedemptions->recordFailure($session, 'paypal_'.$status); return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback)); } diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php new file mode 100644 index 00000000..fbb1cbce --- /dev/null +++ b/app/Http/Controllers/PayPalWebhookController.php @@ -0,0 +1,110 @@ +decodePayload($request); + + if (! is_array($payload)) { + return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); + } + + if (! $this->verifier->verify($request, $payload)) { + Log::warning('PayPal webhook signature verification failed'); + + return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); + } + + $eventType = $payload['event_type'] ?? null; + $eventId = $payload['id'] ?? null; + + $webhookEvent = $this->recorder->recordReceived( + 'paypal', + is_string($eventId) ? $eventId : null, + is_string($eventType) ? $eventType : null, + ); + + $handled = is_string($eventType) ? $this->webhooks->handle($payload) : false; + + Log::info('PayPal webhook processed', [ + 'event_type' => $eventType, + 'handled' => $handled, + ]); + + if ($handled) { + $this->recorder->markProcessed($webhookEvent, ['handled' => true]); + } else { + $this->recorder->markIgnored($webhookEvent, ['handled' => false]); + } + + return response()->json([ + 'status' => $handled ? 'processed' : 'ignored', + ], $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED); + } catch (\Throwable $exception) { + $eventId = $this->captureWebhookException($exception); + + Log::error('PayPal webhook processing failed', [ + 'message' => $exception->getMessage(), + 'event_type' => (string) data_get($request->json()->all(), 'event_type'), + 'sentry_event_id' => $eventId, + ]); + + if (isset($webhookEvent)) { + $this->recorder->markFailed($webhookEvent, $exception->getMessage()); + } + + return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * @return array|null + */ + protected function decodePayload(Request $request): ?array + { + $payload = $request->getContent(); + + if (! is_string($payload) || $payload === '') { + return null; + } + + $decoded = json_decode($payload, true); + + return is_array($decoded) ? $decoded : 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/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 8e5f1ff9..51ada25f 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -15,5 +15,6 @@ class VerifyCsrfToken extends Middleware 'api/v1/photos/*/like', 'api/v1/events/*/upload', 'lemonsqueezy/webhook*', + 'paypal/webhook*', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d8969d4e..b08ff1dc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -191,6 +191,10 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(30)->by('lemonsqueezy:'.$request->ip()); }); + RateLimiter::for('paypal-webhook', function (Request $request) { + return Limit::perMinute(30)->by('paypal:'.$request->ip()); + }); + RateLimiter::for('gift-lookup', function (Request $request) { $code = strtoupper((string) $request->query('code')); $ip = $request->ip() ?? 'unknown'; diff --git a/app/Services/Coupons/CouponRedemptionService.php b/app/Services/Coupons/CouponRedemptionService.php index 418fe0c4..ce0662cb 100644 --- a/app/Services/Coupons/CouponRedemptionService.php +++ b/app/Services/Coupons/CouponRedemptionService.php @@ -31,7 +31,7 @@ class CouponRedemptionService return; } - $transactionId = Arr::get($payload, 'id') ?? $session->lemonsqueezy_order_id; + $transactionId = $this->resolveTransactionId($session, $payload); $context = $this->resolveRequestContext($session); $fraudSnapshot = $this->buildFraudSnapshot($context); @@ -48,6 +48,7 @@ class CouponRedemptionService 'metadata' => array_filter([ 'session_snapshot' => $session->coupon_snapshot, 'payload' => $payload, + 'provider' => $session->provider, 'fraud' => $fraudSnapshot, ]), 'redeemed_at' => now(), @@ -74,6 +75,7 @@ class CouponRedemptionService $context = $this->resolveRequestContext($session); $fraudSnapshot = $this->buildFraudSnapshot($context); + $transactionId = $this->resolveTransactionId($session); CouponRedemption::query()->updateOrCreate( [ @@ -84,13 +86,14 @@ class CouponRedemptionService 'tenant_id' => $session->tenant_id, 'user_id' => $session->user_id, 'package_id' => $session->package_id, - 'lemonsqueezy_order_id' => $session->lemonsqueezy_order_id, + 'lemonsqueezy_order_id' => $transactionId, 'status' => CouponRedemption::STATUS_FAILED, 'failure_reason' => $reason, 'amount_discounted' => $session->amount_discount, 'currency' => $session->currency ?? 'EUR', 'metadata' => array_filter([ 'session_snapshot' => $session->coupon_snapshot, + 'provider' => $session->provider, 'fraud' => $fraudSnapshot, ]), ]), @@ -109,6 +112,24 @@ class CouponRedemptionService ], static fn ($value) => $value !== null && $value !== ''); } + /** + * @param array $payload + */ + private function resolveTransactionId(CheckoutSession $session, array $payload = []): ?string + { + if ($session->provider === CheckoutSession::PROVIDER_PAYPAL) { + $paypalId = $session->paypal_capture_id + ?? $session->paypal_order_id + ?? Arr::get($payload, 'id'); + + return is_string($paypalId) && $paypalId !== '' ? $paypalId : null; + } + + $lemonsqueezyId = Arr::get($payload, 'id') ?? $session->lemonsqueezy_order_id; + + return is_string($lemonsqueezyId) && $lemonsqueezyId !== '' ? $lemonsqueezyId : null; + } + /** * @param array{ip_address?: string|null, device_id?: string|null, user_agent?: string|null} $context * @return array|null diff --git a/app/Services/Coupons/CouponService.php b/app/Services/Coupons/CouponService.php index 81769c65..285cfa1b 100644 --- a/app/Services/Coupons/CouponService.php +++ b/app/Services/Coupons/CouponService.php @@ -4,6 +4,7 @@ namespace App\Services\Coupons; use App\Enums\CouponStatus; use App\Enums\CouponType; +use App\Models\CheckoutSession; use App\Models\Coupon; use App\Models\CouponRedemption; use App\Models\Package; @@ -18,11 +19,11 @@ class CouponService /** * @return array{coupon: Coupon, pricing: array, source: string} */ - public function preview(string $code, Package $package, ?Tenant $tenant = null): array + public function preview(string $code, Package $package, ?Tenant $tenant = null, ?string $provider = null): array { $coupon = $this->findCouponForCode($code); - $this->ensureCouponCanBeApplied($coupon, $package, $tenant); + $this->ensureCouponCanBeApplied($coupon, $package, $tenant, $this->resolveProvider($provider)); $pricing = $this->buildPricingBreakdown($coupon, $package, $tenant); @@ -33,9 +34,9 @@ class CouponService ]; } - public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void + public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null, ?string $provider = null): void { - if (! $coupon->lemonsqueezy_discount_id) { + if ($provider !== CheckoutSession::PROVIDER_PAYPAL && ! $coupon->lemonsqueezy_discount_id) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.not_synced'), ]); @@ -75,6 +76,17 @@ class CouponService } } + protected function resolveProvider(?string $provider): ?string + { + if ($provider && $provider !== '') { + return $provider; + } + + $default = config('checkout.default_provider'); + + return is_string($default) && $default !== '' ? $default : null; + } + protected function findCouponForCode(string $code): Coupon { $normalized = Str::upper(trim($code)); diff --git a/app/Services/PayPal/PayPalWebhookService.php b/app/Services/PayPal/PayPalWebhookService.php new file mode 100644 index 00000000..357055c8 --- /dev/null +++ b/app/Services/PayPal/PayPalWebhookService.php @@ -0,0 +1,248 @@ + $event + */ + public function handle(array $event): bool + { + $eventType = $event['event_type'] ?? null; + $resource = $event['resource'] ?? null; + + if (! is_string($eventType) || ! is_array($resource)) { + return false; + } + + $orderId = $this->resolveOrderId($eventType, $resource); + $session = $this->locateSession($orderId, $resource); + + if (! $session) { + Log::info('[PayPalWebhook] session not resolved', [ + 'event_type' => $eventType, + 'order_id' => $orderId, + ]); + + return false; + } + + $lockKey = 'checkout:webhook:paypal:'.($orderId ?: $session->id); + $lock = Cache::lock($lockKey, 30); + + if (! $lock->get()) { + Log::info('[PayPalWebhook] lock busy', [ + 'order_id' => $orderId, + 'session_id' => $session->id, + ]); + + return true; + } + + try { + if ($orderId && $session->paypal_order_id !== $orderId) { + $session->forceFill([ + 'paypal_order_id' => $orderId, + 'provider' => CheckoutSession::PROVIDER_PAYPAL, + ])->save(); + } elseif ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) { + $session->forceFill(['provider' => CheckoutSession::PROVIDER_PAYPAL])->save(); + } + + $this->mergeProviderMetadata($session, [ + 'paypal_last_event' => $eventType, + 'paypal_status' => $this->resolveStatus($resource), + 'paypal_last_update_at' => now()->toIso8601String(), + ]); + + return $this->applyEvent($session, $eventType, $resource, $event); + } finally { + $lock->release(); + } + } + + /** + * @param array $resource + * @param array $event + */ + protected function applyEvent(CheckoutSession $session, string $eventType, array $resource, array $event): bool + { + $normalized = strtoupper($eventType); + + if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) { + $captureId = $this->resolveCaptureId($resource, $normalized); + $totals = $this->resolveTotals($resource); + $status = strtoupper((string) ($resource['status'] ?? 'COMPLETED')); + + $session->forceFill([ + 'paypal_capture_id' => $captureId ?: $session->paypal_capture_id, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paypal_capture_id' => $captureId, + 'paypal_status' => $status, + 'paypal_totals' => $totals !== [] ? $totals : null, + 'paypal_captured_at' => now()->toIso8601String(), + ])), + ])->save(); + + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markProcessing($session, [ + 'paypal_status' => $status, + 'paypal_capture_id' => $captureId, + ]); + + $this->assignment->finalise($session, [ + 'source' => 'paypal_webhook', + 'provider' => CheckoutSession::PROVIDER_PAYPAL, + 'provider_reference' => $captureId ?: $session->paypal_order_id, + 'payload' => $resource, + ]); + + $this->sessions->markCompleted($session, now()); + $this->couponRedemptions->recordSuccess($session, $resource); + } + + return true; + } + + if ($normalized === 'CHECKOUT.ORDER.APPROVED') { + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markRequiresCustomerAction($session, 'paypal_approved'); + } + + return true; + } + + if ($normalized === 'PAYMENT.CAPTURE.PENDING') { + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markRequiresCustomerAction($session, 'paypal_pending'); + } + + return true; + } + + if (in_array($normalized, ['PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.REFUNDED', 'CHECKOUT.ORDER.VOIDED'], true)) { + $reason = match ($normalized) { + 'PAYMENT.CAPTURE.DENIED' => 'paypal_capture_denied', + 'PAYMENT.CAPTURE.REFUNDED' => 'paypal_refunded', + 'CHECKOUT.ORDER.VOIDED' => 'paypal_voided', + default => 'paypal_failed', + }; + + $this->sessions->markFailed($session, $reason); + $this->couponRedemptions->recordFailure($session, $reason); + + return true; + } + + return false; + } + + /** + * @param array $resource + */ + protected function resolveOrderId(string $eventType, array $resource): ?string + { + if (str_starts_with(strtoupper($eventType), 'PAYMENT.CAPTURE.')) { + $relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id'); + + return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null; + } + + $orderId = $resource['id'] ?? null; + + return is_string($orderId) && $orderId !== '' ? $orderId : null; + } + + /** + * @param array $resource + */ + protected function locateSession(?string $orderId, array $resource): ?CheckoutSession + { + if ($orderId) { + return CheckoutSession::query() + ->where('paypal_order_id', $orderId) + ->first(); + } + + $customId = Arr::get($resource, 'purchase_units.0.custom_id'); + + if (is_string($customId) && $customId !== '') { + return CheckoutSession::query()->find($customId); + } + + return null; + } + + /** + * @param array $resource + */ + protected function resolveCaptureId(array $resource, string $eventType): ?string + { + if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { + $captureId = $resource['id'] ?? null; + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + $captureId = Arr::get($resource, 'purchase_units.0.payments.captures.0.id'); + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + /** + * @param array $resource + * @return array{currency?: string, total?: float} + */ + protected function resolveTotals(array $resource): array + { + $amount = Arr::get($resource, 'amount') + ?? Arr::get($resource, 'purchase_units.0.payments.captures.0.amount') + ?? Arr::get($resource, 'purchase_units.0.amount'); + + if (! is_array($amount)) { + return []; + } + + $currency = Arr::get($amount, 'currency_code'); + $total = Arr::get($amount, 'value'); + + return array_filter([ + 'currency' => is_string($currency) ? strtoupper($currency) : null, + 'total' => is_numeric($total) ? (float) $total : null, + ], static fn ($value) => $value !== null); + } + + /** + * @param array $resource + */ + protected function resolveStatus(array $resource): ?string + { + $status = $resource['status'] ?? null; + + return is_string($status) && $status !== '' ? strtoupper($status) : null; + } + + /** + * @param array $data + */ + protected function mergeProviderMetadata(CheckoutSession $session, array $data): void + { + $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); + $session->save(); + } +} diff --git a/app/Services/PayPal/PayPalWebhookVerifier.php b/app/Services/PayPal/PayPalWebhookVerifier.php new file mode 100644 index 00000000..2d9c4ca7 --- /dev/null +++ b/app/Services/PayPal/PayPalWebhookVerifier.php @@ -0,0 +1,62 @@ + $payload + */ + public function verify(Request $request, array $payload): bool + { + $webhookId = config('services.paypal.webhook_id'); + + if (! is_string($webhookId) || $webhookId === '') { + if (app()->environment('production')) { + Log::warning('PayPal webhook verification skipped: webhook id missing.'); + } + + return true; + } + + $signature = (string) $request->headers->get('PAYPAL-TRANSMISSION-SIG', ''); + + if ($signature === '') { + Log::warning('PayPal webhook missing signature header.', [ + 'header' => 'PAYPAL-TRANSMISSION-SIG', + ]); + + return false; + } + + $payload = array_filter([ + 'auth_algo' => $request->headers->get('PAYPAL-AUTH-ALGO'), + 'cert_url' => $request->headers->get('PAYPAL-CERT-URL'), + 'transmission_id' => $request->headers->get('PAYPAL-TRANSMISSION-ID'), + 'transmission_sig' => $signature, + 'transmission_time' => $request->headers->get('PAYPAL-TRANSMISSION-TIME'), + 'webhook_id' => $webhookId, + 'webhook_event' => $payload, + ], static fn ($value) => $value !== null && $value !== ''); + + try { + $response = $this->client->post('/v1/notifications/verify-webhook-signature', $payload); + } catch (PayPalException $exception) { + Log::warning('PayPal webhook verification failed', [ + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + 'context' => $exception->context(), + ]); + + return false; + } + + return strtoupper((string) ($response['verification_status'] ?? '')) === 'SUCCESS'; + } +} diff --git a/config/services.php b/config/services.php index 264680bd..f4bbba44 100644 --- a/config/services.php +++ b/config/services.php @@ -43,6 +43,7 @@ return [ 'client_id' => env('PAYPAL_CLIENT_ID'), 'secret' => env('PAYPAL_SECRET'), 'sandbox' => env('PAYPAL_SANDBOX', true), + 'webhook_id' => env('PAYPAL_WEBHOOK_ID'), ], 'lemonsqueezy' => [ diff --git a/routes/web.php b/routes/web.php index 70439a55..1305320a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Marketing\GiftVoucherPrintController; use App\Http\Controllers\MarketingController; use App\Http\Controllers\PayPalCheckoutController; use App\Http\Controllers\PayPalReturnController; +use App\Http\Controllers\PayPalWebhookController; use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileDataExportController; @@ -431,5 +432,10 @@ Route::post('/lemonsqueezy/webhook', [LemonSqueezyWebhookController::class, 'han ->middleware('throttle:lemonsqueezy-webhook') ->name('lemonsqueezy.webhook'); +Route::post('/paypal/webhook', [PayPalWebhookController::class, 'handle']) + ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]) + ->middleware('throttle:paypal-webhook') + ->name('paypal.webhook'); + Route::get('/paypal/return', PayPalReturnController::class) ->name('paypal.return'); diff --git a/tests/Feature/PayPalWebhookControllerTest.php b/tests/Feature/PayPalWebhookControllerTest.php new file mode 100644 index 00000000..172d4251 --- /dev/null +++ b/tests/Feature/PayPalWebhookControllerTest.php @@ -0,0 +1,194 @@ + 'client', + 'services.paypal.secret' => 'secret', + 'services.paypal.sandbox' => true, + 'services.paypal.webhook_id' => 'wh_123', + ]); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ + 'access_token' => 'token', + 'expires_in' => 3600, + ]), + 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ + 'verification_status' => 'SUCCESS', + ]), + ]); + + [$tenant, $package, $session, $coupon] = $this->prepareSession(withCoupon: true); + $session->forceFill(['paypal_order_id' => 'ORDER-123'])->save(); + + $payload = [ + 'id' => 'WH-1', + 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', + 'resource' => [ + 'id' => 'CAPTURE-1', + 'status' => 'COMPLETED', + 'amount' => [ + 'value' => '99.00', + 'currency_code' => 'EUR', + ], + 'supplementary_data' => [ + 'related_ids' => [ + 'order_id' => 'ORDER-123', + ], + ], + ], + ]; + + $response = $this->withHeaders($this->paypalHeaders()) + ->postJson('/paypal/webhook', $payload); + + $response->assertOk()->assertJson(['status' => 'processed']); + + $this->assertDatabaseHas('integration_webhook_events', [ + 'provider' => 'paypal', + 'event_id' => 'WH-1', + 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', + 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, + ]); + + $session->refresh(); + + $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); + $this->assertSame('paypal', $session->provider); + $this->assertSame('ORDER-123', $session->paypal_order_id); + $this->assertSame('CAPTURE-1', $session->paypal_capture_id); + + $this->assertTrue( + TenantPackage::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->where('active', true) + ->exists() + ); + + $purchase = PackagePurchase::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->where('provider', 'paypal') + ->first(); + + $this->assertNotNull($purchase); + $this->assertSame(99.0, (float) $purchase->price); + $this->assertSame('EUR', Arr::get($purchase->metadata, 'currency')); + + $this->assertTrue( + CouponRedemption::query() + ->where('coupon_id', $coupon->id) + ->where('checkout_session_id', $session->id) + ->where('status', CouponRedemption::STATUS_SUCCESS) + ->exists() + ); + } + + public function test_rejects_invalid_signature(): void + { + config([ + 'services.paypal.client_id' => 'client', + 'services.paypal.secret' => 'secret', + 'services.paypal.sandbox' => true, + 'services.paypal.webhook_id' => 'wh_123', + ]); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ + 'access_token' => 'token', + 'expires_in' => 3600, + ]), + 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ + 'verification_status' => 'FAILURE', + ]), + ]); + + $response = $this->withHeaders($this->paypalHeaders()) + ->postJson('/paypal/webhook', [ + 'id' => 'WH-FAIL', + 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', + 'resource' => [ + 'id' => 'CAPTURE-FAIL', + 'status' => 'COMPLETED', + ], + ]); + + $response->assertStatus(400)->assertJson(['status' => 'invalid']); + } + + /** + * @return array{ + * 0: \App\Models\Tenant, + * 1: \App\Models\Package, + * 2: \App\Models\CheckoutSession, + * 3: \App\Models\Coupon + * } + */ + protected function prepareSession(bool $withCoupon = false): array + { + $user = User::factory()->create(['email_verified_at' => now()]); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'price' => 99, + ]); + + /** @var CheckoutSessionService $sessions */ + $sessions = app(CheckoutSessionService::class); + $session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); + + $coupon = Coupon::factory()->create([ + 'lemonsqueezy_discount_id' => null, + ]); + + if ($withCoupon) { + /** @var CouponService $coupons */ + $coupons = app(CouponService::class); + $preview = $coupons->preview($coupon->code, $package, $tenant, CheckoutSession::PROVIDER_PAYPAL); + $sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); + } + + return [$tenant, $package, $session, $coupon]; + } + + /** + * @return array + */ + protected function paypalHeaders(): array + { + return [ + 'PAYPAL-AUTH-ALGO' => 'SHA256withRSA', + 'PAYPAL-CERT-URL' => 'https://example.test/cert', + 'PAYPAL-TRANSMISSION-ID' => 'transmission-1', + 'PAYPAL-TRANSMISSION-SIG' => 'signature', + 'PAYPAL-TRANSMISSION-TIME' => '2026-02-04T12:00:00Z', + ]; + } +}