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

@@ -27,9 +27,6 @@ use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
@@ -310,47 +307,7 @@ class AppealResource extends Resource
->sortable()
->description(fn (Appeal $a) => $a->created_at?->format('d M Y')),
])
->filters([
SelectFilter::make('nurture_segment')
->label('Nurture Segment')
->options([
'needs_outreach' => '🔴 Needs Outreach (£0 raised, 7+ days)',
'almost_there' => '🟡 Almost There (80%+ of target)',
'target_reached' => '🟢 Target Reached',
'slowing' => '🟠 Slowing Down (raised something, 30+ days)',
'new_this_week' => '🆕 New This Week',
])
->query(function (Builder $query, array $data) {
if (!$data['value']) return;
$query->where('status', 'confirmed')->where('is_accepting_donations', true);
match ($data['value']) {
'needs_outreach' => $query->where('amount_raised', 0)->where('created_at', '<', now()->subDays(7)),
'almost_there' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised >= amount_to_raise * 0.8')->whereRaw('amount_raised < amount_to_raise'),
'target_reached' => $query->whereRaw('amount_raised >= amount_to_raise')->where('amount_raised', '>', 0),
'slowing' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised < amount_to_raise * 0.8')->where('created_at', '<', now()->subDays(30)),
'new_this_week' => $query->where('created_at', '>=', now()->subDays(7)),
default => null,
};
}),
SelectFilter::make('status')
->options([
'confirmed' => 'Live',
'pending' => 'Pending Review',
]),
Filter::make('accepting_donations')
->label('Currently accepting donations')
->toggle()
->query(fn (Builder $q) => $q->where('is_accepting_donations', true))
->default(),
Filter::make('has_raised')
->label('Has raised money')
->toggle()
->query(fn (Builder $q) => $q->where('amount_raised', '>', 0)),
], layout: FiltersLayout::AboveContentCollapsible)
->filters([])
->actions([
Action::make('view_page')
->label('View Page')

View File

@@ -17,10 +17,8 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -207,56 +205,7 @@ class CustomerResource extends Resource
->since()
->sortable(),
])
->filters([
Filter::make('has_donations')
->label('Has donated')
->toggle()
->query(fn (Builder $q) => $q->has('donations')),
Filter::make('monthly_supporter')
->label('Monthly supporter')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'scheduledGivingDonations',
fn ($q2) => $q2->where('is_active', true)
)),
Filter::make('gift_aid')
->label('Gift Aid donors')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'donations',
fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true))
)),
Filter::make('major_donor')
->label('Major donors (£1000+)')
->toggle()
->query(function (Builder $q) {
$q->whereIn('id', function ($sub) {
$sub->select('customer_id')
->from('donations')
->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
->whereNotNull('donation_confirmations.confirmed_at')
->groupBy('customer_id')
->havingRaw('SUM(donations.amount) >= 100000');
});
}),
Filter::make('incomplete_donations')
->label('Has incomplete donations')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'donations',
fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(30))
)),
Filter::make('recent')
->label('Joined last 30 days')
->toggle()
->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))),
])
->filters([])
->actions([
EditAction::make()
->label('Open')

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