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)
This commit is contained in:
2026-03-04 22:46:08 +08:00
parent 62be460643
commit 3b46222118
27 changed files with 1292 additions and 151 deletions

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Resources\DonationResource\Pages;
use App\Filament\Resources\DonationResource;
use App\Models\Donation;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListDonations extends ListRecords
{
protected static string $resource = DonationResource::class;
public function getHeading(): string
{
return 'Donations';
}
public function getSubheading(): string
{
$todayCount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->whereDate('created_at', today())
->count();
$todayAmount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->whereDate('created_at', today())
->sum('amount') / 100;
return "Today: {$todayCount} confirmed (£" . number_format($todayAmount, 0) . ")";
}
public function getTabs(): array
{
$incompleteCount = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(7))
->count();
return [
'today' => Tab::make('Today')
->icon('heroicon-o-clock')
->modifyQueryUsing(fn (Builder $query) => $query
->whereDate('created_at', today())
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
),
'all_confirmed' => Tab::make('All Confirmed')
->icon('heroicon-o-check-circle')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
),
'incomplete' => Tab::make('Incomplete')
->icon('heroicon-o-exclamation-triangle')
->badge($incompleteCount > 0 ? $incompleteCount : null)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $query) => $query
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(7))
),
'zakat' => Tab::make('Zakat')
->icon('heroicon-o-star')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->whereHas('donationPreferences', fn ($q) => $q->where('is_zakat', true))
),
'gift_aid' => Tab::make('Gift Aid')
->icon('heroicon-o-gift')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
),
'everything' => Tab::make('Everything')
->icon('heroicon-o-squares-2x2'),
];
}
}