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

@@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"
import {
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy,
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap
} from "lucide-react"
/**
@@ -29,6 +29,7 @@ interface OrgSettings {
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
bankAccountName: string; refPrefix: string; primaryColor: string
gcAccessToken: string; gcEnvironment: string; orgType: string
stripeSecretKey: string; stripeWebhookSecret: string
}
interface TeamMember {
@@ -148,6 +149,7 @@ export default function SettingsPage() {
const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName)
const whatsappReady = waStatus === "CONNECTED"
const charityReady = !!settings.name
const stripeReady = !!settings.stripeSecretKey
return (
<div className="space-y-8">
@@ -161,11 +163,12 @@ export default function SettingsPage() {
{/* ── Readiness bar — "Am I set up?" ── */}
<div className="bg-[#111827] p-5">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Setup progress</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-700">
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-700">
{[
{ label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" },
{ label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "Not set" },
{ label: "Charity name", ready: charityReady, detail: charityReady ? settings.name : "Not set" },
{ label: "Card payments", ready: stripeReady, detail: stripeReady ? "Stripe connected" : "Not set up" },
{ label: "Team", ready: team.length > 0, detail: `${team.length} member${team.length !== 1 ? "s" : ""}` },
].map(item => (
<div key={item.label} className="bg-[#111827] p-3">
@@ -429,7 +432,95 @@ export default function SettingsPage() {
</div>
</section>
{/* ── 4. Charity details ── */}
{/* ── 4. Card payments (Stripe) ── */}
<section className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
<div className={`w-8 h-8 flex items-center justify-center shrink-0 ${stripeReady ? "bg-[#635BFF]/10" : "bg-gray-100"}`}>
<CreditCard className={`h-4 w-4 ${stripeReady ? "text-[#635BFF]" : "text-gray-400"}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-[#111827]">Card payments</h2>
{stripeReady && <div className="w-2 h-2 bg-[#635BFF]" />}
</div>
<p className="text-[10px] text-gray-500">Let donors pay by card using your Stripe account</p>
</div>
</div>
<div className="p-5 space-y-4">
<div className="border-l-2 border-[#635BFF] pl-3 text-xs text-gray-500 space-y-1">
<p>Connect your <strong className="text-gray-700">own Stripe account</strong> to accept card payments. Money goes directly to your Stripe balance we never touch it.</p>
<p>When connected, donors see a third payment option: <strong className="text-gray-700">Bank Transfer</strong>, <strong className="text-gray-700">Direct Debit</strong>, and <strong className="text-gray-700">Card Payment</strong>.</p>
</div>
{!stripeReady && (
<div className="bg-[#F9FAFB] border border-gray-100 p-4 space-y-3">
<p className="text-xs font-bold text-[#111827]">How to get your Stripe API key</p>
<ol className="text-[10px] text-gray-500 space-y-1.5 list-decimal list-inside">
<li>Go to <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-[#635BFF] font-bold hover:underline">dashboard.stripe.com/apikeys</a></li>
<li>Copy your <strong className="text-gray-700">Secret key</strong> (starts with <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_live_</code> or <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_test_</code>)</li>
<li>Paste it below</li>
</ol>
</div>
)}
<Field
label="Stripe secret key"
value={settings.stripeSecretKey || ""}
onChange={v => update("stripeSecretKey", v)}
placeholder="sk_live_... or sk_test_..."
type="password"
/>
<details className="text-xs">
<summary className="text-gray-400 cursor-pointer hover:text-gray-600 font-bold">Webhook setup (optional for auto-confirmation)</summary>
<div className="mt-3 space-y-3 pt-3 border-t border-gray-100">
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-500 space-y-1">
<p>If you want pledges to auto-confirm when the card is charged:</p>
<ol className="list-decimal list-inside space-y-0.5">
<li>In Stripe Dashboard Developers Webhooks</li>
<li>Add endpoint: <code className="bg-gray-100 px-1 py-0.5 font-mono">{typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook</code></li>
<li>Select events: <code className="bg-gray-100 px-1 py-0.5 font-mono">checkout.session.completed</code></li>
<li>Copy the signing secret and paste below</li>
</ol>
</div>
<Field
label="Webhook signing secret"
value={settings.stripeWebhookSecret || ""}
onChange={v => update("stripeWebhookSecret", v)}
placeholder="whsec_..."
type="password"
/>
<p className="text-[10px] text-gray-400">Without webhooks, you confirm card payments manually in the Money page (same as bank transfers).</p>
</div>
</details>
{stripeReady && (
<div className="bg-[#635BFF]/5 border border-[#635BFF]/20 p-3 flex items-start gap-2">
<Zap className="h-4 w-4 text-[#635BFF] mt-0.5 shrink-0" />
<div>
<p className="text-xs font-bold text-[#635BFF]">Card payments are live</p>
<p className="text-[10px] text-gray-500 mt-0.5">Donors will see &ldquo;Pay by Card&rdquo; as an option. They&apos;ll be redirected to Stripe&apos;s checkout page. Apple Pay and Google Pay work automatically.</p>
</div>
</div>
)}
<div className="flex items-center justify-between pt-1">
<p className="text-[10px] text-gray-400">{stripeReady ? "Key stored securely · never shown to donors" : "Optional — bank transfer works without this"}</p>
<SaveBtn
section="stripe"
saving={saving}
saved={saved}
onSave={() => save("stripe", {
stripeSecretKey: settings.stripeSecretKey || "",
stripeWebhookSecret: settings.stripeWebhookSecret || "",
})}
/>
</div>
</div>
</section>
{/* ── 5. Charity details ── */}
<section className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
<div className="w-8 h-8 bg-gray-100 flex items-center justify-center shrink-0">