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)
236 lines
8.2 KiB
PHP
236 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Definitions\DonationOccurrence;
|
|
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
|
|
use App\Filament\Resources\CustomerResource\Pages\ListCustomers;
|
|
use App\Filament\Resources\CustomerResource\RelationManagers\AddressesRelationManager;
|
|
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
|
use App\Filament\Resources\CustomerResource\RelationManagers\DonationsRelationManager;
|
|
use App\Filament\Resources\CustomerResource\RelationManagers\ScheduledGivingDonationsRelationManager;
|
|
use App\Models\Customer;
|
|
use Filament\Forms\Components\Grid;
|
|
use Filament\Forms\Components\Section;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Form;
|
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Tables\Actions\EditAction;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\HtmlString;
|
|
|
|
class CustomerResource extends Resource
|
|
{
|
|
protected static ?string $model = Customer::class;
|
|
|
|
protected static ?string $navigationIcon = 'heroicon-o-user-circle';
|
|
|
|
protected static ?string $navigationGroup = 'Supporter Care';
|
|
|
|
protected static ?string $navigationLabel = 'Donors';
|
|
|
|
protected static ?string $modelLabel = 'Donor';
|
|
|
|
protected static ?string $pluralModelLabel = 'Donors';
|
|
|
|
protected static ?int $navigationSort = 1;
|
|
|
|
protected static ?string $recordTitleAttribute = 'email';
|
|
|
|
// ─── Global Search (Cmd+K) ────────────────────────────────────
|
|
// This is how Sahibah finds a donor when they call or email.
|
|
// She might type an email, a name, or a phone number.
|
|
|
|
public static function getGloballySearchableAttributes(): array
|
|
{
|
|
return ['first_name', 'last_name', 'email', 'phone'];
|
|
}
|
|
|
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
|
{
|
|
return $record->name;
|
|
}
|
|
|
|
public static function getGlobalSearchResultDetails(Model $record): array
|
|
{
|
|
$donationCount = $record->donations()->count();
|
|
$totalDonated = $record->donations()
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->sum('amount') / 100;
|
|
|
|
$activeSG = $record->scheduledGivingDonations()->where('is_active', true)->count();
|
|
|
|
$details = [
|
|
'Email' => $record->email,
|
|
];
|
|
|
|
if ($record->phone) {
|
|
$details['Phone'] = $record->phone;
|
|
}
|
|
|
|
if ($donationCount > 0) {
|
|
$details['Donations'] = $donationCount . ' (£' . number_format($totalDonated, 0) . ' total)';
|
|
}
|
|
|
|
if ($activeSG > 0) {
|
|
$details['Monthly Giving'] = $activeSG . ' active';
|
|
}
|
|
|
|
return $details;
|
|
}
|
|
|
|
public static function getGlobalSearchResultActions(Model $record): array
|
|
{
|
|
return [
|
|
GlobalSearchAction::make('edit')
|
|
->label('Open Donor Profile')
|
|
->url(static::getUrl('edit', ['record' => $record])),
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchEloquentQuery(): Builder
|
|
{
|
|
return parent::getGlobalSearchEloquentQuery()->latest('created_at');
|
|
}
|
|
|
|
public static function getGlobalSearchResultsLimit(): int
|
|
{
|
|
return 10;
|
|
}
|
|
|
|
// ─── Form (Edit screen) ──────────────────────────────────────
|
|
|
|
public static function form(Form $form): Form
|
|
{
|
|
return $form
|
|
->schema([
|
|
Select::make('user_id')
|
|
->relationship('user', 'email')
|
|
->searchable()
|
|
->live()
|
|
->helperText('The website login account linked to this donor (if any).')
|
|
->disabled(),
|
|
|
|
Grid::make()->schema([
|
|
Section::make('Personal Details')->schema([
|
|
Select::make('title')
|
|
->required()
|
|
->options(config('donate.titles'))
|
|
->columnSpan(1),
|
|
|
|
TextInput::make('first_name')
|
|
->required()
|
|
->maxLength(255)
|
|
->columnSpan(2),
|
|
|
|
TextInput::make('last_name')
|
|
->required()
|
|
->maxLength(255)
|
|
->columnSpan(2),
|
|
])
|
|
->columns(5)
|
|
->columnSpan(1),
|
|
|
|
Section::make('Contact Information')->schema([
|
|
TextInput::make('email')
|
|
->email()
|
|
->required()
|
|
->disabled(fn (\Filament\Forms\Get $get) => (bool) $get('user_id'))
|
|
->live()
|
|
->maxLength(255)
|
|
->copyable(),
|
|
|
|
TextInput::make('phone')
|
|
->tel()
|
|
->maxLength(32)
|
|
->copyable(),
|
|
])
|
|
->columns()
|
|
->columnSpan(1),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// ─── Table (List screen) ─────────────────────────────────────
|
|
// This is the "Donor Lookup" — staff search by any field and
|
|
// immediately see key context: are they a monthly giver? how many
|
|
// donations? This helps them triage before even clicking in.
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label('Donor')
|
|
->searchable(['first_name', 'last_name'])
|
|
->sortable(['first_name'])
|
|
->weight('bold')
|
|
->description(fn (Customer $record): ?string => $record->email),
|
|
|
|
TextColumn::make('phone')
|
|
->label('Phone')
|
|
->searchable()
|
|
->copyable()
|
|
->placeholder('—'),
|
|
|
|
TextColumn::make('donations_count')
|
|
->label('Donations')
|
|
->counts('donations')
|
|
->sortable()
|
|
->badge()
|
|
->color(fn ($state) => match(true) {
|
|
$state >= 50 => 'success',
|
|
$state >= 10 => 'info',
|
|
$state > 0 => 'gray',
|
|
default => 'danger',
|
|
}),
|
|
|
|
TextColumn::make('scheduled_giving_donations_count')
|
|
->label('Monthly')
|
|
->counts([
|
|
'scheduledGivingDonations' => fn (Builder $q) => $q->where('is_active', true),
|
|
])
|
|
->formatStateUsing(fn ($state) => $state > 0 ? 'Active' : null)
|
|
->badge()
|
|
->color('success')
|
|
->placeholder('—'),
|
|
|
|
TextColumn::make('created_at')
|
|
->label('Joined')
|
|
->since()
|
|
->sortable(),
|
|
])
|
|
->filters([])
|
|
->actions([
|
|
EditAction::make()
|
|
->label('Open')
|
|
->icon('heroicon-o-arrow-right'),
|
|
])
|
|
->defaultSort('created_at', 'desc')
|
|
->searchPlaceholder('Search by name, email, or phone...');
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
DonationsRelationManager::class,
|
|
ScheduledGivingDonationsRelationManager::class,
|
|
AddressesRelationManager::class,
|
|
InternalNotesRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => ListCustomers::route('/'),
|
|
'edit' => EditCustomer::route('/{record}/edit'),
|
|
];
|
|
}
|
|
}
|