From fc3e6715db8986f8cc2411a3afb18766ec0ad459 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 2 Jan 2026 18:35:12 +0100 Subject: [PATCH] Add integrations health monitoring --- .beads/issues.jsonl | 12 +- .beads/last-touched | 2 +- .../Pages/IntegrationsHealthDashboard.php | 39 ++++ .../Widgets/IntegrationsHealthWidget.php | 22 +++ .../Controllers/PaddleWebhookController.php | 15 ++ .../RevenueCatWebhookController.php | 14 +- app/Jobs/ProcessRevenueCatWebhook.php | 67 ++++++- app/Models/IntegrationWebhookEvent.php | 42 ++++ .../Filament/SuperAdminPanelProvider.php | 1 + .../Integrations/IntegrationHealthService.php | 180 ++++++++++++++++++ .../IntegrationWebhookRecorder.php | 82 ++++++++ .../IntegrationWebhookEventFactory.php | 37 ++++ ...reate_integration_webhook_events_table.php | 40 ++++ .../seeders/IntegrationWebhookEventSeeder.php | 16 ++ resources/lang/de/admin.php | 25 +++ resources/lang/en/admin.php | 25 +++ .../integrations-health-dashboard.blade.php | 9 + .../widgets/integrations-health.blade.php | 77 ++++++++ tests/Feature/PaddleWebhookControllerTest.php | 9 + tests/Feature/RevenueCatWebhookTest.php | 12 ++ tests/Unit/SuperAdminNavigationGroupsTest.php | 2 + 21 files changed, 715 insertions(+), 13 deletions(-) create mode 100644 app/Filament/SuperAdmin/Pages/IntegrationsHealthDashboard.php create mode 100644 app/Filament/Widgets/IntegrationsHealthWidget.php create mode 100644 app/Models/IntegrationWebhookEvent.php create mode 100644 app/Services/Integrations/IntegrationHealthService.php create mode 100644 app/Services/Integrations/IntegrationWebhookRecorder.php create mode 100644 database/factories/IntegrationWebhookEventFactory.php create mode 100644 database/migrations/2026_01_02_175042_create_integration_webhook_events_table.php create mode 100644 database/seeders/IntegrationWebhookEventSeeder.php create mode 100644 resources/views/filament/super-admin/pages/integrations-health-dashboard.blade.php create mode 100644 resources/views/filament/widgets/integrations-health.blade.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6bce9bf..396a8f4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,7 +36,7 @@ {"id":"fotospiel-app-9mj","title":"Ops mail default + withdrawal legal routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:02.167387589+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:07.783737036+01:00","closed_at":"2026-01-01T16:10:07.783737036+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-a1n","title":"Paddle migration: define mobile/native billing strategy (RevenueCat vs Paddle)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:40.030226023+01:00","created_by":"soeren","updated_at":"2026-01-01T15:56:40.030226023+01:00"} {"id":"fotospiel-app-agz","title":"Tenant admin onboarding: update PRP/docs + handoff notes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:51.748367378+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:51.748367378+01:00"} -{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","notes":"Fix: WatermarkSettingsPage now defines form(Schema ) to register . Ran vendor/bin/pint --dirty and php artisan test tests/Unit/SuperAdminNavigationGroupsTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-01T20:41:44.791851648+01:00","closed_at":"2026-01-01T20:41:44.791851648+01:00","close_reason":"Completed"} +{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","notes":"Fix: WatermarkSettingsPage now defines form(Schema ) to register . Ran vendor/bin/pint --dirty and php artisan test tests/Unit/SuperAdminNavigationGroupsTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-02T17:33:44.707289021+01:00","closed_at":"2026-01-02T17:33:44.707289021+01:00","close_reason":"Closed"} {"id":"fotospiel-app-auq","title":"Security review checklist: Media pipeline/storage dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:57.616770583+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:57.616770583+01:00"} {"id":"fotospiel-app-b0h","title":"Security review: trust boundaries/entrypoints mapped","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:43.175087637+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:48.799343248+01:00","closed_at":"2026-01-01T16:03:48.799343248+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-bep","title":"SEC-IO-01 Document PAT revocation/rotation playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:44.568780967+01:00","created_by":"soeren","updated_at":"2026-01-01T15:51:44.568780967+01:00"} @@ -46,6 +46,7 @@ {"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:20.543083527+01:00"} +{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"} {"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"} {"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"} @@ -57,8 +58,8 @@ {"id":"fotospiel-app-g5o","title":"SEC-MS-04 Storage health widget in Super Admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:15.088501536+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:20.739996548+01:00","closed_at":"2026-01-01T15:53:20.739996548+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-g74","title":"Paddle migration: automated tests for checkout/webhooks/sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:34.795423009+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:40.467997776+01:00","closed_at":"2026-01-01T15:58:40.467997776+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-gsv","title":"Localized SEO: validate hreflang via Search Console/Lighthouse","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:36.4821072+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:36.4821072+01:00"} -{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-01T18:50:57.274743566+01:00","closed_at":"2026-01-01T18:46:09.677538603+01:00"} -{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","notes":"Added superadmin control surface + access matrix to docs/ops/operations-manual.md (Section 1.1), including non-goals and role scope.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-01T19:52:54.391624328+01:00","closed_at":"2026-01-01T19:52:54.391628452+01:00"} +{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-02T17:33:22.599440896+01:00","closed_at":"2026-01-02T17:33:22.599440896+01:00","close_reason":"Closed"} +{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","notes":"Added superadmin control surface + access matrix to docs/ops/operations-manual.md (Section 1.1), including non-goals and role scope.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-02T17:33:57.71777777+01:00","closed_at":"2026-01-02T17:33:57.71777777+01:00","close_reason":"Closed"} {"id":"fotospiel-app-iyc","title":"Superadmin audit log for admin actions","description":"Audit trail for superadmin actions without PII payloads.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:19.043695952+01:00","updated_at":"2026-01-02T11:57:23.328889123+01:00","closed_at":"2026-01-02T11:57:23.328889123+01:00","close_reason":"Closed"} {"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"} {"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"} @@ -95,7 +96,7 @@ {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"} {"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"} -{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-01T21:07:06.982356199+01:00","closed_at":"2026-01-01T21:07:06.982356199+01:00","close_reason":"completed"} +{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"} {"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-umr","title":"Paddle migration: build Paddle API service layer","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:38.529187953+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:44.178675076+01:00","closed_at":"2026-01-01T15:57:44.178675076+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-v31","title":"SEC-MS-01 AV + EXIF scrubber worker integration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:52.476048623+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:58.118336529+01:00","closed_at":"2026-01-01T15:52:58.118336529+01:00","close_reason":"Completed in codebase (verified)"} @@ -106,10 +107,11 @@ {"id":"fotospiel-app-vk4","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:01.574904029+01:00","created_by":"soeren","updated_at":"2026-01-01T16:11:18.65499639+01:00","closed_at":"2026-01-01T16:11:18.65499639+01:00","close_reason":"Duplicate of fotospiel-app-l6a"} {"id":"fotospiel-app-w2x","title":"SEC-FE-03 Cookie banner UX + localisation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:26.182193434+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:31.84344419+01:00","closed_at":"2026-01-01T15:55:31.84344419+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-w7g","title":"Paddle catalog sync: document failure recovery playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:26.255623751+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:26.255623751+01:00"} -{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Delivered dedicated tenant lifecycle view with limits + audit timeline, added grace_period_ends_at field and tenant_lifecycle_events logging, wired lifecycle actions (activate/suspend/deletion/anonymize) + management actions (limits, grace, subscription expiry), enforced tenant photo/storage limits in PackageLimitEvaluator, added lifecycle/limits tests, ran Pint + targeted tests.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-01T19:36:09.3227431+01:00","closed_at":"2026-01-01T19:36:09.322746908+01:00"} +{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Delivered dedicated tenant lifecycle view with limits + audit timeline, added grace_period_ends_at field and tenant_lifecycle_events logging, wired lifecycle actions (activate/suspend/deletion/anonymize) + management actions (limits, grace, subscription expiry), enforced tenant photo/storage limits in PackageLimitEvaluator, added lifecycle/limits tests, ran Pint + targeted tests.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-02T17:33:35.031605632+01:00","closed_at":"2026-01-02T17:33:35.031605632+01:00","close_reason":"Closed"} {"id":"fotospiel-app-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} +{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T17:39:14.808842908+01:00"} {"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"} {"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"} {"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index 1889171..4f12500 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-kxe +fotospiel-app-y1f diff --git a/app/Filament/SuperAdmin/Pages/IntegrationsHealthDashboard.php b/app/Filament/SuperAdmin/Pages/IntegrationsHealthDashboard.php new file mode 100644 index 0000000..e864441 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/IntegrationsHealthDashboard.php @@ -0,0 +1,39 @@ + $health->providers(), + ]; + } +} diff --git a/app/Http/Controllers/PaddleWebhookController.php b/app/Http/Controllers/PaddleWebhookController.php index 58c13cf..aa2fad6 100644 --- a/app/Http/Controllers/PaddleWebhookController.php +++ b/app/Http/Controllers/PaddleWebhookController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Services\Addons\EventAddonWebhookService; use App\Services\Checkout\CheckoutWebhookService; +use App\Services\Integrations\IntegrationWebhookRecorder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -14,6 +15,7 @@ class PaddleWebhookController extends Controller public function __construct( private readonly CheckoutWebhookService $webhooks, private readonly EventAddonWebhookService $addonWebhooks, + private readonly IntegrationWebhookRecorder $recorder, ) {} public function handle(Request $request): JsonResponse @@ -32,6 +34,12 @@ class PaddleWebhookController extends Controller } $eventType = $payload['event_type'] ?? null; + $eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id'); + $webhookEvent = $this->recorder->recordReceived( + 'paddle', + $eventId ? (string) $eventId : null, + $eventType ? (string) $eventType : null, + ); $handled = false; $this->logDev('Paddle webhook received', [ @@ -53,6 +61,9 @@ class PaddleWebhookController extends Controller ]); $statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED; + $handled + ? $this->recorder->markProcessed($webhookEvent, ['handled' => true]) + : $this->recorder->markIgnored($webhookEvent, ['handled' => false]); return response()->json([ 'status' => $handled ? 'processed' : 'ignored', @@ -68,6 +79,10 @@ class PaddleWebhookController extends Controller $this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all())); + if (isset($webhookEvent)) { + $this->recorder->markFailed($webhookEvent, $exception->getMessage()); + } + return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR); } } diff --git a/app/Http/Controllers/RevenueCatWebhookController.php b/app/Http/Controllers/RevenueCatWebhookController.php index 4da0107..bf91d9c 100644 --- a/app/Http/Controllers/RevenueCatWebhookController.php +++ b/app/Http/Controllers/RevenueCatWebhookController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Jobs\ProcessRevenueCatWebhook; +use App\Services\Integrations\IntegrationWebhookRecorder; use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response; class RevenueCatWebhookController extends Controller { + public function __construct(private readonly IntegrationWebhookRecorder $recorder) {} + public function handle(Request $request): JsonResponse { $secret = (string) config('services.revenuecat.webhook', ''); @@ -61,9 +64,18 @@ class RevenueCatWebhookController extends Controller ); } + $eventId = (string) $request->header('X-Event-Id', ''); + $eventType = data_get($decoded, 'event.type'); + $webhookEvent = $this->recorder->recordReceived( + 'revenuecat', + $eventId !== '' ? $eventId : null, + is_string($eventType) && $eventType !== '' ? $eventType : null, + ); + ProcessRevenueCatWebhook::dispatch( $decoded, - (string) $request->header('X-Event-Id', '') + $eventId, + $webhookEvent->id, ); return response()->json(['status' => 'accepted'], 202); diff --git a/app/Jobs/ProcessRevenueCatWebhook.php b/app/Jobs/ProcessRevenueCatWebhook.php index 22228a9..b83605c 100644 --- a/app/Jobs/ProcessRevenueCatWebhook.php +++ b/app/Jobs/ProcessRevenueCatWebhook.php @@ -3,7 +3,9 @@ namespace App\Jobs; use App\Models\EventPurchase; +use App\Models\IntegrationWebhookEvent; use App\Models\Tenant; +use App\Services\Integrations\IntegrationWebhookRecorder; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -25,24 +27,27 @@ class ProcessRevenueCatWebhook implements ShouldQueue private ?string $eventId; + private ?int $webhookEventId; + public int $tries = 5; public int $backoff = 60; - /** - * @param array $payload + * @param array $payload */ - public function __construct(array $payload, ?string $eventId = null) + public function __construct(array $payload, ?string $eventId = null, ?int $webhookEventId = null) { $this->payload = $payload; $this->eventId = $eventId !== '' ? $eventId : null; + $this->webhookEventId = $webhookEventId; $this->queue = config('services.revenuecat.queue', 'webhooks'); $this->onQueue($this->queue); } public function handle(): void { + $webhookEvent = $this->resolveWebhookEvent(); $appUserId = $this->value('event.app_user_id') ?? $this->value('subscriber.app_user_id'); @@ -50,6 +55,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue Log::warning('RevenueCat webhook missing app_user_id', [ 'event_id' => $this->eventId, ]); + $this->markFailed($webhookEvent, 'Missing app_user_id'); + return; } @@ -59,6 +66,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue 'event_id' => $this->eventId, 'app_user_id' => $appUserId, ]); + $this->markFailed($webhookEvent, 'Tenant not found'); + return; } @@ -74,13 +83,15 @@ class ProcessRevenueCatWebhook implements ShouldQueue if (EventPurchase::where('provider', 'revenuecat') ->where('external_receipt_id', $transactionId) ->exists()) { + $this->markIgnored($webhookEvent, 'Duplicate transaction'); + return; } $amount = (float) ($this->value('event.price') ?? 0); $currency = strtoupper((string) ($this->value('event.currency') ?? 'EUR')); - DB::transaction(function () use ($tenant, $credits, $transactionId, $productId, $amount, $currency) { + DB::transaction(function () use ($tenant, $credits, $transactionId, $amount, $currency) { $tenant->refresh(); $purchase = EventPurchase::create([ @@ -104,6 +115,14 @@ class ProcessRevenueCatWebhook implements ShouldQueue 'product_id' => $productId, 'credits' => $credits, ]); + + $this->markProcessed($webhookEvent); + } + + public function failed(\Throwable $exception): void + { + $webhookEvent = $this->resolveWebhookEvent(); + $this->markFailed($webhookEvent, $exception->getMessage()); } private function updateSubscriptionStatus(Tenant $tenant): void @@ -149,7 +168,7 @@ class ProcessRevenueCatWebhook implements ShouldQueue } foreach ([':', '-', '_'] as $delimiter) { - $needle = strtolower($prefix) . $delimiter; + $needle = strtolower($prefix).$delimiter; if (str_starts_with($lower, $needle)) { $candidate = substr($appUserId, strlen($needle)); if (is_numeric($candidate)) { @@ -226,7 +245,7 @@ class ProcessRevenueCatWebhook implements ShouldQueue return $mappings; } - private function value(string $path, $default = null) + private function value(string $path, $default = null): mixed { $segments = explode('.', $path); $value = $this->payload; @@ -241,4 +260,40 @@ class ProcessRevenueCatWebhook implements ShouldQueue return $value; } + + private function resolveWebhookEvent(): ?IntegrationWebhookEvent + { + if (! $this->webhookEventId) { + return null; + } + + return IntegrationWebhookEvent::find($this->webhookEventId); + } + + private function markProcessed(?IntegrationWebhookEvent $event): void + { + if (! $event) { + return; + } + + app(IntegrationWebhookRecorder::class)->markProcessed($event); + } + + private function markIgnored(?IntegrationWebhookEvent $event, string $reason): void + { + if (! $event) { + return; + } + + app(IntegrationWebhookRecorder::class)->markIgnored($event, ['reason' => $reason]); + } + + private function markFailed(?IntegrationWebhookEvent $event, string $reason): void + { + if (! $event) { + return; + } + + app(IntegrationWebhookRecorder::class)->markFailed($event, $reason); + } } diff --git a/app/Models/IntegrationWebhookEvent.php b/app/Models/IntegrationWebhookEvent.php new file mode 100644 index 0000000..e3f7179 --- /dev/null +++ b/app/Models/IntegrationWebhookEvent.php @@ -0,0 +1,42 @@ + */ + use HasFactory; + + public const STATUS_RECEIVED = 'received'; + + public const STATUS_PROCESSED = 'processed'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_IGNORED = 'ignored'; + + protected $fillable = [ + 'provider', + 'event_id', + 'event_type', + 'status', + 'received_at', + 'processed_at', + 'failed_at', + 'error_message', + 'context', + ]; + + protected function casts(): array + { + return [ + 'received_at' => 'datetime', + 'processed_at' => 'datetime', + 'failed_at' => 'datetime', + 'context' => 'array', + ]; + } +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 0bbe0ba..04f2f36 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -113,6 +113,7 @@ class SuperAdminPanelProvider extends PanelProvider \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class, \App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class, \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class, + \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class, ]) ->authGuard('super_admin'); diff --git a/app/Services/Integrations/IntegrationHealthService.php b/app/Services/Integrations/IntegrationHealthService.php new file mode 100644 index 0000000..7f0338f --- /dev/null +++ b/app/Services/Integrations/IntegrationHealthService.php @@ -0,0 +1,180 @@ +> + */ + public function providers(): array + { + return [ + $this->buildProvider('paddle', 'Paddle', [ + 'is_configured' => filled(config('paddle.webhook_secret')), + 'label' => 'Webhook secret', + ]), + $this->buildProvider('revenuecat', 'RevenueCat', [ + 'is_configured' => filled(config('services.revenuecat.webhook')), + 'label' => 'Webhook secret', + 'queue' => config('services.revenuecat.queue', 'webhooks'), + 'job_class' => ProcessRevenueCatWebhook::class, + ]), + ]; + } + + /** + * @param array $config + * @return array + */ + private function buildProvider(string $provider, string $label, array $config): array + { + $query = IntegrationWebhookEvent::query()->where('provider', $provider); + $lastEvent = (clone $query)->orderByDesc('received_at')->first(); + $lastProcessed = (clone $query) + ->where('status', IntegrationWebhookEvent::STATUS_PROCESSED) + ->orderByDesc('processed_at') + ->first(); + $lastFailed = (clone $query) + ->where('status', IntegrationWebhookEvent::STATUS_FAILED) + ->orderByDesc('failed_at') + ->first(); + + $pendingCount = (clone $query) + ->where('status', IntegrationWebhookEvent::STATUS_RECEIVED) + ->count(); + + $recentFailures = (clone $query) + ->where('status', IntegrationWebhookEvent::STATUS_FAILED) + ->where('failed_at', '>=', now()->subDay()) + ->count(); + + $queueName = $config['queue'] ?? null; + $jobClass = $config['job_class'] ?? null; + + $queueBacklog = $this->countJobs($queueName, $jobClass); + $failedJobs = $this->countFailedJobs($queueName, $jobClass); + + $status = $this->resolveStatus( + (bool) ($config['is_configured'] ?? false), + $pendingCount, + $recentFailures, + $failedJobs, + $lastEvent + ); + + return [ + 'provider' => $provider, + 'label' => $label, + 'config_label' => (string) ($config['label'] ?? 'Config'), + 'is_configured' => (bool) ($config['is_configured'] ?? false), + 'status' => $status, + 'status_label' => $this->statusLabel($status), + 'pending_count' => $pendingCount, + 'recent_failures' => $recentFailures, + 'queue_backlog' => $queueBacklog, + 'failed_jobs' => $failedJobs, + 'last_event' => $this->formatEvent($lastEvent), + 'last_processed' => $this->formatEvent($lastProcessed), + 'last_failed' => $this->formatEvent($lastFailed), + 'processing_lag' => $this->formatLag($lastEvent), + ]; + } + + private function resolveStatus( + bool $isConfigured, + int $pendingCount, + int $recentFailures, + int $failedJobs, + ?IntegrationWebhookEvent $lastEvent, + ): string { + if (! $isConfigured) { + return 'unconfigured'; + } + + if ($pendingCount > 0) { + return 'pending'; + } + + if ($recentFailures > 0 || $failedJobs > 0) { + return 'degraded'; + } + + if (! $lastEvent) { + return 'unknown'; + } + + return 'healthy'; + } + + private function statusLabel(string $status): string + { + return match ($status) { + 'healthy' => __('admin.integrations_health.status.healthy'), + 'pending' => __('admin.integrations_health.status.pending'), + 'degraded' => __('admin.integrations_health.status.degraded'), + 'unconfigured' => __('admin.integrations_health.status.unconfigured'), + default => __('admin.integrations_health.status.unknown'), + }; + } + + private function formatEvent(?IntegrationWebhookEvent $event): ?array + { + if (! $event) { + return null; + } + + return [ + 'status' => $event->status, + 'event_type' => $event->event_type, + 'received_at' => $event->received_at, + 'processed_at' => $event->processed_at, + 'failed_at' => $event->failed_at, + 'error_message' => $event->error_message, + ]; + } + + private function formatLag(?IntegrationWebhookEvent $event): ?array + { + if (! $event || ! $event->received_at) { + return null; + } + + $end = $event->processed_at ?? now(); + $seconds = $end->diffInSeconds($event->received_at); + + return [ + 'seconds' => $seconds, + 'label' => Carbon::now()->subSeconds($seconds)->diffForHumans(null, true), + ]; + } + + private function countJobs(?string $queueName, ?string $jobClass): int + { + if (! $queueName || ! $jobClass) { + return 0; + } + + return (int) DB::table('jobs') + ->where('queue', $queueName) + ->where('payload', 'like', '%'.$jobClass.'%') + ->count(); + } + + private function countFailedJobs(?string $queueName, ?string $jobClass): int + { + if (! $queueName || ! $jobClass) { + return 0; + } + + return (int) DB::table('failed_jobs') + ->where('queue', $queueName) + ->where('payload', 'like', '%'.$jobClass.'%') + ->count(); + } +} diff --git a/app/Services/Integrations/IntegrationWebhookRecorder.php b/app/Services/Integrations/IntegrationWebhookRecorder.php new file mode 100644 index 0000000..c2b386b --- /dev/null +++ b/app/Services/Integrations/IntegrationWebhookRecorder.php @@ -0,0 +1,82 @@ + $context + */ + public function recordReceived(string $provider, ?string $eventId, ?string $eventType, array $context = []): IntegrationWebhookEvent + { + return IntegrationWebhookEvent::create([ + 'provider' => $provider, + 'event_id' => $eventId, + 'event_type' => $eventType, + 'status' => IntegrationWebhookEvent::STATUS_RECEIVED, + 'received_at' => now(), + 'context' => $context, + ]); + } + + /** + * @param array $context + */ + public function markProcessed(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent + { + $event->forceFill([ + 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, + 'processed_at' => now(), + 'context' => $this->mergeContext($event, $context), + ])->save(); + + return $event; + } + + /** + * @param array $context + */ + public function markIgnored(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent + { + $event->forceFill([ + 'status' => IntegrationWebhookEvent::STATUS_IGNORED, + 'processed_at' => now(), + 'context' => $this->mergeContext($event, $context), + ])->save(); + + return $event; + } + + /** + * @param array $context + */ + public function markFailed(IntegrationWebhookEvent $event, string $message, array $context = []): IntegrationWebhookEvent + { + $event->forceFill([ + 'status' => IntegrationWebhookEvent::STATUS_FAILED, + 'failed_at' => now(), + 'error_message' => Str::limit($message, 500, ''), + 'context' => $this->mergeContext($event, $context), + ])->save(); + + return $event; + } + + /** + * @param array $context + * @return array + */ + private function mergeContext(IntegrationWebhookEvent $event, array $context): array + { + $existing = $event->context ?? []; + + if (! is_array($existing)) { + $existing = []; + } + + return array_merge($existing, $context); + } +} diff --git a/database/factories/IntegrationWebhookEventFactory.php b/database/factories/IntegrationWebhookEventFactory.php new file mode 100644 index 0000000..bb3af48 --- /dev/null +++ b/database/factories/IntegrationWebhookEventFactory.php @@ -0,0 +1,37 @@ + + */ +class IntegrationWebhookEventFactory extends Factory +{ + protected $model = IntegrationWebhookEvent::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $receivedAt = $this->faker->dateTimeBetween('-2 days', 'now'); + $processedAt = (clone $receivedAt)->modify('+2 minutes'); + + return [ + 'provider' => $this->faker->randomElement(['paddle', 'revenuecat']), + 'event_id' => $this->faker->uuid(), + 'event_type' => $this->faker->word(), + 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, + 'received_at' => $receivedAt, + 'processed_at' => $processedAt, + 'failed_at' => null, + 'error_message' => null, + 'context' => [], + ]; + } +} diff --git a/database/migrations/2026_01_02_175042_create_integration_webhook_events_table.php b/database/migrations/2026_01_02_175042_create_integration_webhook_events_table.php new file mode 100644 index 0000000..24c217a --- /dev/null +++ b/database/migrations/2026_01_02_175042_create_integration_webhook_events_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('provider', 40); + $table->string('event_id', 191)->nullable(); + $table->string('event_type', 120)->nullable(); + $table->string('status', 30)->default('received'); + $table->timestamp('received_at'); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->string('error_message', 500)->nullable(); + $table->json('context')->nullable(); + $table->timestamps(); + + $table->index(['provider', 'status']); + $table->index(['provider', 'received_at']); + $table->index(['provider', 'event_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('integration_webhook_events'); + } +}; diff --git a/database/seeders/IntegrationWebhookEventSeeder.php b/database/seeders/IntegrationWebhookEventSeeder.php new file mode 100644 index 0000000..39721f3 --- /dev/null +++ b/database/seeders/IntegrationWebhookEventSeeder.php @@ -0,0 +1,16 @@ + 'Paddle-Status', ], ], + 'integrations_health' => [ + 'navigation' => [ + 'label' => 'Integrationen-Status', + ], + 'status' => [ + 'healthy' => 'OK', + 'pending' => 'Ausstehend', + 'degraded' => 'Beeinträchtigt', + 'unconfigured' => 'Nicht konfiguriert', + 'unknown' => 'Unbekannt', + ], + 'heading' => 'Integrationen-Status', + 'help' => 'Operativer Überblick über Paddle/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.', + 'configured' => 'Konfiguriert', + 'unconfigured' => 'Nicht konfiguriert', + 'last_received' => 'Zuletzt empfangen', + 'last_processed' => 'Zuletzt verarbeitet', + 'processing_lag' => 'Verarbeitungsdauer', + 'pending_events' => 'Offene Events', + 'recent_failures' => 'Fehler (24h)', + 'queue_backlog' => 'Queue-Backlog', + 'failed_jobs' => 'Fehlgeschlagene Jobs', + 'last_error' => 'Letzter Fehler', + 'empty' => 'Noch keine Integrationsdaten verfügbar.', + ], 'guest_policy' => [ 'navigation' => [ 'label' => 'Gast-Richtlinien', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 6389e07..5158f53 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -188,6 +188,31 @@ return [ 'label' => 'Paddle health', ], ], + 'integrations_health' => [ + 'navigation' => [ + 'label' => 'Integrations health', + ], + 'status' => [ + 'healthy' => 'Healthy', + 'pending' => 'Pending', + 'degraded' => 'Degraded', + 'unconfigured' => 'Unconfigured', + 'unknown' => 'Unknown', + ], + 'heading' => 'Integrations health', + 'help' => 'Operational snapshot of Paddle/RevenueCat webhooks, queue backlog, and recent failures.', + 'configured' => 'Configured', + 'unconfigured' => 'Unconfigured', + 'last_received' => 'Last received', + 'last_processed' => 'Last processed', + 'processing_lag' => 'Processing lag', + 'pending_events' => 'Pending events', + 'recent_failures' => 'Failures (24h)', + 'queue_backlog' => 'Queue backlog', + 'failed_jobs' => 'Failed jobs', + 'last_error' => 'Last error', + 'empty' => 'No integration health data available yet.', + ], 'guest_policy' => [ 'navigation' => [ 'label' => 'Guest policy', diff --git a/resources/views/filament/super-admin/pages/integrations-health-dashboard.blade.php b/resources/views/filament/super-admin/pages/integrations-health-dashboard.blade.php new file mode 100644 index 0000000..62acf66 --- /dev/null +++ b/resources/views/filament/super-admin/pages/integrations-health-dashboard.blade.php @@ -0,0 +1,9 @@ +@php + use Filament\Support\Icons\Heroicon; +@endphp + + + + {{ __('admin.integrations_health.help') }} + + diff --git a/resources/views/filament/widgets/integrations-health.blade.php b/resources/views/filament/widgets/integrations-health.blade.php new file mode 100644 index 0000000..9438eb6 --- /dev/null +++ b/resources/views/filament/widgets/integrations-health.blade.php @@ -0,0 +1,77 @@ + + +
+ @forelse($providers as $provider) +
+
+
+

{{ $provider['label'] }}

+

+ {{ $provider['config_label'] }}: + + {{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }} + +

+
+ $provider['status'] === 'healthy', + 'bg-amber-100 text-amber-800' => $provider['status'] === 'pending', + 'bg-rose-100 text-rose-800' => in_array($provider['status'], ['degraded', 'unconfigured']), + 'bg-slate-100 text-slate-600' => $provider['status'] === 'unknown', + ])> + {{ $provider['status_label'] }} + +
+ +
+
+ {{ __('admin.integrations_health.last_received') }} + + {{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }} + +
+
+ {{ __('admin.integrations_health.last_processed') }} + + {{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }} + +
+
+ {{ __('admin.integrations_health.processing_lag') }} + + {{ $provider['processing_lag']['label'] ?? '—' }} + +
+
+ {{ __('admin.integrations_health.pending_events') }} + {{ number_format($provider['pending_count']) }} +
+
+ {{ __('admin.integrations_health.recent_failures') }} + {{ number_format($provider['recent_failures']) }} +
+
+ {{ __('admin.integrations_health.queue_backlog') }} + {{ number_format($provider['queue_backlog']) }} +
+
+ {{ __('admin.integrations_health.failed_jobs') }} + {{ number_format($provider['failed_jobs']) }} +
+
+ + @if(! empty(data_get($provider, 'last_failed.error_message'))) +
+ {{ __('admin.integrations_health.last_error') }}: {{ data_get($provider, 'last_failed.error_message') }} +
+ @endif +
+ @empty +

+ {{ __('admin.integrations_health.empty') }} +

+ @endforelse +
+
+
diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php index c81e246..4ded54a 100644 --- a/tests/Feature/PaddleWebhookControllerTest.php +++ b/tests/Feature/PaddleWebhookControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Models\CheckoutSession; +use App\Models\IntegrationWebhookEvent; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\Tenant; @@ -25,6 +26,7 @@ class PaddleWebhookControllerTest extends TestCase [$tenant, $package, $session] = $this->prepareSession(); $payload = [ + 'event_id' => 'evt_123', 'event_type' => 'transaction.completed', 'data' => [ 'id' => 'txn_123', @@ -56,6 +58,13 @@ class PaddleWebhookControllerTest extends TestCase $response->assertOk()->assertJson(['status' => 'processed']); + $this->assertDatabaseHas('integration_webhook_events', [ + 'provider' => 'paddle', + 'event_id' => 'evt_123', + 'event_type' => 'transaction.completed', + 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, + ]); + $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); diff --git a/tests/Feature/RevenueCatWebhookTest.php b/tests/Feature/RevenueCatWebhookTest.php index 647a78d..37de243 100644 --- a/tests/Feature/RevenueCatWebhookTest.php +++ b/tests/Feature/RevenueCatWebhookTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Jobs\ProcessRevenueCatWebhook; +use App\Models\IntegrationWebhookEvent; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Tests\TestCase; @@ -17,6 +18,7 @@ class RevenueCatWebhookTest extends TestCase $payload = [ 'event' => [ + 'type' => 'INITIAL_PURCHASE', 'app_user_id' => 'tenant-123', 'product_id' => 'pro_month', ], @@ -28,12 +30,20 @@ class RevenueCatWebhookTest extends TestCase Bus::fake(); $response = $this->postJson('/api/v1/webhooks/revenuecat', $payload, [ + 'X-Event-Id' => 'evt_123', 'X-Signature' => $signature, ]); $response->assertStatus(202) ->assertJson(['status' => 'accepted']); + $this->assertDatabaseHas('integration_webhook_events', [ + 'provider' => 'revenuecat', + 'event_id' => 'evt_123', + 'event_type' => 'INITIAL_PURCHASE', + 'status' => IntegrationWebhookEvent::STATUS_RECEIVED, + ]); + Bus::assertDispatched(ProcessRevenueCatWebhook::class); } @@ -53,6 +63,8 @@ class RevenueCatWebhookTest extends TestCase ->assertJsonPath('error.code', 'signature_invalid') ->assertJsonPath('error.title', 'Invalid Signature'); + $this->assertDatabaseCount('integration_webhook_events', 0); + Bus::assertNotDispatched(ProcessRevenueCatWebhook::class); } } diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index 27f2621..4e33332 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -43,6 +43,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class => 'admin.nav.branding', \App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure', \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure', + \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => 'admin.nav.infrastructure', ]; foreach ($expectations as $resourceClass => $key) { @@ -57,6 +58,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => DailyOpsCluster::class, \App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class, \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class, + \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => DailyOpsCluster::class, \App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,