move into sonardesk
This commit is contained in:
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Phase 8 — Billing integrity audit (read-only)
|
||||
|
||||
This package adds **Billing Audit** to the SonarDesk administration sidebar at `/administration/sonardesk/billing-audit`.
|
||||
|
||||
## Included
|
||||
- Subscription-linked licence completeness counts.
|
||||
- Duplicate subscription identifiers (informational, not automatically errors).
|
||||
- Duplicate webhook event identifiers.
|
||||
- Unprocessed and failed webhook event counts, with a bounded list of recent errors.
|
||||
- Same authentication, email verification and `manage-sonardesk-licensing` Gate as the existing pages.
|
||||
|
||||
## Installation
|
||||
1. Commit/back up local modifications. Overlay the paths in this ZIP onto the ABSDev root. Merge the full sidebar Blade file if you have changed it since Phase 7.
|
||||
2. Run `composer dump-autoload`, `php artisan optimize:clear`, and `php artisan route:list --path=administration/sonardesk -v`.
|
||||
3. Sign in as an authorised administrator and open `/administration/sonardesk/billing-audit`.
|
||||
4. Confirm local `licenses` and `license_webhook_events` tables exist from earlier licensing migrations.
|
||||
|
||||
## Explicit limitations
|
||||
This is **not** a live Paddle connection or completed commerce integration. It does not contact Paddle, handle checkout, reconcile remote records, process webhooks, change customer identities, or alter billing. Existing production payment endpoints must remain unchanged.
|
||||
|
||||
## Follow-on work
|
||||
Phase 9: staged Paddle checkout/webhook parity and idempotency tests. Phase 10: customer identity/account linking. Phase 11: downloads/releases and update feeds. Phase 12: unified frontend, end-to-end regression and controlled deployment.
|
||||
@@ -50,6 +50,9 @@
|
||||
<flux:sidebar.group :heading="__('SonarDesk')" expandable :expanded="request()->routeIs('administration.sonardesk.*')" class="grid">
|
||||
<flux:sidebar.item icon="squares-2x2" :href="route('administration.sonardesk.index')" :current="request()->routeIs('administration.sonardesk.index')">{{ __('Overview') }}</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="key" :href="route('administration.sonardesk.licenses')" :current="request()->routeIs('administration.sonardesk.licenses', 'administration.sonardesk.licenses.show')">{{ __('Licences & Activations') }}</flux:sidebar.item>
|
||||
@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.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