367 lines
18 KiB
PHP
367 lines
18 KiB
PHP
<?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';
|
|
}
|
|
}
|