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

@@ -9,9 +9,10 @@ import { IdentityStep } from "./steps/identity-step"
import { ConfirmationStep } from "./steps/confirmation-step"
import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { ExternalRedirectStep } from "./steps/external-redirect-step"
import { CardPaymentStep } from "./steps/card-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step"
export type Rail = "bank" | "gocardless"
export type Rail = "bank" | "gocardless" | "card"
export interface PledgeData {
amountPence: number
@@ -44,6 +45,7 @@ interface EventInfo {
externalUrl: string | null
externalPlatform: string | null
zakatEligible: boolean
hasStripe: boolean
}
/*
@@ -142,7 +144,7 @@ export default function PledgePage() {
// Step 2: Payment method selected (only for "now" self-payment mode)
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
setStep(rail === "bank" ? 3 : 8)
setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8)
}
// Submit pledge (from identity step, or card/DD steps)
@@ -225,7 +227,7 @@ export default function PledgePage() {
const steps: Record<number, React.ReactNode> = {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
5: pledgeResult && (
@@ -242,19 +244,20 @@ export default function PledgePage() {
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
/>
),
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
7: pledgeResult && <ExternalRedirectStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} externalUrl={eventInfo?.externalUrl || ""} externalPlatform={eventInfo?.externalPlatform} donorPhone={pledgeData.donorPhone} />,
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
}
const backableSteps = new Set([1, 2, 3, 6, 8])
const getBackStep = (s: number): number => {
if (s === 8) return 2 // DD → payment method
if (s === 6 || s === 8) return 2 // card/DD → payment method
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
if (s === 3) return 2 // bank identity → payment method
return s - 1
}
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 7: 100, 8: 60 }
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 }
const progressPercent = progressMap[step] || 10
return (