Files
calvana/temp_files/v3/AppealResource.php
Omair Saleh 3b46222118 Stripe integration: charity connects their own Stripe account
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)
2026-03-04 22:46:08 +08:00

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'),
];
}
}