Add Paddle health ops metrics

This commit is contained in:
Codex Agent
2026-01-02 17:03:36 +01:00
parent 8f13465415
commit bc99929040
9 changed files with 524 additions and 3 deletions

View File

@@ -1 +1 @@
fotospiel-app-097
fotospiel-app-kxe

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantPaddleHealths extends ListRecords
{
protected static string $resource = TenantPaddleHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,366 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class TenantPaddleHealthTable
{
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.common.tenant'))
->searchable()
->sortable(),
TextColumn::make('slug')
->label(__('admin.common.slug'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('contact_email')
->label(__('admin.tenants.fields.contact_email'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id')
->label('Paddle customer')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'),
TextColumn::make('subscription_status')
->label('Subscription')
->badge()
->color(fn (?string $state) => match ($state) {
'active' => 'success',
'suspended' => 'warning',
'expired' => 'danger',
'free' => 'gray',
default => 'gray',
}),
TextColumn::make('active_reseller_package')
->label('Active package')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->name ?? '—')
->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id')
->label('Paddle subscription')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
IconColumn::make('status_mismatch')
->label('Status mismatch')
->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('paddle_customer_duplicates')
->label('Paddle duplicates')
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status')
->label('Paddle sync')
->badge()
->color(fn (?string $state) => match ($state) {
'synced' => 'success',
'syncing' => 'warning',
'pulled' => 'info',
'dry-run' => 'gray',
'failed', 'pull-failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at')
->label('Paddle synced')
->badge()
->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
TextColumn::make('last_paddle_transaction_at')
->label('Last Paddle tx')
->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
? Carbon::parse($record->last_paddle_transaction_at)
: null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count_window')
->label('Paddle tx (30d)')
->default('0')
->sortable()
->toggleable(),
TextColumn::make('paddle_transaction_total_window')
->label('Paddle total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(),
TextColumn::make('paddle_refund_count_window')
->label('Refunds (30d)')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_refund_total_window')
->label('Refund total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_requires_action_count')
->label('Checkout action required')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_processing_count')
->label('Checkout processing')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_expired_count')
->label('Checkout expired')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count')
->label('Paddle tx (all)')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_total')
->label('Paddle total (all)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Filter::make('missing_paddle_customer')
->label('Missing Paddle customer')
->indicator('Missing Paddle customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
Filter::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->indicator('Missing Paddle subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true)
->whereNull('paddle_subscription_id'))),
Filter::make('duplicate_paddle_customer')
->label('Duplicate Paddle customer')
->indicator('Duplicate Paddle customer')
->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id')
->whereIn('paddle_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id')
->from('tenants')
->whereNotNull('paddle_customer_id')
->groupBy('paddle_customer_id')
->havingRaw('count(*) > 1');
})),
Filter::make('status_mismatch')
->label('Status mismatch')
->indicator('Status mismatch')
->query(fn (Builder $query) => self::applyStatusMismatchFilter($query)),
Filter::make('active_package')
->label('Active package')
->indicator('Active package')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', function (Builder $query) {
$query->where('active', true)
->where(function (Builder $query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>=', now());
});
})),
Filter::make('not_suspended_or_deleted')
->label('Not suspended/deleted')
->indicator('Not suspended/deleted')
->query(fn (Builder $query) => $query
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at')),
Filter::make('paddle_sync_failed')
->label('Paddle sync failed')
->indicator('Paddle sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale')
->label('Paddle sync stale')
->indicator('Paddle sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing')
->label('Missing Paddle sync timestamp')
->indicator('Missing Paddle sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))),
Filter::make('paddle_transaction_stale')
->label('Stale Paddle transactions')
->indicator('Stale Paddle transactions')
->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
->whereDoesntHave('purchases', fn (Builder $query) => $query
->where('provider', 'paddle')
->where('purchased_at', '>=', $cutoff));
}),
Filter::make('checkout_attention')
->label('Checkout attention')
->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', 'paddle')
->where(function (Builder $query) {
$query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
CheckoutSession::STATUS_PROCESSING,
])
->orWhere(function (Builder $query) {
$query->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
])
->whereNotNull('expires_at')
->where('expires_at', '<', now());
});
});
})),
Filter::make('refund_spike')
->label('Refund spike (30d)')
->form([
TextInput::make('min_refunds')
->label('Minimum refunds')
->numeric()
->default(1)
->minValue(1),
])
->indicateUsing(function (array $data): ?string {
$min = (int) ($data['min_refunds'] ?? 0);
return $min > 0 ? "Refunds >= {$min} (30d)" : null;
})
->query(function (Builder $query, array $data): Builder {
$min = (int) ($data['min_refunds'] ?? 0);
if ($min < 1) {
return $query;
}
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min);
}),
SelectFilter::make('subscription_status')
->label('Subscription')
->options([
'active' => 'Active',
'suspended' => 'Suspended',
'expired' => 'Expired',
'free' => 'Free',
]),
])
->actions([]);
}
private static function hasStatusMismatch(Tenant $record): bool
{
$hasActivePackage = (bool) ($record->has_active_reseller_package ?? $record->activeResellerPackage);
$status = (string) ($record->subscription_status ?? '');
$expiresAt = $record->subscription_expires_at;
if ($status === 'active' && ! $hasActivePackage) {
return true;
}
if ($status !== 'active' && $hasActivePackage) {
return true;
}
if ($status === 'active' && $expiresAt && $expiresAt->isPast()) {
return true;
}
return false;
}
private static function missingPaddleSubscription(Tenant $record): bool
{
$package = $record->activeResellerPackage;
return $package && $package->active && ! $package->paddle_subscription_id;
}
private static function applyStatusMismatchFilter(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where(function (Builder $query) {
$query->where('subscription_status', 'active')
->whereDoesntHave('activeResellerPackage');
})->orWhere(function (Builder $query) {
$query->where('subscription_status', '!=', 'active')
->whereHas('activeResellerPackage');
})->orWhere(function (Builder $query) {
$query->where('subscription_status', 'active')
->whereNotNull('subscription_expires_at')
->where('subscription_expires_at', '<', now());
});
});
}
private static function syncAgeColor($state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
return 'danger';
}
return 'success';
}
private static function transactionAgeColor(?Carbon $state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger';
}
return 'success';
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantPaddleHealthResource extends Resource
{
public const STALE_SYNC_DAYS = 30;
public const TRANSACTION_WINDOW_DAYS = 30;
protected static ?string $model = Tenant::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'paddle-health';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return TenantPaddleHealthTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function getNavigationLabel(): string
{
return __('admin.paddle_health.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.billing');
}
public static function getEloquentQuery(): Builder
{
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
return parent::getEloquentQuery()
->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([
'paddle_customer_duplicates' => Tenant::query()
->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
->whereNotNull('paddle_customer_id'),
])
->withCount([
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
])
->whereNotNull('expires_at')
->where('expires_at', '<', now()),
])
->withSum([
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
], 'price')
->withSum([
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withSum([
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withMax([
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'),
], 'purchased_at');
}
public static function getPages(): array
{
return [
'index' => ListTenantPaddleHealths::route('/'),
];
}
}

View File

@@ -10,6 +10,7 @@ use App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Models\TenantAnnouncement;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -201,7 +202,7 @@ class TenantAnnouncementResource extends Resource
->options($audienceOptions),
])
->actions([
Tables\Actions\EditAction::make()
Actions\EditAction::make()
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
@@ -210,7 +211,7 @@ class TenantAnnouncementResource extends Resource
)),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make()
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);

View File

@@ -65,6 +65,11 @@ class Tenant extends Model
return $this->hasMany(PackagePurchase::class);
}
public function checkoutSessions(): HasMany
{
return $this->hasMany(CheckoutSession::class);
}
public function tenantPackages(): HasMany
{
return $this->hasMany(TenantPackage::class);

View File

@@ -183,6 +183,11 @@ return [
'deleted' => 'Gelöscht',
],
],
'paddle_health' => [
'navigation' => [
'label' => 'Paddle-Status',
],
],
'guest_policy' => [
'navigation' => [
'label' => 'Gast-Richtlinien',

View File

@@ -183,6 +183,11 @@ return [
'deleted' => 'Deleted',
],
],
'paddle_health' => [
'navigation' => [
'label' => 'Paddle health',
],
],
'guest_policy' => [
'navigation' => [
'label' => 'Guest policy',

View File

@@ -32,6 +32,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
\App\Filament\Resources\PackageResource::class => 'admin.nav.commercial',
\App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource::class => 'admin.nav.storage',
\App\Filament\Resources\PurchaseResource::class => 'admin.nav.billing',
\App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => 'admin.nav.billing',
\App\Filament\Resources\PurchaseHistoryResource::class => 'admin.nav.commercial',
\App\Filament\Resources\EventPurchaseResource::class => 'admin.nav.commercial',
\App\Filament\Resources\TenantPackageResource::class => 'admin.nav.commercial',
@@ -53,6 +54,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
\App\Filament\Resources\PhotoResource::class => DailyOpsCluster::class,
\App\Filament\Resources\TenantResource::class => DailyOpsCluster::class,
\App\Filament\Resources\PurchaseResource::class => DailyOpsCluster::class,
\App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => DailyOpsCluster::class,
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class,
\App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class,