From a88d336991428b0a7c2783776a276cbf152b002a Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 12 Oct 2026 15:18:07 +0100 Subject: [PATCH] move into sonardesk --- .../Admin/PaddleReconciliationController.php | 51 +++++++++++++++++++ .../Customer/CustomerLicensingController.php | 40 +++++++++++++++ .../IntegratedLicensingServiceProvider.php | 2 + .../admin/paddle-reconciliation.blade.php | 22 ++++++++ .../Resources/views/customer/index.blade.php | 14 +++++ .../Resources/views/customer/layout.blade.php | 7 +++ .../Resources/views/customer/show.blade.php | 12 +++++ Modules/Licensing/Routes/admin.php | 1 + Modules/Licensing/Routes/customer.php | 13 +++++ .../Services/PaddleReadOnlyClient.php | 49 ++++++++++++++++++ config/sonardesk-paddle.php | 6 +++ integration/PHASE10.md | 23 +++++++++ integration/PHASE9.md | 11 ++++ .../components/layouts/app/sidebar.blade.php | 3 ++ 14 files changed, 254 insertions(+) create mode 100644 Modules/Licensing/Http/Controllers/Admin/PaddleReconciliationController.php create mode 100644 Modules/Licensing/Http/Controllers/Customer/CustomerLicensingController.php create mode 100644 Modules/Licensing/Resources/views/admin/paddle-reconciliation.blade.php create mode 100644 Modules/Licensing/Resources/views/customer/index.blade.php create mode 100644 Modules/Licensing/Resources/views/customer/layout.blade.php create mode 100644 Modules/Licensing/Resources/views/customer/show.blade.php create mode 100644 Modules/Licensing/Routes/customer.php create mode 100644 Modules/Licensing/Services/PaddleReadOnlyClient.php create mode 100644 config/sonardesk-paddle.php create mode 100644 integration/PHASE10.md create mode 100644 integration/PHASE9.md diff --git a/Modules/Licensing/Http/Controllers/Admin/PaddleReconciliationController.php b/Modules/Licensing/Http/Controllers/Admin/PaddleReconciliationController.php new file mode 100644 index 0000000..e7929cb --- /dev/null +++ b/Modules/Licensing/Http/Controllers/Admin/PaddleReconciliationController.php @@ -0,0 +1,51 @@ +where('payment_provider', 'paddle') + ->whereNotNull('external_subscription_id') + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + foreach ($licenses as $license) { + $result = [ + 'license' => $license, + 'remote_status' => null, + 'result' => $configured ? 'unknown' : 'not configured', + 'detail' => null, + ]; + if ($configured) { + try { + $subscription = $paddle->subscription((string) $license->external_subscription_id); + $result['remote_status'] = (string) ($subscription['status'] ?? 'unknown'); + $result['result'] = $result['remote_status'] === (string) $license->subscription_status + ? 'matched' : 'different'; + } catch (Throwable $e) { + report($e); + $result['result'] = 'lookup failed'; + $result['detail'] = $e->getMessage(); + } + } + $rows[] = $result; + } + + return view('licensing::admin.paddle-reconciliation', compact('rows', 'configured', 'environment')); + } +} diff --git a/Modules/Licensing/Http/Controllers/Customer/CustomerLicensingController.php b/Modules/Licensing/Http/Controllers/Customer/CustomerLicensingController.php new file mode 100644 index 0000000..ef85ccf --- /dev/null +++ b/Modules/Licensing/Http/Controllers/Customer/CustomerLicensingController.php @@ -0,0 +1,40 @@ +user()->email)); + + $licenses = License::query() + ->with('product') + ->whereRaw('LOWER(customer_email) = ?', [$email]) + ->latest() + ->paginate(15); + + return view('licensing::customer.index', compact('licenses')); + } + + public function show(Request $request, License $license): View + { + // Return 404, not 403, to avoid disclosing whether a licence exists. + abort_unless( + hash_equals( + mb_strtolower(trim((string) $license->customer_email)), + mb_strtolower(trim((string) $request->user()->email)) + ), + 404 + ); + + $license->load(['product', 'activations']); + + return view('licensing::customer.show', compact('license')); + } +} diff --git a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php index 65e02df..98f7054 100644 --- a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php +++ b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php @@ -20,6 +20,7 @@ final class IntegratedLicensingServiceProvider extends ServiceProvider Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), ]); + $this->mergeConfigFrom(base_path('config/sonardesk-paddle.php'), 'sonardesk-paddle'); $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing'); $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing'); @@ -31,5 +32,6 @@ final class IntegratedLicensingServiceProvider extends ServiceProvider Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php'); Route::group([], __DIR__.'/../Routes/admin.php'); + Route::group([], __DIR__.'/../Routes/customer.php'); } } diff --git a/Modules/Licensing/Resources/views/admin/paddle-reconciliation.blade.php b/Modules/Licensing/Resources/views/admin/paddle-reconciliation.blade.php new file mode 100644 index 0000000..d0ea378 --- /dev/null +++ b/Modules/Licensing/Resources/views/admin/paddle-reconciliation.blade.php @@ -0,0 +1,22 @@ +@extends('licensing::admin.layout') +@section('title', 'Paddle Reconciliation') +@section('content') +

Paddle Reconciliation

+

Read-only comparison of the 20 most recent Paddle-linked licences against Paddle {{ $environment }}. This page never changes subscriptions, licences or webhooks.

+@if (! $configured) +
Paddle API not configured.

Set SONARDESK_PADDLE_API_KEY and SONARDESK_PADDLE_ENVIRONMENT in your local environment, then clear configuration cache. No API calls were made.

+@endif +
+@forelse ($rows as $row) + + + + + + + +@empty + +@endforelse +
LicenceSubscriptionLocal statusPaddle statusComparison
{{ $row['license']->license_key_last4 ?? $row['license']->id }}{{ $row['license']->external_subscription_id }}{{ $row['license']->subscription_status ?? '—' }}{{ $row['remote_status'] ?? '—' }}{{ $row['result'] }}@if ($row['detail']) {{ $row['detail'] }} @endif
No Paddle-linked licences found in the local database.
+@endsection diff --git a/Modules/Licensing/Resources/views/customer/index.blade.php b/Modules/Licensing/Resources/views/customer/index.blade.php new file mode 100644 index 0000000..b9ccf47 --- /dev/null +++ b/Modules/Licensing/Resources/views/customer/index.blade.php @@ -0,0 +1,14 @@ +@extends('licensing::customer.layout') +@section('title', 'My SonarDesk Licences') +@section('content') +

My SonarDesk licences

+

Licences associated with your verified ABSDev account email. This portal is read-only; existing billing and licence activation services remain unchanged.

+
+@forelse($licenses as $license) + +@empty + +@endforelse +
EditionLicenceSubscriptionExpiry
{{ $license->edition }}•••• {{ $license->license_key_last4 ?? '—' }}{{ $license->subscription_status ?? 'Not linked' }}{{ $license->expires_at?->format('d M Y') ?? '—' }}View details
No SonarDesk licences match your account email. If you purchased with a different email, contact support to verify ownership; licences cannot be claimed automatically.
+{{ $licenses->links() }} +@endsection diff --git a/Modules/Licensing/Resources/views/customer/layout.blade.php b/Modules/Licensing/Resources/views/customer/layout.blade.php new file mode 100644 index 0000000..457eae3 --- /dev/null +++ b/Modules/Licensing/Resources/views/customer/layout.blade.php @@ -0,0 +1,7 @@ + + +@yield('title', 'SonarDesk Account') · ABSDev +
ABSDev · SonarDesk   My licences
+
@yield('content')
diff --git a/Modules/Licensing/Resources/views/customer/show.blade.php b/Modules/Licensing/Resources/views/customer/show.blade.php new file mode 100644 index 0000000..625705d --- /dev/null +++ b/Modules/Licensing/Resources/views/customer/show.blade.php @@ -0,0 +1,12 @@ +@extends('licensing::customer.layout') +@section('title', 'Licence Details') +@section('content') +

← My licences

+

SonarDesk {{ $license->edition }}

+

Licence

Key ending: {{ $license->license_key_last4 ?? '—' }}

Status: {{ $license->revoked_at ? 'Revoked' : ($license->is_usable ? 'Usable' : 'Expired') }}

Subscription: {{ $license->subscription_status ?? 'Not linked' }}

Current period ends: {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}

Activation limit: {{ $license->activation_limit }}

Full licence keys are not displayed or recoverable from their stored hashes.

+

Devices

+@forelse($license->activations as $activation) + +@empty@endforelse +
InstallationStatus
{{ $activation->installation_id ?? $activation->installationID ?? 'Device' }}{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}
No activations recorded.
+@endsection diff --git a/Modules/Licensing/Routes/admin.php b/Modules/Licensing/Routes/admin.php index 5746753..b32ffeb 100644 --- a/Modules/Licensing/Routes/admin.php +++ b/Modules/Licensing/Routes/admin.php @@ -11,6 +11,7 @@ Route::middleware(['web', 'auth', 'verified', 'can:manage-sonardesk-licensing']) 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('/paddle-reconciliation', [\Modules\Licensing\Http\Controllers\Admin\PaddleReconciliationController::class, 'index'])->name('paddle-reconciliation'); Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions'); Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show'); }); diff --git a/Modules/Licensing/Routes/customer.php b/Modules/Licensing/Routes/customer.php new file mode 100644 index 0000000..8519dbb --- /dev/null +++ b/Modules/Licensing/Routes/customer.php @@ -0,0 +1,13 @@ +prefix('account/sonardesk') + ->name('account.sonardesk.') + ->group(function (): void { + Route::get('/', [CustomerLicensingController::class, 'index'])->name('index'); + Route::get('/licenses/{license}', [CustomerLicensingController::class, 'show'])->name('licenses.show'); + }); diff --git a/Modules/Licensing/Services/PaddleReadOnlyClient.php b/Modules/Licensing/Services/PaddleReadOnlyClient.php new file mode 100644 index 0000000..e64c34a --- /dev/null +++ b/Modules/Licensing/Services/PaddleReadOnlyClient.php @@ -0,0 +1,49 @@ +withToken($token) + ->acceptJson() + ->timeout(8) + ->retry(2, 200, throw: false) + ->get('/subscriptions/'.$id); + + if (! $response->successful()) { + throw new RuntimeException('Paddle lookup failed (HTTP '.$response->status().').'); + } + + $data = $response->json('data'); + if (! is_array($data)) { + throw new RuntimeException('Unexpected Paddle response.'); + } + + return $data; + } +} diff --git a/config/sonardesk-paddle.php b/config/sonardesk-paddle.php new file mode 100644 index 0000000..7502c4d --- /dev/null +++ b/config/sonardesk-paddle.php @@ -0,0 +1,6 @@ + env('SONARDESK_PADDLE_API_KEY', ''), + 'environment' => env('SONARDESK_PADDLE_ENVIRONMENT', 'sandbox'), +]; diff --git a/integration/PHASE10.md b/integration/PHASE10.md new file mode 100644 index 0000000..048d6bf --- /dev/null +++ b/integration/PHASE10.md @@ -0,0 +1,23 @@ +# Phase 10 — Read-only customer licence portal + +## Install +Extract this archive at the ABSDev project root, preserving paths. Merge `IntegratedLicensingServiceProvider.php` if locally customised. Do not replace the production licensing application. + +```sh +composer dump-autoload +php artisan optimize:clear +php artisan route:list --path=account/sonardesk -v +``` + +Visit `/account/sonardesk` while signed in with a **verified** ABSDev account. Licences are matched to `customer_email` case-insensitively; no cross-application SSO or automatic account linking is claimed. + +## Security and scope +- Authenticated and verified users only, rate-limited. +- Ownership checked on each licence detail request, returning 404 for non-owners. +- No subscription cancellation, upgrade, activation changes, key retrieval or checkout. +- No new migrations, secrets or third-party API calls. +- Existing Paddle billing, licensing APIs, admin routes and webhooks are untouched. +- Test with one owner, one unrelated user and an unverified user before release. + +## Phase 11 +Downloads, releases and update feeds. diff --git a/integration/PHASE9.md b/integration/PHASE9.md new file mode 100644 index 0000000..f6ce94a --- /dev/null +++ b/integration/PHASE9.md @@ -0,0 +1,11 @@ +# Phase 9 — Paddle read-only reconciliation + +This is the **first Paddle API integration step**, not a checkout or webhook migration. Existing production billing and webhook endpoints remain untouched. + +## Install +Copy the patch files into the ABSDev project, preserving paths. Configure `SONARDESK_PADDLE_API_KEY` with a read-only Paddle API key and `SONARDESK_PADDLE_ENVIRONMENT=sandbox` (or `production`) in `.env`. **Never copy production credentials into source control.** Run `composer dump-autoload`, `php artisan optimize:clear`, and `php artisan route:list --path=administration/sonardesk -v`. + +Open `/administration/sonardesk/paddle-reconciliation`. Page checks up to 20 recent Paddle-linked licence statuses. It is deliberately limited to avoid unbounded API calls and is read-only. + +## Not included +Checkout creation, customer identity linking, payment processing, webhook cutover, subscription mutation, and production deployment. These require migration tests and explicit cutover approval. 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 4904563..cdbe35d 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -53,6 +53,9 @@ @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.billing-audit')) {{ __('Billing Audit') }} @endif + @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.paddle-reconciliation')) + {{ __('Paddle Reconciliation') }} + @endif @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.subscriptions')) {{ __('Subscriptions') }} @endif