From dfdbf09bf8842a914ebae96dbe5eb7b14768e945 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 2 Jan 2026 22:25:11 +0100 Subject: [PATCH] Register Paddle sandbox webhooks --- .../Commands/PaddleRegisterWebhooks.php | 132 ++++++++++++++++++ config/paddle.php | 13 ++ docs/ops/billing-ops.md | 25 ++++ .../PaddleRegisterWebhooksCommandTest.php | 44 ++++++ 4 files changed, 214 insertions(+) create mode 100644 app/Console/Commands/PaddleRegisterWebhooks.php create mode 100644 tests/Feature/PaddleRegisterWebhooksCommandTest.php diff --git a/app/Console/Commands/PaddleRegisterWebhooks.php b/app/Console/Commands/PaddleRegisterWebhooks.php new file mode 100644 index 0000000..d35d99f --- /dev/null +++ b/app/Console/Commands/PaddleRegisterWebhooks.php @@ -0,0 +1,132 @@ +option('url') ?: $this->defaultWebhookUrl()); + + if ($destination === '') { + $this->error('Webhook destination URL is required. Use --url=...'); + + return self::FAILURE; + } + + $events = collect((array) $this->option('events')) + ->filter() + ->map(fn ($event) => trim((string) $event)) + ->filter() + ->values() + ->all(); + + if ($events === []) { + $events = config('paddle.webhook_events', []); + } + + if ($events === [] || ! is_array($events)) { + $this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.'); + + return self::FAILURE; + } + + $trafficSource = (string) $this->option('traffic-source'); + $allowedSources = ['platform', 'simulation', 'all']; + + if (! in_array($trafficSource, $allowedSources, true)) { + $this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources))); + + return self::FAILURE; + } + + $payload = [ + 'type' => 'url', + 'destination' => $destination, + 'description' => $this->resolveDescription(), + 'subscribed_events' => $events, + 'traffic_source' => $trafficSource, + 'include_sensitive_fields' => (bool) $this->option('include-sensitive'), + ]; + + if ((bool) $this->option('dry-run')) { + $this->line(json_encode($payload, JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $response = $client->post('/notification-settings', $payload); + $data = Arr::get($response, 'data', $response); + $id = Arr::get($data, 'id'); + $secret = Arr::get($data, 'endpoint_secret_key'); + + Log::channel('paddle-sync')->info('Paddle webhook registered', [ + 'notification_setting_id' => $id, + 'destination' => $destination, + 'traffic_source' => $trafficSource, + ]); + + $this->info('Paddle webhook registered.'); + + if ($id) { + $this->line('ID: '.$id); + } + + if ($secret && $this->option('show-secret')) { + $this->line('Secret: '.$secret); + } elseif ($secret) { + $this->line('Secret returned (hidden). Use --show-secret to display.'); + } + + return self::SUCCESS; + } + + protected function defaultWebhookUrl(): string + { + $base = rtrim((string) config('app.url'), '/'); + + return $base !== '' ? $base.'/paddle/webhook' : ''; + } + + protected function resolveDescription(): string + { + $description = (string) $this->option('description'); + + if ($description !== '') { + return $description; + } + + $environment = (string) config('paddle.environment', 'production'); + + return sprintf('Fotospiel Paddle webhooks (%s)', $environment); + } +} diff --git a/config/paddle.php b/config/paddle.php index 90091e1..72ef9c2 100644 --- a/config/paddle.php +++ b/config/paddle.php @@ -32,4 +32,17 @@ return [ 'console_url' => $consoleUrl, 'webhook_secret' => $webhookSecret, 'public_key' => $publicKey, + 'webhook_events' => [ + 'transaction.created', + 'transaction.processing', + 'transaction.completed', + 'transaction.failed', + 'transaction.cancelled', + 'subscription.created', + 'subscription.updated', + 'subscription.paused', + 'subscription.resumed', + 'subscription.cancelled', + 'subscription.past_due', + ], ]; diff --git a/docs/ops/billing-ops.md b/docs/ops/billing-ops.md index f31b267..c872983 100644 --- a/docs/ops/billing-ops.md +++ b/docs/ops/billing-ops.md @@ -107,6 +107,31 @@ Diese Sektion ist bewusst generisch gehalten, damit sie auch nach Implementation - Bei `transaction.failed` / `transaction.cancelled`: - Session wird auf `failed` gesetzt, Coupons werden als fehlgeschlagen markiert. +### 6.2.1 Sandbox‑Webhook registrieren + +- Command (Sandbox-Umgebung aktiv): + - `php artisan paddle:webhooks:register --traffic-source=simulation` +- Optional mit URL-Override: + - `php artisan paddle:webhooks:register --url=https://staging.example.com/paddle/webhook --traffic-source=simulation` +- Event‑Liste kommt aus `config/paddle.php` (`webhook_events`). Override möglich: + - `php artisan paddle:webhooks:register --events=transaction.completed --events=subscription.created` + +### 6.2.2 Verarbeitete Paddle-Events + +Die Webhook‑Handler erwarten mindestens diese Events: + +- `transaction.created` +- `transaction.processing` +- `transaction.completed` (inkl. Add-ons/Gift‑Vouchers) +- `transaction.failed` +- `transaction.cancelled` +- `subscription.created` +- `subscription.updated` +- `subscription.paused` +- `subscription.resumed` +- `subscription.cancelled` +- `subscription.past_due` + ### 6.3 Subscriptions & TenantPackages - Subscription‑Events (`subscription.*`) werden ebenfalls von `CheckoutWebhookService` behandelt: diff --git a/tests/Feature/PaddleRegisterWebhooksCommandTest.php b/tests/Feature/PaddleRegisterWebhooksCommandTest.php new file mode 100644 index 0000000..d6dfd09 --- /dev/null +++ b/tests/Feature/PaddleRegisterWebhooksCommandTest.php @@ -0,0 +1,44 @@ + ['transaction.completed', 'subscription.created'], + ]); + + $client = Mockery::mock(PaddleClient::class); + $client->shouldReceive('post') + ->once() + ->with('/notification-settings', Mockery::on(function (array $payload): bool { + return $payload['destination'] === 'https://example.test/paddle/webhook' + && $payload['subscribed_events'] === ['transaction.completed', 'subscription.created'] + && $payload['traffic_source'] === 'simulation'; + })) + ->andReturn(['data' => ['id' => 'ntfset_123']]); + + $this->app->instance(PaddleClient::class, $client); + + $this->artisan('paddle:webhooks:register', [ + '--url' => 'https://example.test/paddle/webhook', + '--traffic-source' => 'simulation', + ])->assertExitCode(0); + } +}