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:
374
temp_files/care/EditDonation.php
Normal file
374
temp_files/care/EditDonation.php
Normal file
@@ -0,0 +1,374 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DonationResource\Pages;
|
||||
|
||||
use App\Definitions\PaymentProviders;
|
||||
use App\Filament\Resources\CustomerResource;
|
||||
use App\Filament\Resources\DonationResource;
|
||||
use App\Mail\DonationConfirmed;
|
||||
use App\Models\Donation;
|
||||
use App\Services\StripeRefundService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
/**
|
||||
* Donation detail page — supporter care command centre.
|
||||
*
|
||||
* Design: read-only view + action buttons. No inline editing of
|
||||
* donation data — all changes go through explicit actions with
|
||||
* confirmation modals and audit logging.
|
||||
*
|
||||
* @property Donation $record
|
||||
*/
|
||||
class EditDonation extends EditRecord
|
||||
{
|
||||
protected static string $resource = DonationResource::class;
|
||||
|
||||
// ── Heading: one-glance context ─────────────────────────────
|
||||
|
||||
public function getHeading(): string|HtmlString
|
||||
{
|
||||
$d = $this->record;
|
||||
|
||||
$status = $d->isConfirmed()
|
||||
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">✓ Confirmed</span>'
|
||||
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">✗ Incomplete</span>';
|
||||
|
||||
$amount = '£' . number_format($d->amount / 100, 2);
|
||||
$provider = PaymentProviders::translate($d->provider_type);
|
||||
|
||||
$badges = $status;
|
||||
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-1">' . $provider . '</span>';
|
||||
|
||||
if ($d->isGiftAid()) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 ml-1">Gift Aid</span>';
|
||||
}
|
||||
if ($d->isZakat()) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700 ml-1">Zakat</span>';
|
||||
}
|
||||
if ($d->reoccurrence !== -1) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-1">Recurring</span>';
|
||||
}
|
||||
|
||||
return new HtmlString("{$amount} — " . e($d->customer?->name ?? 'Unknown donor') . '<div class="mt-1">' . $badges . '</div>');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$d = $this->record;
|
||||
$parts = [];
|
||||
$parts[] = $d->donationType?->display_name ?? 'Unknown cause';
|
||||
$parts[] = $d->created_at?->format('d M Y H:i') . ' (' . $d->created_at?->diffForHumans() . ')';
|
||||
if ($d->appeal) {
|
||||
$parts[] = 'Fundraiser: ' . $d->appeal->name;
|
||||
}
|
||||
if ($d->reference_code) {
|
||||
$parts[] = 'Ref: ' . $d->reference_code;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
// ── Header Actions ──────────────────────────────────────────
|
||||
//
|
||||
// Priority order:
|
||||
// 1. Refund (Stripe PI) — the #1 support request
|
||||
// 2. Cancel Recurring (Stripe SetupIntent monthly)
|
||||
// 3. Confirm / Unconfirm
|
||||
// 4. Resend Receipt
|
||||
// 5. Stripe Status check
|
||||
// 6. Open in Stripe / View Donor / Email
|
||||
//
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$donation = $this->record;
|
||||
|
||||
$isStripe = $donation->provider_type === PaymentProviders::STRIPE;
|
||||
$isPayPal = $donation->provider_type === PaymentProviders::PAYPAL;
|
||||
$isGoCardless = $donation->provider_type === PaymentProviders::GOCARDLESS;
|
||||
$ref = $donation->provider_reference ?? '';
|
||||
$hasPI = $isStripe && str_starts_with($ref, 'pi_');
|
||||
$hasSI = $isStripe && str_starts_with($ref, 'seti_');
|
||||
|
||||
return array_values(array_filter([
|
||||
|
||||
// ── REFUND (Stripe PaymentIntent) ───────────────────
|
||||
$hasPI && $donation->isConfirmed() ? Action::make('refund')
|
||||
->label('Refund')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalIcon('heroicon-o-arrow-uturn-left')
|
||||
->modalHeading('Refund Donation')
|
||||
->modalDescription(
|
||||
'Refund £' . number_format($donation->amount / 100, 2)
|
||||
. ' to ' . e($donation->customer?->name ?? 'donor')
|
||||
. '\'s card via Stripe. This cannot be undone.'
|
||||
)
|
||||
->modalSubmitActionLabel('Process Refund')
|
||||
->form([
|
||||
TextInput::make('refund_amount')
|
||||
->label('Refund amount (£)')
|
||||
->numeric()
|
||||
->default(number_format($donation->amount / 100, 2, '.', ''))
|
||||
->required()
|
||||
->minValue(0.01)
|
||||
->maxValue($donation->amount / 100)
|
||||
->step(0.01)
|
||||
->helperText('Full amount for complete refund. Reduce for partial.'),
|
||||
])
|
||||
->action(function (array $data) {
|
||||
$donation = $this->record;
|
||||
$amountPence = (int) round($data['refund_amount'] * 100);
|
||||
$isPartial = $amountPence < $donation->amount;
|
||||
|
||||
$service = app(StripeRefundService::class);
|
||||
$result = $service->refundPaymentIntent(
|
||||
$donation->provider_reference,
|
||||
$isPartial ? $amountPence : null,
|
||||
'Admin refund by ' . auth()->user()?->name
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
if (! $isPartial) {
|
||||
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||
}
|
||||
|
||||
$donation->internalNotes()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => ($isPartial ? 'Partial' : 'Full') . ' refund of £'
|
||||
. number_format($amountPence / 100, 2)
|
||||
. ' processed via Stripe. Refund ID: ' . $result['refund_id'],
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Refund processed')
|
||||
->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();
|
||||
}
|
||||
}) : null,
|
||||
|
||||
// ── CANCEL RECURRING (SetupIntent monthly) ──────────
|
||||
$hasSI && $donation->isConfirmed() ? Action::make('cancel_recurring')
|
||||
->label('Cancel Recurring')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalIcon('heroicon-o-x-circle')
|
||||
->modalHeading('Cancel Monthly Giving')
|
||||
->modalDescription(
|
||||
'This will detach the payment method from Stripe, preventing '
|
||||
. 'all future charges for ' . e($donation->customer?->name ?? 'this donor')
|
||||
. '. Previously collected payments will NOT be refunded.'
|
||||
)
|
||||
->modalSubmitActionLabel('Cancel Monthly Giving')
|
||||
->action(function () {
|
||||
$donation = $this->record;
|
||||
$service = app(StripeRefundService::class);
|
||||
$result = $service->detachPaymentMethod($donation->provider_reference);
|
||||
|
||||
if ($result['success']) {
|
||||
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||
|
||||
$donation->internalNotes()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => 'Monthly giving cancelled. ' . ($result['message'] ?? 'Payment method detached from Stripe.'),
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Monthly giving cancelled')
|
||||
->body($result['message'] ?? 'Payment method detached')
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Cancellation failed')
|
||||
->body($result['error'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}) : null,
|
||||
|
||||
// ── OPEN PAYPAL ─────────────────────────────────────
|
||||
$isPayPal && $ref ? Action::make('open_paypal')
|
||||
->label('Open PayPal')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('warning')
|
||||
->url('https://www.paypal.com/activity/payment/' . urlencode($ref))
|
||||
->openUrlInNewTab() : null,
|
||||
|
||||
// ── OPEN GOCARDLESS ─────────────────────────────────
|
||||
$isGoCardless && $ref ? Action::make('open_gocardless')
|
||||
->label('Open GoCardless')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('warning')
|
||||
->url('https://manage.gocardless.com/payments/' . urlencode($ref))
|
||||
->openUrlInNewTab() : null,
|
||||
|
||||
// ── UNCONFIRM ───────────────────────────────────────
|
||||
$donation->isConfirmed() ? Action::make('unconfirm')
|
||||
->label('Unconfirm')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Unconfirm Donation')
|
||||
->modalDescription(
|
||||
'Mark this donation as incomplete. This does NOT refund money '
|
||||
. 'on Stripe/PayPal — use the Refund button for that.'
|
||||
)
|
||||
->action(function () {
|
||||
$donation = $this->record;
|
||||
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||
|
||||
$donation->internalNotes()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => 'Donation manually unconfirmed by admin.',
|
||||
]);
|
||||
|
||||
Notification::make()->title('Donation unconfirmed')->warning()->send();
|
||||
}) : null,
|
||||
|
||||
// ── CONFIRM ─────────────────────────────────────────
|
||||
! $donation->isConfirmed() ? Action::make('confirm')
|
||||
->label('Confirm')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Confirm Donation')
|
||||
->modalDescription(
|
||||
'Mark this donation as confirmed. Only do this if you have '
|
||||
. 'verified the payment was received.'
|
||||
)
|
||||
->action(function () {
|
||||
$donation = $this->record;
|
||||
$donation->confirm();
|
||||
|
||||
$donation->internalNotes()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => 'Donation manually confirmed by admin.',
|
||||
]);
|
||||
|
||||
Notification::make()->title('Donation confirmed')->success()->send();
|
||||
}) : null,
|
||||
|
||||
// ── RESEND RECEIPT ──────────────────────────────────
|
||||
$donation->isConfirmed() && $donation->customer?->email
|
||||
? Action::make('resend_receipt')
|
||||
->label('Receipt')
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Send receipt to ' . $donation->customer->email)
|
||||
->action(function () {
|
||||
$donation = $this->record;
|
||||
try {
|
||||
Mail::to($donation->customer->email)
|
||||
->send(new DonationConfirmed($donation));
|
||||
|
||||
Notification::make()
|
||||
->title('Receipt sent to ' . $donation->customer->email)
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error($e);
|
||||
Notification::make()
|
||||
->title('Failed to send receipt')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}) : null,
|
||||
|
||||
// ── STRIPE STATUS ───────────────────────────────────
|
||||
($hasPI || $hasSI) ? Action::make('check_stripe')
|
||||
->label('Stripe Status')
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->color('gray')
|
||||
->action(function () use ($hasPI) {
|
||||
$donation = $this->record;
|
||||
$service = app(StripeRefundService::class);
|
||||
$details = $hasPI
|
||||
? $service->getPaymentDetails($donation->provider_reference)
|
||||
: $service->getSetupIntentDetails($donation->provider_reference);
|
||||
|
||||
if (! $details) {
|
||||
Notification::make()
|
||||
->title('Could not retrieve Stripe details')
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($details as $key => $val) {
|
||||
if ($val === null || $val === '') {
|
||||
continue;
|
||||
}
|
||||
$label = str_replace('_', ' ', ucfirst($key));
|
||||
if (is_bool($val)) {
|
||||
$val = $val ? 'Yes' : 'No';
|
||||
}
|
||||
if ($key === 'amount' || $key === 'amount_refunded') {
|
||||
$val = '£' . number_format($val / 100, 2);
|
||||
}
|
||||
$lines[] = "{$label}: {$val}";
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Stripe Details')
|
||||
->body(implode("\n", $lines))
|
||||
->info()
|
||||
->persistent()
|
||||
->send();
|
||||
}) : null,
|
||||
|
||||
// ── OPEN IN STRIPE ──────────────────────────────────
|
||||
$isStripe && $ref ? Action::make('open_stripe')
|
||||
->label('Stripe ↗')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url('https://dashboard.stripe.com/search?query=' . urlencode($ref))
|
||||
->openUrlInNewTab() : null,
|
||||
|
||||
// ── VIEW DONOR ──────────────────────────────────────
|
||||
$donation->customer_id ? Action::make('view_donor')
|
||||
->label('Donor')
|
||||
->icon('heroicon-o-user')
|
||||
->color('gray')
|
||||
->url(CustomerResource::getUrl('edit', ['record' => $donation->customer_id]))
|
||||
: null,
|
||||
|
||||
// ── EMAIL ───────────────────────────────────────────
|
||||
$donation->customer?->email ? Action::make('email')
|
||||
->label('Email')
|
||||
->icon('heroicon-o-at-symbol')
|
||||
->color('gray')
|
||||
->url('mailto:' . $donation->customer->email)
|
||||
->openUrlInNewTab() : null,
|
||||
]));
|
||||
}
|
||||
|
||||
// Hide save/cancel — donations are not directly editable
|
||||
protected function getSaveFormAction(): Action
|
||||
{
|
||||
return parent::getSaveFormAction()->visible(false);
|
||||
}
|
||||
|
||||
protected function getCancelFormAction(): Action
|
||||
{
|
||||
return parent::getCancelFormAction()->visible(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user