diff --git a/.beads/last-touched b/.beads/last-touched index 2e7b9de..1889171 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-097 +fotospiel-app-kxe diff --git a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php b/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php new file mode 100644 index 0000000..e6f1896 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php @@ -0,0 +1,16 @@ +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'; + } +} diff --git a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php b/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php new file mode 100644 index 0000000..76208b0 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php @@ -0,0 +1,121 @@ +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('/'), + ]; + } +} diff --git a/app/Filament/Resources/TenantAnnouncementResource.php b/app/Filament/Resources/TenantAnnouncementResource.php index c05bd86..a1d15c3 100644 --- a/app/Filament/Resources/TenantAnnouncementResource.php +++ b/app/Filament/Resources/TenantAnnouncementResource.php @@ -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); diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index adb74e3..1858bc5 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -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); diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 3d500f8..db26738 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -183,6 +183,11 @@ return [ 'deleted' => 'Gelöscht', ], ], + 'paddle_health' => [ + 'navigation' => [ + 'label' => 'Paddle-Status', + ], + ], 'guest_policy' => [ 'navigation' => [ 'label' => 'Gast-Richtlinien', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 802b841..6389e07 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -183,6 +183,11 @@ return [ 'deleted' => 'Deleted', ], ], + 'paddle_health' => [ + 'navigation' => [ + 'label' => 'Paddle health', + ], + ], 'guest_policy' => [ 'navigation' => [ 'label' => 'Guest policy', diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index eb7717d..27f2621 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -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,