move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 15:08:42 +01:00
parent 38b4a7406f
commit ef9903b7b7
5 changed files with 102 additions and 0 deletions
@@ -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']),
]);
}
}
@@ -0,0 +1,34 @@
@extends('licensing::admin.layout')
@section('title', 'SonarDesk Billing Audit')
@section('content')
<h1>Billing Audit</h1>
<p class="muted">Local database checks only. No Paddle API requests, billing changes, webhook replays or licence mutations are performed.</p>
<div class="cards">
<div class="card"><div class="muted">Subscription-linked licences</div><div class="metric">{{ number_format($linkedCount) }}</div></div>
<div class="card"><div class="muted">Missing payment provider</div><div class="metric">{{ number_format($missingProvider) }}</div></div>
<div class="card"><div class="muted">Missing customer ID</div><div class="metric">{{ number_format($missingCustomer) }}</div></div>
<div class="card"><div class="muted">Missing subscription status</div><div class="metric">{{ number_format($missingStatus) }}</div></div>
<div class="card"><div class="muted">Webhook events stored</div><div class="metric">{{ number_format($eventCount) }}</div></div>
<div class="card"><div class="muted">Unprocessed events</div><div class="metric">{{ number_format($unprocessedEvents) }}</div></div>
<div class="card"><div class="muted">Events with errors</div><div class="metric">{{ number_format($failedEvents) }}</div></div>
</div>
<h2>Duplicate subscription identifiers (first 25)</h2>
<p class="muted">Multiple licences can legitimately share a subscription. Review these before deciding whether a record is incorrect.</p>
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Subscription ID</th><th>Licences</th></tr></thead><tbody>
@forelse ($duplicateSubscriptions as $row)
<tr><td>{{ $row->payment_provider ?? '—' }}</td><td>{{ $row->external_subscription_id }}</td><td>{{ $row->license_count }}</td></tr>
@empty<tr><td colspan="3">No repeated subscription identifiers.</td></tr>@endforelse
</tbody></table></div>
<h2>Duplicate webhook event identifiers (first 25)</h2>
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Event ID</th><th>Rows</th></tr></thead><tbody>
@forelse ($duplicateEvents as $row)
<tr><td>{{ $row->provider }}</td><td>{{ $row->external_event_id }}</td><td>{{ $row->event_count }}</td></tr>
@empty<tr><td colspan="3">No duplicate event identifiers.</td></tr>@endforelse
</tbody></table></div>
<h2>Recent processing errors</h2>
<div class="table-wrap"><table><thead><tr><th>When</th><th>Provider</th><th>Event type</th><th>Event ID</th><th>Error</th></tr></thead><tbody>
@forelse ($recentFailures as $event)
<tr><td>{{ $event->created_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $event->provider }}</td><td>{{ $event->event_type }}</td><td>{{ $event->external_event_id }}</td><td>{{ \Illuminate\Support\Str::limit($event->processing_error, 240) }}</td></tr>
@empty<tr><td colspan="5">No stored processing errors.</td></tr>@endforelse
</tbody></table></div>
@endsection
+1
View File
@@ -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');
});