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,88 @@
<?php
namespace App\Providers\Filament;
use App\Helpers;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem;
use Filament\Navigation\NavigationGroup;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors(['primary' => config('branding.colours')])
->viteTheme('resources/css/filament/admin/theme.css')
->sidebarCollapsibleOnDesktop()
->sidebarWidth('16rem')
->globalSearch(true)
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
->globalSearchDebounce('300ms')
->navigationGroups([
// ── Daily Work (always visible, top of sidebar) ──
NavigationGroup::make('Daily')
->collapsible(false),
// ── Fundraising (campaigns, review queue) ──
NavigationGroup::make('Fundraising')
->icon('heroicon-o-megaphone')
->collapsible(),
// ── Setup (rarely touched config) ──
NavigationGroup::make('Setup')
->icon('heroicon-o-cog-6-tooth')
->collapsible()
->collapsed(),
])
->brandLogo(Helpers::getCurrentLogo(true))
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([\Filament\Pages\Dashboard::class])
->userMenuItems([
'profile' => MenuItem::make()
->label('Edit profile')
->url(url('user/profile')),
'back2site' => MenuItem::make()
->label('Return to site')
->icon('heroicon-o-home')
->url(url('/')),
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->login(null)
->registration(null)
->darkMode(false)
->databaseNotifications();
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Resources\AppealResource\Pages;
use App\Filament\Resources\AppealResource;
use App\Models\Appeal;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListAppeals extends ListRecords
{
protected static string $resource = AppealResource::class;
public function getHeading(): string
{
return 'Fundraisers';
}
public function getSubheading(): string
{
$live = Appeal::where('status', 'confirmed')->where('is_accepting_donations', true)->count();
return "{$live} fundraisers are live right now.";
}
public function getTabs(): array
{
$needsHelp = Appeal::where('status', 'confirmed')
->where('is_accepting_donations', true)
->where('amount_raised', 0)
->where('created_at', '<', now()->subDays(7))
->where('created_at', '>', now()->subDays(90))
->count();
$almostThere = Appeal::where('status', 'confirmed')
->where('is_accepting_donations', true)
->where('amount_raised', '>', 0)
->whereRaw('amount_raised >= amount_to_raise * 0.8')
->whereRaw('amount_raised < amount_to_raise')
->count();
return [
'live' => Tab::make('Live')
->icon('heroicon-o-signal')
->modifyQueryUsing(fn (Builder $q) => $q
->where('status', 'confirmed')
->where('is_accepting_donations', true)
),
'needs_help' => Tab::make('Needs Outreach')
->icon('heroicon-o-hand-raised')
->badge($needsHelp > 0 ? $needsHelp : null)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $q) => $q
->where('status', 'confirmed')
->where('is_accepting_donations', true)
->where('amount_raised', 0)
->where('created_at', '<', now()->subDays(7))
->where('created_at', '>', now()->subDays(90))
),
'almost' => Tab::make('Almost There')
->icon('heroicon-o-fire')
->badge($almostThere > 0 ? $almostThere : null)
->badgeColor('warning')
->modifyQueryUsing(fn (Builder $q) => $q
->where('status', 'confirmed')
->where('is_accepting_donations', true)
->where('amount_raised', '>', 0)
->whereRaw('amount_raised >= amount_to_raise * 0.8')
->whereRaw('amount_raised < amount_to_raise')
),
'hit_target' => Tab::make('Target Reached')
->icon('heroicon-o-trophy')
->modifyQueryUsing(fn (Builder $q) => $q
->where('status', 'confirmed')
->where('amount_raised', '>', 0)
->whereRaw('amount_raised >= amount_to_raise')
),
'all' => Tab::make('Everything')
->icon('heroicon-o-squares-2x2'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
use App\Filament\Resources\ApprovalQueueResource;
use App\Models\ApprovalQueue;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListApprovalQueues extends ListRecords
{
protected static string $resource = ApprovalQueueResource::class;
public function getHeading(): string
{
return 'Fundraiser Review';
}
public function getSubheading(): string
{
$pending = ApprovalQueue::where('status', 'pending')->count();
if ($pending === 0) return 'All caught up — no fundraisers waiting for review.';
return "{$pending} fundraisers waiting for your review.";
}
public function getTabs(): array
{
$pending = ApprovalQueue::where('status', 'pending')->count();
return [
'pending' => Tab::make('Needs Review')
->icon('heroicon-o-clock')
->badge($pending > 0 ? $pending : null)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'pending')),
'approved' => Tab::make('Approved')
->icon('heroicon-o-check-circle')
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'confirmed')),
'rejected' => Tab::make('Rejected')
->icon('heroicon-o-x-circle')
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'change_requested')),
'all' => Tab::make('All')
->icon('heroicon-o-squares-2x2'),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use App\Models\Customer;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
public function getHeading(): string
{
return 'Donors';
}
public function getSubheading(): string
{
return 'Search by name, email, or phone number. Click a donor to see their full history.';
}
public function getTabs(): array
{
return [
'all' => Tab::make('All Donors')
->icon('heroicon-o-users'),
'monthly' => Tab::make('Monthly Supporters')
->icon('heroicon-o-arrow-path')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('scheduledGivingDonations', fn ($q) => $q->where('is_active', true))
),
'major' => Tab::make('Major Donors')
->icon('heroicon-o-star')
->modifyQueryUsing(fn (Builder $query) => $query
->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');
})
),
'recent' => Tab::make('New (30 days)')
->icon('heroicon-o-sparkles')
->modifyQueryUsing(fn (Builder $query) => $query
->where('created_at', '>=', now()->subDays(30))
),
];
}
protected function getHeaderActions(): array
{
return [];
}
}

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

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
use App\Filament\Resources\ScheduledGivingDonationResource;
use App\Models\ScheduledGivingDonation;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListScheduledGivingDonations extends ListRecords
{
protected static string $resource = ScheduledGivingDonationResource::class;
public function getHeading(): string
{
return 'Regular Giving';
}
public function getSubheading(): string
{
$active = ScheduledGivingDonation::where('is_active', true)->count();
return "{$active} people giving every month.";
}
public function getTabs(): array
{
$active = ScheduledGivingDonation::where('is_active', true)->count();
$inactive = ScheduledGivingDonation::where('is_active', false)->count();
return [
'active' => Tab::make('Active')
->icon('heroicon-o-check-circle')
->badge($active)
->badgeColor('success')
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', true)),
'cancelled' => Tab::make('Cancelled')
->icon('heroicon-o-x-circle')
->badge($inactive > 0 ? $inactive : null)
->badgeColor('gray')
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', false)),
'all' => Tab::make('All')
->icon('heroicon-o-squares-2x2'),
];
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Reassign every Filament resource to the correct navigation group.
SIDEBAR STRUCTURE:
Daily (always open, no collapse)
├ Donors — "Find a donor, help them"
├ Donations — "See what came in, investigate issues"
└ Regular Giving — "Monthly supporters"
Fundraising
├ Review Queue (84 pending badge)
├ All Fundraisers
└ Scheduled Campaigns (3 only)
Setup (collapsed by default — rarely touched)
├ Causes (30)
├ Countries (12)
├ URL Builder
├ Settings
├ Users
├ Activity Log
└ (hidden): Snowdon, Campaign Regs, WOH, Engage dims, Fund dims
"""
import re, os
BASE = '/home/forge/app.charityright.org.uk'
changes = {
# ── DAILY (top 3 — the only pages staff use every day) ──
'app/Filament/Resources/CustomerResource.php': {
'navigationGroup': 'Daily',
'navigationIcon': 'heroicon-o-user-circle',
'navigationSort': 1,
'navigationLabel': 'Donors',
},
'app/Filament/Resources/DonationResource.php': {
'navigationGroup': 'Daily',
'navigationIcon': 'heroicon-o-banknotes',
'navigationSort': 2,
'navigationLabel': 'Donations',
},
'app/Filament/Resources/ScheduledGivingDonationResource.php': {
'navigationGroup': 'Daily',
'navigationIcon': 'heroicon-o-arrow-path',
'navigationSort': 3,
'navigationLabel': 'Regular Giving',
},
# ── FUNDRAISING ──
'app/Filament/Resources/ApprovalQueueResource.php': {
'navigationGroup': 'Fundraising',
'navigationIcon': 'heroicon-o-shield-check',
'navigationSort': 1,
'navigationLabel': 'Review Queue',
},
'app/Filament/Resources/AppealResource.php': {
'navigationGroup': 'Fundraising',
'navigationIcon': 'heroicon-o-hand-raised',
'navigationSort': 2,
'navigationLabel': 'All Fundraisers',
},
'app/Filament/Resources/ScheduledGivingCampaignResource.php': {
'navigationGroup': 'Fundraising',
'navigationIcon': 'heroicon-o-calendar',
'navigationSort': 3,
'navigationLabel': 'Giving Campaigns',
},
# ── SETUP (collapsed, rarely used) ──
'app/Filament/Resources/DonationTypeResource.php': {
'navigationGroup': 'Setup',
'navigationIcon': 'heroicon-o-tag',
'navigationSort': 1,
'navigationLabel': 'Causes',
},
'app/Filament/Resources/DonationCountryResource.php': {
'navigationGroup': 'Setup',
'navigationIcon': 'heroicon-o-globe-alt',
'navigationSort': 2,
'navigationLabel': 'Countries',
},
'app/Filament/Resources/UserResource.php': {
'navigationGroup': 'Setup',
'navigationIcon': 'heroicon-o-users',
'navigationSort': 3,
'navigationLabel': 'Admin Users',
},
'app/Filament/Resources/EventLogResource.php': {
'navigationGroup': 'Setup',
'navigationIcon': 'heroicon-o-exclamation-triangle',
'navigationSort': 4,
'navigationLabel': 'Activity Log',
},
}
# Resources to HIDE from navigation entirely (dead data, 0 recent activity)
hide = [
'app/Filament/Resources/SnowdonRegistrationResource.php',
'app/Filament/Resources/CampaignRegistrationResource.php',
'app/Filament/Resources/WOHMessageResource.php',
'app/Filament/Resources/EngageAttributionDimensionResource.php',
'app/Filament/Resources/EngageFundDimensionResource.php',
]
# Pages
page_changes = {
'app/Filament/Pages/DonationURLBuilder.php': {
'navigationGroup': 'Setup',
'navigationSort': 5,
'navigationLabel': 'URL Builder',
},
'app/Filament/Pages/Settings.php': {
'navigationGroup': 'Setup',
'navigationSort': 6,
},
}
def update_static_property(content, prop, value):
"""Replace a static property value in a PHP class."""
# Match: protected static ?string $prop = 'old_value';
# or: protected static ?int $prop = 123;
pattern = rf"(protected\s+static\s+\?(?:string|int)\s+\${prop}\s*=\s*)('[^']*'|\d+)(\s*;)"
if isinstance(value, int):
replacement = rf"\g<1>{value}\3"
else:
replacement = rf"\g<1>'{value}'\3"
new_content, count = re.subn(pattern, replacement, content)
return new_content, count
def apply_changes(filepath, props):
full = os.path.join(BASE, filepath)
with open(full, 'r') as f:
content = f.read()
for prop, value in props.items():
content, count = update_static_property(content, prop, value)
if count == 0:
print(f" WARNING: {prop} not found in {filepath}")
with open(full, 'w') as f:
f.write(content)
print(f"Updated: {filepath}")
def hide_from_nav(filepath):
full = os.path.join(BASE, filepath)
with open(full, 'r') as f:
content = f.read()
# Add shouldRegisterNavigation = false if not present
if 'shouldRegisterNavigation' not in content:
# Insert after the class opening
content = content.replace(
'protected static ?string $navigationIcon',
'protected static bool $shouldRegisterNavigation = false;\n\n protected static ?string $navigationIcon'
)
with open(full, 'w') as f:
f.write(content)
print(f"Hidden: {filepath}")
else:
print(f"Already hidden: {filepath}")
# Apply all changes
print("=== UPDATING NAVIGATION ===")
for filepath, props in changes.items():
apply_changes(filepath, props)
print("\n=== HIDING DEAD PAGES ===")
for filepath in hide:
hide_from_nav(filepath)
print("\n=== UPDATING PAGES ===")
for filepath, props in page_changes.items():
apply_changes(filepath, props)
print("\nDone!")