move into sonardesk
This commit is contained in:
@@ -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> <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
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_key' => env('SONARDESK_PADDLE_API_KEY', ''),
|
||||
'environment' => env('SONARDESK_PADDLE_ENVIRONMENT', 'sandbox'),
|
||||
];
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user