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
+| Licence | Subscription | Local status | Paddle status | Comparison |
+@forelse ($rows as $row)
+
+| {{ $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 |
+
+@empty
+| No Paddle-linked licences found in the local database. |
+@endforelse
+
+@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.
+
+{{ $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
+
+@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.
+
+@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