Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs
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)
This commit is contained in:
300
temp_files/care/ScheduledGivingDonationPayments.php
Normal file
300
temp_files/care/ScheduledGivingDonationPayments.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?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();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user