diff --git a/app/Http/Controllers/PaddleReturnController.php b/app/Http/Controllers/PaddleReturnController.php new file mode 100644 index 0000000..9089091 --- /dev/null +++ b/app/Http/Controllers/PaddleReturnController.php @@ -0,0 +1,119 @@ +resolveTransactionId($request); + $fallback = $this->resolveFallbackUrl(); + + if (! $transactionId) { + return redirect()->to($fallback); + } + + try { + $transaction = $this->transactions->retrieve($transactionId); + } catch (PaddleException $exception) { + Log::warning('Paddle return failed to load transaction', [ + 'transaction_id' => $transactionId, + 'error' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + return redirect()->to($fallback); + } + + $customData = $this->extractCustomData($transaction); + $status = Str::lower((string) ($transaction['status'] ?? '')); + $successUrl = $customData['success_url'] ?? null; + $cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null; + + $target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl; + $target = $this->resolveSafeRedirect($target, $fallback); + + return redirect()->to($target); + } + + protected function resolveTransactionId(Request $request): ?string + { + $candidate = $request->query('_ptxn') + ?? $request->query('ptxn') + ?? $request->query('transaction_id'); + + if (! is_string($candidate) || $candidate === '') { + return null; + } + + return $candidate; + } + + protected function resolveFallbackUrl(): string + { + return rtrim((string) config('app.url', url('/')), '/') ?: url('/'); + } + + /** + * @param array $transaction + * @return array + */ + protected function extractCustomData(array $transaction): array + { + $customData = Arr::get($transaction, 'custom_data', []); + + if (! is_array($customData)) { + $customData = []; + } + + $legacy = Arr::get($transaction, 'customData'); + if (is_array($legacy)) { + $customData = array_merge($customData, $legacy); + } + + $metadata = Arr::get($transaction, 'metadata'); + if (is_array($metadata)) { + $customData = array_merge($customData, $metadata); + } + + return $customData; + } + + protected function isSuccessStatus(string $status): bool + { + return in_array($status, ['completed', 'paid'], true); + } + + protected function resolveSafeRedirect(?string $target, string $fallback): string + { + if (! $target) { + return $fallback; + } + + if (Str::startsWith($target, ['/'])) { + return $target; + } + + $appHost = parse_url($fallback, PHP_URL_HOST); + $targetHost = parse_url($target, PHP_URL_HOST); + + if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) { + return $target; + } + + return $fallback; + } +} diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index ce4084d..59150c9 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -66,7 +66,7 @@ class EventAddonCheckoutService $increments = $this->catalog->resolveIncrements($addonKey); - $metadata = [ + $metadata = array_filter([ 'tenant_id' => (string) $tenant->id, 'event_id' => (string) $event->id, 'event_package_id' => (string) $event->eventPackage->id, @@ -76,7 +76,9 @@ class EventAddonCheckoutService 'legal_version' => $this->resolveLegalVersion(), 'accepted_terms' => $acceptedTerms ? '1' : '0', 'accepted_waiver' => $acceptedWaiver ? '1' : '0', - ]; + 'success_url' => $payload['success_url'] ?? null, + 'cancel_url' => $payload['cancel_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); $requestPayload = array_filter([ 'customer_id' => $customerId, diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php index 5774bf9..19280dc 100644 --- a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -68,6 +68,8 @@ class GiftVoucherCheckoutService 'recipient_name' => $data['recipient_name'] ?? null, 'message' => $data['message'] ?? null, 'app_locale' => App::getLocale(), + 'success_url' => $data['success_url'] ?? null, + 'cancel_url' => $data['return_url'] ?? null, ]), ]; diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php index 028217c..0f94d3d 100644 --- a/app/Services/Paddle/PaddleCheckoutService.php +++ b/app/Services/Paddle/PaddleCheckoutService.php @@ -24,7 +24,14 @@ class PaddleCheckoutService $customData = $this->buildMetadata( $tenant, $package, - array_merge($options['metadata'] ?? [], $options['custom_data'] ?? []) + array_merge( + $options['metadata'] ?? [], + $options['custom_data'] ?? [], + array_filter([ + 'success_url' => $options['success_url'] ?? null, + 'cancel_url' => $options['return_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== '') + ) ); $payload = [ diff --git a/routes/web.php b/routes/web.php index 5d063fa..a0a8d10 100644 --- a/routes/web.php +++ b/routes/web.php @@ -390,6 +390,9 @@ Route::middleware('auth')->group(function () { Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create'); }); +Route::get('/paddle/return', \App\Http\Controllers\PaddleReturnController::class) + ->name('paddle.return'); + Route::post('/paddle/webhook', [PaddleWebhookController::class, 'handle']) ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]) ->middleware('throttle:paddle-webhook') diff --git a/tests/Feature/PaddleReturnTest.php b/tests/Feature/PaddleReturnTest.php new file mode 100644 index 0000000..1399737 --- /dev/null +++ b/tests/Feature/PaddleReturnTest.php @@ -0,0 +1,60 @@ + Http::response([ + 'data' => [ + 'id' => 'txn_123', + 'status' => 'completed', + 'custom_data' => [ + 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', + 'cancel_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', + ], + ], + ], 200), + ]); + + $response = $this->get('/paddle/return?_ptxn=txn_123'); + + $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1'); + } + + public function test_return_redirects_to_cancel_url_when_not_completed(): void + { + Config::set('paddle.api_key', 'test_key'); + Config::set('paddle.base_url', 'https://paddle.test'); + Config::set('paddle.environment', 'sandbox'); + Config::set('app.url', 'https://fotospiel-app.test'); + + Http::fake([ + 'https://paddle.test/transactions/txn_456' => Http::response([ + 'data' => [ + 'id' => 'txn_456', + 'status' => 'failed', + 'custom_data' => [ + 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', + 'cancel_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', + ], + ], + ], 200), + ]); + + $response = $this->get('/paddle/return?_ptxn=txn_456'); + + $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos'); + } +} diff --git a/tests/Unit/PaddleCheckoutServiceTest.php b/tests/Unit/PaddleCheckoutServiceTest.php index f0528f3..87fb2f2 100644 --- a/tests/Unit/PaddleCheckoutServiceTest.php +++ b/tests/Unit/PaddleCheckoutServiceTest.php @@ -41,6 +41,8 @@ class PaddleCheckoutServiceTest extends TestCase && ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id && ($payload['custom_data']['package_id'] ?? null) === (string) $package->id && ($payload['custom_data']['source'] ?? null) === 'test' + && ($payload['custom_data']['success_url'] ?? null) === 'https://example.test/success' + && ($payload['custom_data']['cancel_url'] ?? null) === 'https://example.test/cancel' && ! isset($payload['metadata']) && ! isset($payload['success_url']) && ! isset($payload['cancel_url'])