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

@@ -34,10 +34,8 @@ use Filament\Tables\Actions\ExportAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -302,7 +300,7 @@ class DonationResource extends Resource
->copyable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters(static::getTableFilters(), layout: FiltersLayout::AboveContentCollapsible)
->filters(static::getTableFilters())
->actions(static::getTableRowActions())
->bulkActions(static::getBulkActions())
->headerActions([
@@ -431,27 +429,11 @@ class DonationResource extends Resource
]);
}
// ─── Filters ─────────────────────────────────────────────────
// Designed around real questions:
// "Show me today's incomplete donations" (investigating failures)
// "Show me Zakat donations this month" (reporting)
// "Show me donations to a specific cause" (allocation check)
// ─── Filters (drill-down within tabs) ───────────────────────
private static function getTableFilters(): array
{
return [
TernaryFilter::make('confirmed')
->label('Payment Status')
->placeholder('All')
->trueLabel('Confirmed only')
->falseLabel('Incomplete only')
->queries(
true: fn (Builder $q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
false: fn (Builder $q) => $q->whereDoesntHave('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
blank: fn (Builder $q) => $q,
)
->default(true),
SelectFilter::make('donation_type_id')
->label('Cause')
->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id'))
@@ -473,21 +455,6 @@ class DonationResource extends Resource
->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to']));
})
->columns(2),
Filter::make('is_zakat')
->label('Zakat')
->toggle()
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_zakat', true))),
Filter::make('is_gift_aid')
->label('Gift Aid')
->toggle()
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_gift_aid', true))),
Filter::make('has_fundraiser')
->label('Via Fundraiser')
->toggle()
->query(fn (Builder $q) => $q->whereNotNull('appeal_id')),
];
}
}