recordTitleAttribute('amount') ->columns([ TextColumn::make('expected_at') ->label('Date') ->date('d M Y') ->description(fn ($record) => $record->expected_at?->diffForHumans()) ->sortable(), TextColumn::make('amount') ->label('Amount') ->money('gbp', divideBy: 100) ->sortable(), IconColumn::make('is_paid') ->label('Status') ->icon(fn ($state, $record) => match (true) { (bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'heroicon-o-forward', (bool) $state && (bool) $record->stripe_payment_intent_id => 'heroicon-o-check-circle', (bool) $state => 'heroicon-o-check', $record->expected_at?->isPast() ?? false => 'heroicon-o-exclamation-circle', default => 'heroicon-o-clock', }) ->color(fn ($state, $record) => match (true) { (bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'warning', (bool) $state => 'success', $record->expected_at?->isPast() ?? false => 'danger', default => 'gray', }) ->tooltip(fn ($state, $record) => match (true) { (bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'Skipped by admin', (bool) $state && (bool) $record->stripe_payment_intent_id => 'Paid — ' . $record->stripe_payment_intent_id, (bool) $state => 'Marked as paid (no Stripe ref)', $record->expected_at?->isPast() ?? false => 'Overdue — not yet charged', default => 'Upcoming', }), TextColumn::make('attempts') ->label('Tries') ->badge() ->color(fn ($state) => match (true) { $state === 0 || $state === null => 'gray', $state === 1 => 'success', default => 'warning', }), TextColumn::make('stripe_payment_intent_id') ->label('Stripe') ->url(fn ($state) => $state && $state !== 'SKIPPED' ? "https://dashboard.stripe.com/payments/{$state}" : null) ->openUrlInNewTab() ->placeholder('—') ->limit(18) ->fontFamily('mono') ->copyable(), ]) ->defaultSort('expected_at', 'asc') // ── Row Actions ───────────────────────────────────── ->actions([ // Charge now Action::make('take_payment') ->label('Charge') ->icon('heroicon-o-banknotes') ->color('success') ->size('sm') ->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id) ->requiresConfirmation() ->modalHeading('Charge Payment') ->modalDescription(fn ($record) => 'Charge £' . number_format($record->amount / 100, 2) . ' (expected ' . $record->expected_at?->format('d M Y') . ')') ->action(function (ScheduledGivingPayment $record) { try { (new ProcessScheduleGivingDonationPayment($record))->handle(); (new AppealScheduledDonationService())->syncProcessedPayments(collect([$record])); Notification::make() ->title('Payment charged') ->body('£' . number_format($record->amount / 100, 2) . ' collected') ->success() ->send(); } catch (\Throwable $e) { Notification::make() ->title('Payment failed') ->body($e->getMessage()) ->danger() ->send(); } }), // Refund Action::make('refund') ->label('Refund') ->icon('heroicon-o-arrow-uturn-left') ->color('danger') ->size('sm') ->visible(fn ($record) => $record->is_paid && $record->stripe_payment_intent_id && $record->stripe_payment_intent_id !== 'SKIPPED') ->requiresConfirmation() ->modalHeading('Refund Payment') ->modalDescription(fn ($record) => 'Refund £' . number_format($record->amount / 100, 2) . ' for ' . $record->expected_at?->format('d M Y') . ' back to donor\'s card via Stripe.') ->form([ TextInput::make('refund_amount') ->label('Refund amount (£)') ->numeric() ->default(fn ($record) => number_format($record->amount / 100, 2, '.', '')) ->required() ->minValue(0.01) ->maxValue(fn ($record) => $record->amount / 100) ->step(0.01) ->helperText('Full amount for complete refund, or reduce for partial.'), ]) ->action(function (ScheduledGivingPayment $record, array $data) { $amountPence = (int) round($data['refund_amount'] * 100); $isPartial = $amountPence < $record->amount; $service = app(StripeRefundService::class); $result = $service->refundPaymentIntent( $record->stripe_payment_intent_id, $isPartial ? $amountPence : null, 'Admin refund: scheduled payment #' . $record->id ); if ($result['success']) { if (! $isPartial) { $record->update(['is_paid' => false]); } Notification::make() ->title('Payment refunded') ->body('£' . number_format($result['amount'] / 100, 2) . ' refunded. ID: ' . $result['refund_id']) ->success() ->send(); } else { Notification::make() ->title('Refund failed') ->body($result['error']) ->danger() ->send(); } }), // Skip Action::make('skip') ->label('Skip') ->icon('heroicon-o-forward') ->color('warning') ->size('sm') ->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id) ->requiresConfirmation() ->modalHeading('Skip Payment') ->modalDescription(fn ($record) => 'Mark the £' . number_format($record->amount / 100, 2) . ' payment for ' . $record->expected_at?->format('d M Y') . ' as skipped. No charge will be made.') ->action(function (ScheduledGivingPayment $record) { $record->update([ 'is_paid' => true, 'stripe_payment_intent_id' => 'SKIPPED', ]); Notification::make()->title('Payment skipped')->warning()->send(); }), ]) // ── Header Actions ────────────────────────────────── ->headerActions([ Action::make('generate_payments') ->label('Generate Payments') ->icon('heroicon-o-calculator') ->visible(function () { return $this->ownerRecord->payments()->count() == 0 && $this->ownerRecord->isConfirmed(); }) ->requiresConfirmation() ->action(fn () => $this->ownerRecord->generatePayments()), ]) // ── Bulk Actions ──────────────────────────────────── ->bulkActions([ BulkActionGroup::make([ BulkAction::make('refund_selected') ->label('Refund Selected') ->icon('heroicon-o-arrow-uturn-left') ->color('danger') ->requiresConfirmation() ->modalHeading('Refund Selected Payments') ->modalDescription('Refund all selected paid payments via Stripe. This cannot be undone.') ->action(function (Collection $records) { $service = app(StripeRefundService::class); $refunded = 0; $failed = 0; foreach ($records as $record) { if (! $record->is_paid || ! $record->stripe_payment_intent_id || $record->stripe_payment_intent_id === 'SKIPPED') { continue; } $result = $service->refundPaymentIntent( $record->stripe_payment_intent_id, null, 'Bulk refund: payment #' . $record->id ); if ($result['success']) { $record->update(['is_paid' => false]); $refunded++; } else { $failed++; } } Notification::make() ->title($refunded . ' payments refunded' . ($failed ? ", {$failed} failed" : '')) ->success() ->send(); }), BulkAction::make('charge_selected') ->label('Charge Selected') ->icon('heroicon-o-banknotes') ->color('success') ->requiresConfirmation() ->modalHeading('Charge Selected Payments') ->modalDescription('Process all selected unpaid payments now.') ->action(function (Collection $records) { $charged = 0; $failed = 0; foreach ($records as $record) { if ($record->is_paid || $record->stripe_payment_intent_id) { continue; } try { (new ProcessScheduleGivingDonationPayment($record))->handle(); (new AppealScheduledDonationService())->syncProcessedPayments(collect([$record])); $charged++; } catch (\Throwable $e) { $failed++; } } Notification::make() ->title($charged . ' payments charged' . ($failed ? ", {$failed} failed" : '')) ->success() ->send(); }), ]), ]); } }