Files
calvana/temp_files/v4/nav_changes.py
Omair Saleh 3b46222118 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)
2026-03-04 22:46:08 +08:00

184 lines
5.7 KiB
Python

#!/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!")