Files
fotospiel-app/app/Filament/Resources/PurchaseResource.php
Codex Agent 412ecbe691
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Implement superadmin audit log for mutations
2026-01-02 11:57:49 +01:00

291 lines
12 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase;
use App\Notifications\Customer\RefundReceipt;
use App\Notifications\Ops\RefundProcessed;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Paddle\PaddleTransactionService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\BadgeColumn;
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\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class PurchaseResource extends Resource
{
protected static ?string $model = PackagePurchase::class;
protected static ?string $cluster = DailyOpsCluster::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shopping-cart';
public static function getNavigationGroup(): string
{
return __('admin.nav.billing');
}
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Select::make('tenant_id')
->label('Tenant')
->relationship('tenant', 'name')
->searchable()
->preload()
->nullable(),
Select::make('event_id')
->label('Event')
->relationship('event', 'name')
->searchable()
->preload()
->nullable(),
Select::make('package_id')
->label('Package')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
TextInput::make('provider_id')
->label('Provider ID')
->required()
->maxLength(255),
TextInput::make('price')
->label('Price')
->numeric()
->step(0.01)
->prefix('€')
->required(),
Select::make('type')
->label('Type')
->options([
'endcustomer_event' => 'Endcustomer Event',
'reseller_subscription' => 'Reseller Subscription',
])
->required(),
Textarea::make('metadata')
->label('Metadata')
->json()
->columnSpanFull(),
Toggle::make('refunded')
->label('Refunded')
->default(false),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
BadgeColumn::make('type')
->label('Type')
->color(fn (string $state): string => match ($state) {
'endcustomer_event' => 'info',
'reseller_subscription' => 'success',
default => 'gray',
}),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('event.name')
->label('Event')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('package.name')
->label('Package')
->badge()
->color('success'),
TextColumn::make('price')
->label('Price')
->money('EUR')
->sortable(),
TextColumn::make('purchased_at')
->dateTime()
->sortable(),
BadgeColumn::make('refunded')
->label('Status')
->color(fn (bool $state): string => $state ? 'danger' : 'success'),
TextColumn::make('provider_id')
->copyable()
->toggleable(),
TextColumn::make('metadata.consents.legal_version')
->label('Legal Version')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.accepted_terms_at')
->label('Terms accepted')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.accepted_withdrawal_notice_at')
->label('Withdrawal notice')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.digital_content_waiver_at')
->label('Waiver (digital)')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('type')
->options([
'endcustomer_event' => 'Endcustomer Event',
'reseller_subscription' => 'Reseller Subscription',
]),
Filter::make('purchased_at')
->form([
DateTimePicker::make('started_from'),
DateTimePicker::make('ended_before'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['started_from'],
fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '>=', $date),
)
->when(
$data['ended_before'],
fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '<=', $date),
);
}),
SelectFilter::make('tenant_id')
->label('Tenant')
->relationship('tenant', 'name')
->searchable(),
])
->actions([
ViewAction::make(),
EditAction::make()
->after(fn (array $data, PackagePurchase $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Action::make('refund')
->label('Refund')
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
->form([
Textarea::make('reason')
->label('Refund reason (optional)')
->rows(2),
])
->action(function (PackagePurchase $record, array $data) {
$reason = $data['reason'] ?? null;
$refundSuccess = true;
$errorMessage = null;
if ($record->provider === 'paddle' && $record->provider_id) {
try {
/** @var PaddleTransactionService $paddle */
$paddle = App::make(PaddleTransactionService::class);
$paddle->refund($record->provider_id, ['reason' => $reason]);
} catch (\Throwable $exception) {
$refundSuccess = false;
$errorMessage = $exception->getMessage();
Log::warning('Paddle refund failed', [
'purchase_id' => $record->id,
'provider_id' => $record->provider_id,
'error' => $exception->getMessage(),
]);
}
}
$metadata = $record->metadata ?? [];
$metadata['refund_reason'] = $reason ?: ($metadata['refund_reason'] ?? null);
$record->update([
'refunded' => true,
'metadata' => $metadata,
]);
Log::info('Refund processed for purchase ID: '.$record->id, [
'provider' => $record->provider,
'provider_id' => $record->provider_id,
'reason' => $reason,
]);
$customerEmail = $record->tenant->contact_email ?? $record->tenant?->user?->email;
if ($customerEmail) {
Notification::route('mail', $customerEmail)->notify(new RefundReceipt($record, $reason));
}
$opsEmail = config('mail.ops_address');
if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
}
app(SuperAdminAuditLogger::class)->record(
'purchase.refunded',
$record,
SuperAdminAuditLogger::fieldsMetadata(['refunded', 'metadata']),
source: static::class
);
}),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]),
])
->emptyStateHeading('No Purchases Found')
->emptyStateDescription('Create your first purchase.');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPurchases::route('/'),
'create' => Pages\CreatePurchase::route('/create'),
'view' => Pages\ViewPurchase::route('/{record}'),
'edit' => Pages\EditPurchase::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
// Add RelationManagers if needed
];
}
}