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)
387 lines
16 KiB
PHP
387 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\AppealResource\Pages\EditAppeal;
|
|
use App\Filament\Resources\AppealResource\Pages\ListAppeals;
|
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealChildrenRelationManager;
|
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealDonationsRelationManager;
|
|
use App\Jobs\N3O\Data\SendAppeal;
|
|
use App\Jobs\WordPress\SyncAppeal;
|
|
use App\Models\Appeal;
|
|
use App\Models\DonationType;
|
|
use Filament\Forms\Components\Fieldset;
|
|
use Filament\Forms\Components\FileUpload;
|
|
use Filament\Forms\Components\RichEditor;
|
|
use Filament\Forms\Components\Section;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Form;
|
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Support\Enums\MaxWidth;
|
|
use Filament\Tables\Actions\Action;
|
|
use Filament\Tables\Actions\ActionGroup;
|
|
use Filament\Tables\Actions\BulkAction;
|
|
use Filament\Tables\Actions\EditAction;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\HtmlString;
|
|
|
|
class AppealResource extends Resource
|
|
{
|
|
protected static ?string $model = Appeal::class;
|
|
|
|
protected static ?string $navigationIcon = 'heroicon-o-hand-raised';
|
|
|
|
protected static ?string $navigationGroup = 'Campaigns';
|
|
|
|
protected static ?int $navigationSort = 1;
|
|
|
|
protected static ?string $modelLabel = 'Fundraiser';
|
|
|
|
protected static ?string $pluralModelLabel = 'Fundraisers';
|
|
|
|
protected static ?string $navigationLabel = 'Fundraisers';
|
|
|
|
protected static ?string $recordTitleAttribute = 'name';
|
|
|
|
// ─── Global Search ────────────────────────────────────────────
|
|
|
|
public static function getGloballySearchableAttributes(): array
|
|
{
|
|
return ['name', 'slug', 'description'];
|
|
}
|
|
|
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
|
{
|
|
return $record->name;
|
|
}
|
|
|
|
public static function getGlobalSearchResultDetails(Model $record): array
|
|
{
|
|
$pct = $record->amount_to_raise > 0
|
|
? round($record->amount_raised / $record->amount_to_raise * 100)
|
|
: 0;
|
|
|
|
return [
|
|
'Progress' => '£' . number_format($record->amount_raised / 100, 0) . ' / £' . number_format($record->amount_to_raise / 100, 0) . " ({$pct}%)",
|
|
'Status' => $record->is_accepting_donations ? 'Live' : 'Closed',
|
|
'Owner' => $record->user?->name ?? '—',
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchResultActions(Model $record): array
|
|
{
|
|
return [
|
|
GlobalSearchAction::make('edit')
|
|
->label('Open Fundraiser')
|
|
->url(static::getUrl('edit', ['record' => $record])),
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchEloquentQuery(): Builder
|
|
{
|
|
return parent::getGlobalSearchEloquentQuery()
|
|
->with('user')
|
|
->latest('created_at');
|
|
}
|
|
|
|
// ─── Form ────────────────────────────────────────────────────
|
|
|
|
public static function form(Form $form): Form
|
|
{
|
|
return $form
|
|
->schema([
|
|
Fieldset::make('Connections')->schema([
|
|
Select::make('parent_appeal_id')
|
|
->label('Parent Fundraiser')
|
|
->relationship('parent', 'name', modifyQueryUsing: fn ($query) => $query->whereNull('parent_appeal_id'))
|
|
->required(false)
|
|
->searchable(),
|
|
|
|
Select::make('user_id')
|
|
->label('Page Owner')
|
|
->searchable()
|
|
->relationship('user', 'name')
|
|
->required(),
|
|
]),
|
|
|
|
Section::make('General Info')->schema([
|
|
Fieldset::make('Info')->schema([
|
|
TextInput::make('name')
|
|
->label('Fundraiser Name')
|
|
->required()
|
|
->maxLength(255),
|
|
|
|
TextInput::make('slug')
|
|
->label('URL Slug')
|
|
->required()
|
|
->maxLength(255)
|
|
->unique(
|
|
table: 'appeals',
|
|
column: 'slug',
|
|
ignorable: $form->getRecord(),
|
|
modifyRuleUsing: fn ($rule) => $rule->whereNull('deleted_at')
|
|
)
|
|
->disabled(),
|
|
|
|
TextInput::make('description')
|
|
->label('Short Description')
|
|
->required()
|
|
->maxLength(512),
|
|
]),
|
|
|
|
Fieldset::make('Target & Allocation')->schema([
|
|
TextInput::make('amount_to_raise')
|
|
->label('Fundraising Target')
|
|
->numeric()
|
|
->minValue(150)
|
|
->maxValue(999_999_999)
|
|
->prefix('£')
|
|
->required(),
|
|
|
|
TextInput::make('amount_raised')
|
|
->label('Amount Raised So Far')
|
|
->numeric()
|
|
->prefix('£')
|
|
->disabled(),
|
|
|
|
Select::make('donation_type_id')
|
|
->label('Cause')
|
|
->relationship('donationType', 'display_name')
|
|
->searchable()
|
|
->preload()
|
|
->live()
|
|
->required(),
|
|
|
|
Select::make('donation_country_id')
|
|
->label('Country')
|
|
->required()
|
|
->visible(function (\Filament\Forms\Get $get) {
|
|
$donationTypeId = $get('donation_type_id');
|
|
if (!($donationType = DonationType::find($donationTypeId))) {
|
|
return false;
|
|
}
|
|
return $donationType->donationCountries()->count() > 1;
|
|
})
|
|
->options(function (\Filament\Forms\Get $get) {
|
|
$donationTypeId = $get('donation_type_id');
|
|
if (!($donationType = DonationType::find($donationTypeId))) {
|
|
return [];
|
|
}
|
|
return $donationType->donationCountries->pluck('name', 'id')->toArray();
|
|
})
|
|
->live(),
|
|
]),
|
|
|
|
Fieldset::make('Settings')->schema([
|
|
Toggle::make('is_visible')
|
|
->label('Visible on website'),
|
|
|
|
Toggle::make('is_in_memory')
|
|
->label('In memory of someone')
|
|
->live(),
|
|
|
|
TextInput::make('in_memory_name')
|
|
->label('In memory of')
|
|
->required(fn (\Filament\Forms\Get $get) => $get('is_in_memory'))
|
|
->visible(fn (\Filament\Forms\Get $get) => $get('is_in_memory')),
|
|
|
|
Toggle::make('is_accepting_donations')
|
|
->label('Accepting donations'),
|
|
|
|
Toggle::make('is_team_campaign')
|
|
->label('Team fundraiser'),
|
|
|
|
Toggle::make('is_accepting_members')
|
|
->label('Accepting team members')
|
|
->live()
|
|
->visible(fn (\Filament\Forms\Get $get) => $get('is_team_campaign')),
|
|
]),
|
|
])
|
|
->collapsible()
|
|
->collapsed(),
|
|
|
|
Section::make('Content')->schema([
|
|
FileUpload::make('picture')
|
|
->label('Cover Image')
|
|
->required()
|
|
->columnSpanFull(),
|
|
|
|
RichEditor::make('story')
|
|
->label('Fundraiser Story')
|
|
->required()
|
|
->minLength(150)
|
|
->columnSpanFull(),
|
|
])
|
|
->collapsible()
|
|
->collapsed(),
|
|
]);
|
|
}
|
|
|
|
// ─── Table ────────────────────────────────────────────────────
|
|
// Designed for the "Fundraiser Nurture" journey:
|
|
// Jasmine opens this page and immediately sees which fundraisers
|
|
// need attention, which are succeeding, which are stale.
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label('Fundraiser')
|
|
->searchable()
|
|
->sortable()
|
|
->weight('bold')
|
|
->limit(40)
|
|
->description(fn (Appeal $a) => $a->user?->name ? 'by ' . $a->user->name : null)
|
|
->tooltip(fn (Appeal $a) => $a->name),
|
|
|
|
TextColumn::make('status_label')
|
|
->label('Status')
|
|
->getStateUsing(function (Appeal $a) {
|
|
if ($a->status === 'pending') return 'Pending Review';
|
|
if (!$a->is_accepting_donations) return 'Closed';
|
|
return 'Live';
|
|
})
|
|
->badge()
|
|
->color(fn ($state) => match ($state) {
|
|
'Live' => 'success',
|
|
'Pending Review' => 'warning',
|
|
'Closed' => 'gray',
|
|
default => 'gray',
|
|
}),
|
|
|
|
TextColumn::make('progress')
|
|
->label('Progress')
|
|
->getStateUsing(function (Appeal $a) {
|
|
$raised = $a->amount_raised / 100;
|
|
$target = $a->amount_to_raise / 100;
|
|
$pct = $target > 0 ? round($raised / $target * 100) : 0;
|
|
return '£' . number_format($raised, 0) . ' / £' . number_format($target, 0) . " ({$pct}%)";
|
|
})
|
|
->color(function (Appeal $a) {
|
|
$pct = $a->amount_to_raise > 0 ? $a->amount_raised / $a->amount_to_raise : 0;
|
|
if ($pct >= 1) return 'success';
|
|
if ($pct >= 0.5) return 'info';
|
|
if ($pct > 0) return 'warning';
|
|
return 'danger';
|
|
})
|
|
->weight('bold'),
|
|
|
|
TextColumn::make('donationType.display_name')
|
|
->label('Cause')
|
|
->badge()
|
|
->color('info')
|
|
->toggleable(),
|
|
|
|
TextColumn::make('nurture_status')
|
|
->label('Needs Attention?')
|
|
->getStateUsing(function (Appeal $a) {
|
|
if ($a->status !== 'confirmed') return null;
|
|
if (!$a->is_accepting_donations) return null;
|
|
|
|
$raised = $a->amount_raised;
|
|
$target = $a->amount_to_raise;
|
|
$age = $a->created_at?->diffInDays(now()) ?? 0;
|
|
|
|
if ($raised == 0 && $age > 7) return '🔴 No donations yet';
|
|
if ($raised > 0 && $raised >= $target * 0.8 && $raised < $target) return '🟡 Almost there!';
|
|
if ($raised >= $target) return '🟢 Target reached!';
|
|
if ($raised > 0 && $age > 30) return '🟠 Slowing down';
|
|
|
|
return null;
|
|
})
|
|
->placeholder('—')
|
|
->wrap(),
|
|
|
|
TextColumn::make('created_at')
|
|
->label('Created')
|
|
->since()
|
|
->sortable()
|
|
->description(fn (Appeal $a) => $a->created_at?->format('d M Y')),
|
|
])
|
|
->filters([])
|
|
->actions([
|
|
Action::make('view_page')
|
|
->label('View Page')
|
|
->icon('heroicon-o-eye')
|
|
->color('gray')
|
|
->url(fn (Appeal $a) => 'https://www.charityright.org.uk/fundraiser/' . $a->slug)
|
|
->openUrlInNewTab(),
|
|
|
|
ActionGroup::make([
|
|
EditAction::make(),
|
|
|
|
Action::make('email_owner')
|
|
->label('Email Owner')
|
|
->icon('heroicon-o-envelope')
|
|
->url(fn (Appeal $a) => $a->user?->email ? 'mailto:' . $a->user->email : null)
|
|
->visible(fn (Appeal $a) => (bool) $a->user?->email)
|
|
->openUrlInNewTab(),
|
|
|
|
Action::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->action(function (Appeal $appeal) {
|
|
dispatch(new SendAppeal($appeal));
|
|
Notification::make()->title('Sent to Engage')->success()->send();
|
|
}),
|
|
|
|
Action::make('appeal_owner')
|
|
->label('Page Owner Details')
|
|
->icon('heroicon-o-user')
|
|
->modalContent(fn (Appeal $appeal): View => view('filament.fields.appeal-owner', ['appeal' => $appeal]))
|
|
->modalWidth(MaxWidth::Large)
|
|
->modalSubmitAction(false)
|
|
->modalCancelAction(false),
|
|
]),
|
|
])
|
|
->bulkActions([
|
|
BulkAction::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->action(function ($records) {
|
|
foreach ($records as $appeal) {
|
|
dispatch(new SendAppeal($appeal));
|
|
}
|
|
Notification::make()->title(count($records) . ' sent to Engage')->success()->send();
|
|
}),
|
|
|
|
BulkAction::make('send_to_wp')
|
|
->label('Sync to WordPress')
|
|
->icon('heroicon-o-globe-alt')
|
|
->action(function ($records) {
|
|
foreach ($records as $appeal) {
|
|
dispatch(new SyncAppeal($appeal));
|
|
}
|
|
Notification::make()->title(count($records) . ' synced to WordPress')->success()->send();
|
|
}),
|
|
])
|
|
->defaultSort('created_at', 'desc')
|
|
->searchPlaceholder('Search by fundraiser name...');
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
AppealDonationsRelationManager::class,
|
|
AppealChildrenRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => ListAppeals::route('/'),
|
|
'edit' => EditAppeal::route('/{record}/edit'),
|
|
];
|
|
}
|
|
}
|