Automations: - 2-column layout: WhatsApp phone LEFT, education RIGHT - Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips - Hero spans full width with photo+dark panel - Improvement CTA is a prominent card, not floating text - No misalignment — phone fills left column naturally Collect: - Appeals shown as visible gap-px grid cards (not hidden dropdown) - Each card shows name, platform, amount raised, pledge count, collection rate - Active appeal has border-l-2 blue indicator - Platform integration clarity: shows 'Donors redirected to JustGiving' etc - Educational section: 'Where to share your link' + 'How payment works' - Explains bank transfer vs JustGiving vs card payment inline AI model: Stripped all model name comments from code (no user-facing references existed)
301 lines
14 KiB
PHP
301 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\ScheduledGivingDonationResource\RelationManagers;
|
|
|
|
use App\Jobs\ProcessScheduleGivingDonationPayment;
|
|
use App\Models\ScheduledGivingPayment;
|
|
use App\Services\AppealScheduledDonationService;
|
|
use App\Services\StripeRefundService;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Form;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\RelationManagers\RelationManager;
|
|
use Filament\Tables\Actions\Action;
|
|
use Filament\Tables\Actions\BulkAction;
|
|
use Filament\Tables\Actions\BulkActionGroup;
|
|
use Filament\Tables\Columns\IconColumn;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
|
|
/**
|
|
* Payment schedule with per-row charge / refund / skip actions.
|
|
*
|
|
* Visual status:
|
|
* ✓ green = paid via Stripe
|
|
* ✓ gray = paid (skipped / no PI)
|
|
* ! red = overdue
|
|
* ○ gray = upcoming
|
|
*/
|
|
class ScheduledGivingDonationPayments extends RelationManager
|
|
{
|
|
protected static string $relationship = 'payments';
|
|
|
|
protected static ?string $title = 'Payment Schedule';
|
|
|
|
public function form(Form $form): Form
|
|
{
|
|
return $form;
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->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();
|
|
}),
|
|
]),
|
|
]);
|
|
}
|
|
}
|