move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 15:18:07 +01:00
parent ef9903b7b7
commit a88d336991
14 changed files with 254 additions and 0 deletions
@@ -0,0 +1,51 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Routing\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Services\PaddleReadOnlyClient;
use Throwable;
final class PaddleReconciliationController extends Controller
{
public function index(PaddleReadOnlyClient $paddle): View
{
$configured = trim((string) config('sonardesk-paddle.api_key')) !== '';
$environment = config('sonardesk-paddle.environment', 'sandbox');
$rows = [];
// A bounded, explicit read-only comparison; no local changes or Paddle writes.
$licenses = License::query()
->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'));
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Licensing\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Licensing\Models\License;
final class CustomerLicensingController extends Controller
{
public function index(Request $request): View
{
$email = mb_strtolower(trim((string) $request->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'));
}
}
@@ -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');
}
}
@@ -0,0 +1,22 @@
@extends('licensing::admin.layout')
@section('title', 'Paddle Reconciliation')
@section('content')
<h1>Paddle Reconciliation</h1>
<p class="muted">Read-only comparison of the 20 most recent Paddle-linked licences against Paddle {{ $environment }}. This page never changes subscriptions, licences or webhooks.</p>
@if (! $configured)
<div class="card"><strong>Paddle API not configured.</strong><p>Set <code>SONARDESK_PADDLE_API_KEY</code> and <code>SONARDESK_PADDLE_ENVIRONMENT</code> in your local environment, then clear configuration cache. No API calls were made.</p></div>
@endif
<div class="table-wrap"><table><thead><tr><th>Licence</th><th>Subscription</th><th>Local status</th><th>Paddle status</th><th>Comparison</th></tr></thead><tbody>
@forelse ($rows as $row)
<tr>
<td><a href="{{ route('administration.sonardesk.licenses.show', $row['license']) }}">{{ $row['license']->license_key_last4 ?? $row['license']->id }}</a></td>
<td>{{ $row['license']->external_subscription_id }}</td>
<td>{{ $row['license']->subscription_status ?? '—' }}</td>
<td>{{ $row['remote_status'] ?? '—' }}</td>
<td><span class="pill">{{ $row['result'] }}</span>@if ($row['detail']) <span class="muted">{{ $row['detail'] }}</span> @endif</td>
</tr>
@empty
<tr><td colspan="5">No Paddle-linked licences found in the local database.</td></tr>
@endforelse
</tbody></table></div>
@endsection
@@ -0,0 +1,14 @@
@extends('licensing::customer.layout')
@section('title', 'My SonarDesk Licences')
@section('content')
<h1>My SonarDesk licences</h1>
<p class="muted">Licences associated with your verified ABSDev account email. This portal is read-only; existing billing and licence activation services remain unchanged.</p>
<div class="card scroll"><table><thead><tr><th>Edition</th><th>Licence</th><th>Subscription</th><th>Expiry</th><th></th></tr></thead><tbody>
@forelse($licenses as $license)
<tr><td><strong>{{ $license->edition }}</strong></td><td>•••• {{ $license->license_key_last4 ?? '—' }}</td><td><span class="pill">{{ $license->subscription_status ?? 'Not linked' }}</span></td><td>{{ $license->expires_at?->format('d M Y') ?? '—' }}</td><td><a href="{{ route('account.sonardesk.licenses.show', $license) }}">View details</a></td></tr>
@empty
<tr><td colspan="5">No SonarDesk licences match your account email. If you purchased with a different email, contact support to verify ownership; licences cannot be claimed automatically.</td></tr>
@endforelse
</tbody></table></div>
{{ $licenses->links() }}
@endsection
@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'SonarDesk Account') · ABSDev</title>
<style>
:root{color-scheme:light dark;font-family:system-ui,-apple-system,sans-serif}body{margin:0;background:#f5f7fb;color:#19253a}header{background:#112440;color:#fff;padding:18px max(20px,calc((100vw - 1100px)/2))}header a{color:#e8c36b;text-decoration:none}main{max-width:1100px;margin:35px auto;padding:0 20px}.card{background:#fff;border:1px solid #dbe2ee;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 4px 16px #10233d0a}a{color:#19579b}table{border-collapse:collapse;width:100%}th,td{padding:12px;text-align:left;border-bottom:1px solid #e5eaf2}th{color:#53627b}small,.muted{color:#59677e}.scroll{overflow-x:auto}.pill{border-radius:99px;background:#eef3fa;padding:5px 10px;display:inline-block}h1{font-size:1.8rem}@media(prefers-color-scheme:dark){body{background:#0e1725;color:#e9eef8}.card{background:#18263b;border-color:#33435a}th,td{border-color:#33435a}th,.muted,small{color:#bdc7d8}.pill{background:#33435a}a{color:#a8cfff}}
</style></head><body><header><strong>ABSDev · SonarDesk</strong> &nbsp; <a href="{{ route('account.sonardesk.index') }}">My licences</a></header>
<main>@yield('content')</main></body></html>
@@ -0,0 +1,12 @@
@extends('licensing::customer.layout')
@section('title', 'Licence Details')
@section('content')
<p><a href="{{ route('account.sonardesk.index') }}">← My licences</a></p>
<h1>SonarDesk {{ $license->edition }}</h1>
<div class="card"><h2>Licence</h2><p><strong>Key ending:</strong> {{ $license->license_key_last4 ?? '—' }}</p><p><strong>Status:</strong> {{ $license->revoked_at ? 'Revoked' : ($license->is_usable ? 'Usable' : 'Expired') }}</p><p><strong>Subscription:</strong> {{ $license->subscription_status ?? 'Not linked' }}</p><p><strong>Current period ends:</strong> {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}</p><p><strong>Activation limit:</strong> {{ $license->activation_limit }}</p><p class="muted">Full licence keys are not displayed or recoverable from their stored hashes.</p></div>
<div class="card scroll"><h2>Devices</h2><table><thead><tr><th>Installation</th><th>Status</th></tr></thead><tbody>
@forelse($license->activations as $activation)
<tr><td>{{ $activation->installation_id ?? $activation->installationID ?? 'Device' }}</td><td>{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}</td></tr>
@empty<tr><td colspan="2">No activations recorded.</td></tr>@endforelse
</tbody></table></div>
@endsection
+1
View File
@@ -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');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Customer\CustomerLicensingController;
// Read-only customer portal. No licence claiming by ID, no billing mutations.
Route::middleware(['web', 'auth', 'verified', 'throttle:30,1'])
->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');
});
@@ -0,0 +1,49 @@
<?php
namespace Modules\Licensing\Services;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/** Read-only Paddle Billing API access; no writes or webhook registration. */
final class PaddleReadOnlyClient
{
public function subscription(string $id): array
{
if (! preg_match('/^sub_[a-zA-Z0-9]+$/', $id)) {
throw new RuntimeException('Invalid Paddle subscription identifier.');
}
$token = trim((string) config('sonardesk-paddle.api_key'));
if ($token === '') {
throw new RuntimeException('SONARDESK_PADDLE_API_KEY is not configured.');
}
$environment = config('sonardesk-paddle.environment', 'sandbox');
if (! in_array($environment, ['sandbox', 'production'], true)) {
throw new RuntimeException('Invalid Paddle environment configuration.');
}
$url = $environment === 'production'
? 'https://api.paddle.com'
: 'https://sandbox-api.paddle.com';
$response = Http::baseUrl($url)
->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;
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_key' => env('SONARDESK_PADDLE_API_KEY', ''),
'environment' => env('SONARDESK_PADDLE_ENVIRONMENT', 'sandbox'),
];
+23
View File
@@ -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.
+11
View File
@@ -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.
@@ -53,6 +53,9 @@
@if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.billing-audit'))
<flux:sidebar.item icon="clipboard-document-check" :href="route('administration.sonardesk.billing-audit')" :current="request()->routeIs('administration.sonardesk.billing-audit')">{{ __('Billing Audit') }}</flux:sidebar.item>
@endif
@if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.paddle-reconciliation'))
<flux:sidebar.item icon="arrow-path" :href="route('administration.sonardesk.paddle-reconciliation')" :current="request()->routeIs('administration.sonardesk.paddle-reconciliation')">{{ __('Paddle Reconciliation') }}</flux:sidebar.item>
@endif
@if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.subscriptions'))
<flux:sidebar.item icon="credit-card" :href="route('administration.sonardesk.subscriptions')" :current="request()->routeIs('administration.sonardesk.subscriptions')">{{ __('Subscriptions') }}</flux:sidebar.item>
@endif