From ef9903b7b7209105edd4910baaa68c7485b29441 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 12 Oct 2026 15:08:42 +0100 Subject: [PATCH] move into sonardesk --- .../Admin/LicensingDashboardController.php | 42 +++++++++++++++++++ .../views/admin/billing-audit.blade.php | 34 +++++++++++++++ Modules/Licensing/Routes/admin.php | 1 + integration/PHASE8.md | 22 ++++++++++ .../components/layouts/app/sidebar.blade.php | 3 ++ 5 files changed, 102 insertions(+) create mode 100644 Modules/Licensing/Resources/views/admin/billing-audit.blade.php create mode 100644 integration/PHASE8.md diff --git a/Modules/Licensing/Http/Controllers/Admin/LicensingDashboardController.php b/Modules/Licensing/Http/Controllers/Admin/LicensingDashboardController.php index 7a2a235..add1114 100644 --- a/Modules/Licensing/Http/Controllers/Admin/LicensingDashboardController.php +++ b/Modules/Licensing/Http/Controllers/Admin/LicensingDashboardController.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Routing\Controller; use Modules\Licensing\Models\License; use Modules\Licensing\Models\LicenseProduct; +use Modules\Licensing\Models\LicenseWebhookEvent; final class LicensingDashboardController extends Controller { @@ -53,4 +54,45 @@ final class LicensingDashboardController extends Controller return view('licensing::admin.subscriptions', compact('subscriptions', 'total', 'active', 'cancelScheduled')); } + /** Local-only billing integrity audit. Does not contact Paddle or mutate records. */ + public function billingAudit(): View + { + $linked = License::query()->whereNotNull('external_subscription_id'); + + $duplicateSubscriptions = License::query() + ->select('payment_provider', 'external_subscription_id') + ->selectRaw('COUNT(*) AS license_count') + ->whereNotNull('external_subscription_id') + ->groupBy('payment_provider', 'external_subscription_id') + ->havingRaw('COUNT(*) > 1') + ->orderByDesc('license_count') + ->limit(25) + ->get(); + + $duplicateEvents = LicenseWebhookEvent::query() + ->select('provider', 'external_event_id') + ->selectRaw('COUNT(*) AS event_count') + ->whereNotNull('external_event_id') + ->groupBy('provider', 'external_event_id') + ->havingRaw('COUNT(*) > 1') + ->orderByDesc('event_count') + ->limit(25) + ->get(); + + return view('licensing::admin.billing-audit', [ + 'linkedCount' => (clone $linked)->count(), + 'missingProvider' => (clone $linked)->whereNull('payment_provider')->count(), + 'missingCustomer' => (clone $linked)->whereNull('external_customer_id')->count(), + 'missingStatus' => (clone $linked)->whereNull('subscription_status')->count(), + 'eventCount' => LicenseWebhookEvent::query()->count(), + 'unprocessedEvents' => LicenseWebhookEvent::query()->whereNull('processed_at')->count(), + 'failedEvents' => LicenseWebhookEvent::query()->whereNotNull('processing_error')->count(), + 'duplicateSubscriptions' => $duplicateSubscriptions, + 'duplicateEvents' => $duplicateEvents, + 'recentFailures' => LicenseWebhookEvent::query() + ->whereNotNull('processing_error')->latest()->limit(15) + ->get(['id', 'provider', 'external_event_id', 'event_type', 'processing_error', 'created_at']), + ]); + } + } diff --git a/Modules/Licensing/Resources/views/admin/billing-audit.blade.php b/Modules/Licensing/Resources/views/admin/billing-audit.blade.php new file mode 100644 index 0000000..587f978 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/billing-audit.blade.php @@ -0,0 +1,34 @@ +@extends('licensing::admin.layout') +@section('title', 'SonarDesk Billing Audit') +@section('content') +

Billing Audit

+

Local database checks only. No Paddle API requests, billing changes, webhook replays or licence mutations are performed.

+
+
Subscription-linked licences
{{ number_format($linkedCount) }}
+
Missing payment provider
{{ number_format($missingProvider) }}
+
Missing customer ID
{{ number_format($missingCustomer) }}
+
Missing subscription status
{{ number_format($missingStatus) }}
+
Webhook events stored
{{ number_format($eventCount) }}
+
Unprocessed events
{{ number_format($unprocessedEvents) }}
+
Events with errors
{{ number_format($failedEvents) }}
+
+

Duplicate subscription identifiers (first 25)

+

Multiple licences can legitimately share a subscription. Review these before deciding whether a record is incorrect.

+
+@forelse ($duplicateSubscriptions as $row) + +@empty@endforelse +
ProviderSubscription IDLicences
{{ $row->payment_provider ?? '—' }}{{ $row->external_subscription_id }}{{ $row->license_count }}
No repeated subscription identifiers.
+

Duplicate webhook event identifiers (first 25)

+
+@forelse ($duplicateEvents as $row) + +@empty@endforelse +
ProviderEvent IDRows
{{ $row->provider }}{{ $row->external_event_id }}{{ $row->event_count }}
No duplicate event identifiers.
+

Recent processing errors

+
+@forelse ($recentFailures as $event) + +@empty@endforelse +
WhenProviderEvent typeEvent IDError
{{ $event->created_at?->format('d M Y H:i') ?? '—' }}{{ $event->provider }}{{ $event->event_type }}{{ $event->external_event_id }}{{ \Illuminate\Support\Str::limit($event->processing_error, 240) }}
No stored processing errors.
+@endsection diff --git a/Modules/Licensing/Routes/admin.php b/Modules/Licensing/Routes/admin.php index e11b495..5746753 100644 --- a/Modules/Licensing/Routes/admin.php +++ b/Modules/Licensing/Routes/admin.php @@ -10,6 +10,7 @@ Route::middleware(['web', 'auth', 'verified', 'can:manage-sonardesk-licensing']) ->group(function (): void { Route::get('/', [LicensingDashboardController::class, 'index'])->name('index'); Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses'); + Route::get('/billing-audit', [LicensingDashboardController::class, 'billingAudit'])->name('billing-audit'); Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions'); Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show'); }); diff --git a/integration/PHASE8.md b/integration/PHASE8.md new file mode 100644 index 0000000..3b16b82 --- /dev/null +++ b/integration/PHASE8.md @@ -0,0 +1,22 @@ +# Phase 8 — Billing integrity audit (read-only) + +This package adds **Billing Audit** to the SonarDesk administration sidebar at `/administration/sonardesk/billing-audit`. + +## Included +- Subscription-linked licence completeness counts. +- Duplicate subscription identifiers (informational, not automatically errors). +- Duplicate webhook event identifiers. +- Unprocessed and failed webhook event counts, with a bounded list of recent errors. +- Same authentication, email verification and `manage-sonardesk-licensing` Gate as the existing pages. + +## Installation +1. Commit/back up local modifications. Overlay the paths in this ZIP onto the ABSDev root. Merge the full sidebar Blade file if you have changed it since Phase 7. +2. Run `composer dump-autoload`, `php artisan optimize:clear`, and `php artisan route:list --path=administration/sonardesk -v`. +3. Sign in as an authorised administrator and open `/administration/sonardesk/billing-audit`. +4. Confirm local `licenses` and `license_webhook_events` tables exist from earlier licensing migrations. + +## Explicit limitations +This is **not** a live Paddle connection or completed commerce integration. It does not contact Paddle, handle checkout, reconcile remote records, process webhooks, change customer identities, or alter billing. Existing production payment endpoints must remain unchanged. + +## Follow-on work +Phase 9: staged Paddle checkout/webhook parity and idempotency tests. Phase 10: customer identity/account linking. Phase 11: downloads/releases and update feeds. Phase 12: unified frontend, end-to-end regression and controlled deployment. diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 44adba8..4904563 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -50,6 +50,9 @@ {{ __('Overview') }} {{ __('Licences & Activations') }} + @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.billing-audit')) + {{ __('Billing Audit') }} + @endif @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.subscriptions')) {{ __('Subscriptions') }} @endif