Model: PNPL never touches the money. Each charity connects their own Stripe account by pasting their API key in Settings. When a donor chooses card payment, they're redirected to Stripe Checkout. The money lands in the charity's Stripe balance. ## Schema - Organization.stripeSecretKey (new column) - Organization.stripeWebhookSecret (new column) ## New/rewritten files - src/lib/stripe.ts — getStripeForOrg(secretKey), per-org client - src/app/api/stripe/checkout/route.ts — uses org's key, not env var - src/app/api/stripe/webhook/route.ts — tries all org webhook secrets - src/app/p/[token]/steps/card-payment-step.tsx — redirect to Stripe Checkout (no fake card form — Stripe handles PCI) ## Settings page - New 'Card payments' section between Bank and Charity - Instructions: how to get your Stripe API key - Webhook setup in collapsed <details> (optional, for auto-confirm) - 'Card payments live' green banner when connected - Readiness bar shows Stripe status (5 columns now) ## Pledge flow - PaymentStep shows card option ONLY if org has Stripe configured - hasStripe flag passed from /api/qr/[token] → PaymentStep - Secret key never exposed to frontend (only boolean hasStripe) ## How it works 1. Charity pastes sk_live_... in Settings → Save 2. Donor opens pledge link → sees 'Bank Transfer', 'Direct Debit', 'Card' 3. Donor picks card → enters name + email → redirects to Stripe Checkout 4. Stripe processes payment → money in charity's Stripe balance 5. (Optional) Webhook auto-confirms pledge as paid Payment options: - Bank Transfer: zero fees (default, always available) - Direct Debit via GoCardless: 1% + 20p (if org configured) - Card via Stripe: standard Stripe fees (if org configured)
461 lines
21 KiB
PHP
461 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Definitions\DonationOccurrence;
|
|
use App\Filament\Exports\DonationExporter;
|
|
use App\Filament\Resources\DonationResource\Pages\EditDonation;
|
|
use App\Filament\Resources\DonationResource\Pages\ListDonations;
|
|
use App\Filament\Resources\DonationResource\RelationManagers\EventLogsRelationManager;
|
|
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
|
use App\Helpers;
|
|
use App\Jobs\N3O\Data\SendDonation;
|
|
use App\Jobs\Zapier\Data\SendCustomer;
|
|
use App\Mail\DonationConfirmed;
|
|
use App\Models\Donation;
|
|
use App\Models\DonationCountry;
|
|
use App\Models\DonationType;
|
|
use App\Models\EverGiveDonation;
|
|
use Filament\Forms\Components\DatePicker;
|
|
use Filament\Forms\Components\Fieldset;
|
|
use Filament\Forms\Components\Grid;
|
|
use Filament\Forms\Components\Placeholder;
|
|
use Filament\Forms\Components\Section;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Form;
|
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Tables\Actions\Action;
|
|
use Filament\Tables\Actions\ActionGroup;
|
|
use Filament\Tables\Actions\BulkAction;
|
|
use Filament\Tables\Actions\BulkActionGroup;
|
|
use Filament\Tables\Actions\ExportAction;
|
|
use Filament\Tables\Actions\ViewAction;
|
|
use Filament\Tables\Columns\IconColumn;
|
|
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\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\HtmlString;
|
|
use Throwable;
|
|
|
|
class DonationResource extends Resource
|
|
{
|
|
protected static ?string $model = Donation::class;
|
|
|
|
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
|
|
|
|
protected static ?string $navigationGroup = 'Donations';
|
|
|
|
protected static ?string $navigationLabel = 'All Donations';
|
|
|
|
protected static ?string $modelLabel = 'Donation';
|
|
|
|
protected static ?string $pluralModelLabel = 'Donations';
|
|
|
|
protected static ?int $navigationSort = 1;
|
|
|
|
// ─── Global Search ────────────────────────────────────────────
|
|
// Staff search by donor name, email, or payment reference.
|
|
// "Someone called about payment ref pi_3xxx" → finds it instantly.
|
|
|
|
public static function getGloballySearchableAttributes(): array
|
|
{
|
|
return ['reference_code', 'provider_reference'];
|
|
}
|
|
|
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
|
{
|
|
$status = $record->isConfirmed() ? '✓' : '✗';
|
|
return $status . ' £' . number_format($record->amount / 100, 2) . ' — ' . ($record->customer?->name ?? 'Unknown donor');
|
|
}
|
|
|
|
public static function getGlobalSearchResultDetails(Model $record): array
|
|
{
|
|
return [
|
|
'Cause' => $record->donationType?->display_name ?? '—',
|
|
'Date' => $record->created_at?->format('d M Y H:i'),
|
|
'Ref' => $record->reference_code ?? '—',
|
|
'Status' => $record->isConfirmed() ? 'Confirmed' : 'Incomplete',
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchResultActions(Model $record): array
|
|
{
|
|
return [
|
|
GlobalSearchAction::make('edit')
|
|
->label('View Donation')
|
|
->url(static::getUrl('edit', ['record' => $record])),
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchEloquentQuery(): Builder
|
|
{
|
|
return parent::getGlobalSearchEloquentQuery()
|
|
->with(['customer', 'donationType', 'donationConfirmation'])
|
|
->latest('created_at');
|
|
}
|
|
|
|
public static function getGlobalSearchResultsLimit(): int
|
|
{
|
|
return 8;
|
|
}
|
|
|
|
// ─── Form (Edit/View screen) ─────────────────────────────────
|
|
|
|
public static function form(Form $form): Form
|
|
{
|
|
return $form
|
|
->schema([
|
|
Section::make('Donation Details')
|
|
->collapsible()
|
|
->schema([
|
|
Grid::make(5)->schema([
|
|
Placeholder::make('Confirmed?')
|
|
->content(fn (Donation $record) => new HtmlString(
|
|
$record->isConfirmed()
|
|
? '<span class="text-green-600 font-bold">✓ Confirmed</span>'
|
|
: '<span class="text-red-600 font-bold">✗ Incomplete</span>'
|
|
)),
|
|
|
|
Placeholder::make('Amount')
|
|
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationTotal()) . '</b>')),
|
|
|
|
Placeholder::make('Admin Contribution')
|
|
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationAdminAmount()) . '</b>')),
|
|
|
|
Placeholder::make('Gift Aid?')
|
|
->content(fn (Donation $record) => new HtmlString(
|
|
$record->isGiftAid()
|
|
? '<span class="text-green-600 font-bold">✓ Yes</span>'
|
|
: '<span class="text-gray-400">No</span>'
|
|
)),
|
|
|
|
Placeholder::make('Zakat?')
|
|
->content(fn (Donation $record) => new HtmlString(
|
|
$record->isZakat()
|
|
? '<span class="text-green-600 font-bold">✓ Yes</span>'
|
|
: '<span class="text-gray-400">No</span>'
|
|
)),
|
|
]),
|
|
|
|
Fieldset::make('Donor')
|
|
->columns(3)
|
|
->schema([
|
|
Placeholder::make('name')
|
|
->content(fn (Donation $donation) => $donation->customer?->name ?? '—'),
|
|
|
|
Placeholder::make('email')
|
|
->content(fn (Donation $donation) => $donation->customer?->email ?? '—'),
|
|
|
|
Placeholder::make('phone')
|
|
->content(fn (Donation $donation) => strlen(trim($phone = $donation->customer?->phone ?? '')) > 0 ? $phone : new HtmlString('<i class="text-gray-400">Not provided</i>')),
|
|
])
|
|
->visible(fn (Donation $donation) => (bool) $donation->customer),
|
|
|
|
Fieldset::make('Address')
|
|
->columns(3)
|
|
->schema([
|
|
Placeholder::make('house')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->house ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
|
|
Placeholder::make('street')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->street ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
|
|
Placeholder::make('town')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->town ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
|
|
Placeholder::make('state')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->state ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
|
|
Placeholder::make('postcode')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->postcode ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
|
|
Placeholder::make('country_code')
|
|
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->country_code ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
|
|
])
|
|
->visible(fn (Donation $donation) => (bool) $donation->address),
|
|
|
|
Fieldset::make('Allocation')
|
|
->schema([
|
|
Select::make('donation_type_id')
|
|
->label('Cause')
|
|
->relationship('donationType', 'display_name')
|
|
->required()
|
|
->disabled(),
|
|
|
|
Select::make('donation_country_id')
|
|
->label('Country')
|
|
->relationship('donationCountry', 'name')
|
|
->required()
|
|
->disabled(),
|
|
|
|
Select::make('appeal_id')
|
|
->label('Fundraiser')
|
|
->relationship('appeal', 'name')
|
|
->searchable()
|
|
->live()
|
|
->disabled()
|
|
->visible(fn (Donation $donation) => $donation->appeal_id !== null),
|
|
|
|
Placeholder::make('ever_give_donation')
|
|
->content(fn (Donation $donation) =>
|
|
'£' . number_format(
|
|
EverGiveDonation::where('donation_id', $donation->id)->value('amount') ?? 0,
|
|
2
|
|
)
|
|
)->visible(function (Donation $donation) {
|
|
return EverGiveDonation::where('donation_id', $donation->id)->where('status', '!=', 'failed_to_send_to_ever_give_api')->first();
|
|
}),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// ─── Table ────────────────────────────────────────────────────
|
|
// Designed for two workflows:
|
|
// 1. "Find a specific donation" (search by donor name/email/ref)
|
|
// 2. "What happened today?" (default sort, quick status scan)
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
IconColumn::make('is_confirmed')
|
|
->label('')
|
|
->boolean()
|
|
->trueIcon('heroicon-o-check-circle')
|
|
->falseIcon('heroicon-o-x-circle')
|
|
->trueColor('success')
|
|
->falseColor('danger')
|
|
->tooltip(fn (Donation $d) => $d->isConfirmed() ? 'Payment confirmed' : 'Payment incomplete — may need follow-up'),
|
|
|
|
TextColumn::make('created_at')
|
|
->label('Date')
|
|
->dateTime('d M Y H:i')
|
|
->sortable()
|
|
->description(fn (Donation $d) => $d->created_at?->diffForHumans()),
|
|
|
|
TextColumn::make('customer.name')
|
|
->label('Donor')
|
|
->description(fn (Donation $d) => $d->customer?->email)
|
|
->searchable(query: function (Builder $query, string $search) {
|
|
$query->whereHas('customer', fn ($q) => $q
|
|
->where('first_name', 'like', "%{$search}%")
|
|
->orWhere('last_name', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%")
|
|
);
|
|
})
|
|
->sortable(),
|
|
|
|
TextColumn::make('amount')
|
|
->label('Amount')
|
|
->money('gbp', divideBy: 100)
|
|
->sortable()
|
|
->weight('bold'),
|
|
|
|
TextColumn::make('donationType.display_name')
|
|
->label('Cause')
|
|
->badge()
|
|
->color('info')
|
|
->toggleable(),
|
|
|
|
TextColumn::make('reoccurrence')
|
|
->label('Type')
|
|
->formatStateUsing(fn ($state) => match ((int) $state) {
|
|
-1 => 'One-off',
|
|
2 => 'Monthly',
|
|
default => DonationOccurrence::translate($state),
|
|
})
|
|
->badge()
|
|
->color(fn ($state) => match ((int) $state) {
|
|
-1 => 'gray',
|
|
2 => 'success',
|
|
default => 'warning',
|
|
}),
|
|
|
|
TextColumn::make('appeal.name')
|
|
->label('Fundraiser')
|
|
->toggleable()
|
|
->limit(25)
|
|
->placeholder('Direct'),
|
|
|
|
TextColumn::make('reference_code')
|
|
->label('Ref')
|
|
->searchable()
|
|
->fontFamily('mono')
|
|
->copyable()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
|
|
TextColumn::make('provider_reference')
|
|
->label('Payment Ref')
|
|
->searchable()
|
|
->fontFamily('mono')
|
|
->copyable()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters(static::getTableFilters())
|
|
->actions(static::getTableRowActions())
|
|
->bulkActions(static::getBulkActions())
|
|
->headerActions([
|
|
ExportAction::make('donations')
|
|
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
|
|
->label('Export')
|
|
->icon('heroicon-o-arrow-down-on-square')
|
|
->exporter(DonationExporter::class),
|
|
])
|
|
->defaultSort('created_at', 'desc')
|
|
->searchPlaceholder('Search by donor name, email, or reference...')
|
|
->poll('30s');
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
EventLogsRelationManager::class,
|
|
InternalNotesRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => ListDonations::route('/'),
|
|
'edit' => EditDonation::route('/{record}/edit'),
|
|
];
|
|
}
|
|
|
|
// ─── Table Actions ───────────────────────────────────────────
|
|
// Quick actions that solve problems without navigating away.
|
|
// "See a failed donation? → Resend receipt. See unknown donor? → Open profile."
|
|
|
|
private static function getTableRowActions(): array
|
|
{
|
|
return [
|
|
Action::make('view_donor')
|
|
->label('Donor')
|
|
->icon('heroicon-o-user')
|
|
->color('gray')
|
|
->url(fn (Donation $d) => $d->customer_id
|
|
? CustomerResource::getUrl('edit', ['record' => $d->customer_id])
|
|
: null)
|
|
->visible(fn (Donation $d) => (bool) $d->customer_id)
|
|
->openUrlInNewTab(),
|
|
|
|
ActionGroup::make([
|
|
ViewAction::make(),
|
|
|
|
Action::make('send_receipt')
|
|
->label('Send Receipt')
|
|
->icon('heroicon-o-envelope')
|
|
->requiresConfirmation()
|
|
->modalDescription(fn (Donation $d) => 'Send receipt to ' . ($d->customer?->email ?? '?'))
|
|
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
|
|
->action(function (Donation $donation) {
|
|
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();
|
|
}
|
|
}),
|
|
|
|
Action::make('view_in_stripe')
|
|
->label('View in Stripe')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->visible(fn (Donation $d) => (bool) $d->provider_reference)
|
|
->url(fn (Donation $d) => 'https://dashboard.stripe.com/search?query=' . urlencode($d->provider_reference))
|
|
->openUrlInNewTab(),
|
|
|
|
Action::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
|
|
->action(function (Donation $donation) {
|
|
dispatch(new SendDonation($donation));
|
|
Notification::make()->title('Sent to Engage')->success()->send();
|
|
}),
|
|
|
|
Action::make('send_to_zapier')
|
|
->label('Send to Zapier')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
|
|
->action(function (Donation $donation) {
|
|
dispatch(new SendCustomer($donation->customer));
|
|
Notification::make()->title('Sent to Zapier')->success()->send();
|
|
}),
|
|
]),
|
|
];
|
|
}
|
|
|
|
private static function getBulkActions()
|
|
{
|
|
return BulkActionGroup::make([
|
|
BulkAction::make('send_receipt')
|
|
->label('Send Receipts')
|
|
->icon('heroicon-o-envelope')
|
|
->requiresConfirmation()
|
|
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
|
|
->action(function ($records) {
|
|
$sent = 0;
|
|
foreach ($records as $donation) {
|
|
try {
|
|
Mail::to($donation->customer->email)->send(new DonationConfirmed($donation));
|
|
$sent++;
|
|
} catch (Throwable $e) {
|
|
Log::error($e);
|
|
}
|
|
}
|
|
Notification::make()->title($sent . ' receipts sent')->success()->send();
|
|
}),
|
|
|
|
BulkAction::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
|
|
->action(function ($records) {
|
|
foreach ($records as $donation) {
|
|
dispatch(new SendDonation($donation));
|
|
}
|
|
Notification::make()->title(count($records) . ' sent to Engage')->success()->send();
|
|
}),
|
|
]);
|
|
}
|
|
|
|
// ─── Filters (drill-down within tabs) ───────────────────────
|
|
|
|
private static function getTableFilters(): array
|
|
{
|
|
return [
|
|
SelectFilter::make('donation_type_id')
|
|
->label('Cause')
|
|
->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id'))
|
|
->searchable(),
|
|
|
|
SelectFilter::make('donation_country_id')
|
|
->label('Country')
|
|
->options(fn () => DonationCountry::orderBy('name')->pluck('name', 'id'))
|
|
->searchable(),
|
|
|
|
Filter::make('date_range')
|
|
->form([
|
|
DatePicker::make('from')->label('From'),
|
|
DatePicker::make('to')->label('To'),
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
return $query
|
|
->when($data['from'], fn (Builder $q) => $q->whereDate('created_at', '>=', $data['from']))
|
|
->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to']));
|
|
})
|
|
->columns(2),
|
|
];
|
|
}
|
|
}
|