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

@@ -11,6 +11,7 @@
"@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"@types/bcryptjs": "^2.4.6",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
@@ -28,6 +29,7 @@
"react": "^18",
"react-dom": "^18",
"sharp": "^0.34.5",
"stripe": "^20.4.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
@@ -1792,6 +1794,15 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stripe/stripe-js": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.9.0.tgz",
"integrity": "sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -7781,6 +7792,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.4.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz",
"integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",

View File

@@ -12,6 +12,7 @@
"@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"@types/bcryptjs": "^2.4.6",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
@@ -29,6 +30,7 @@
"react": "^18",
"react-dom": "^18",
"sharp": "^0.34.5",
"stripe": "^20.4.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},

View File

@@ -23,6 +23,8 @@ model Organization {
primaryColor String @default("#1e40af")
gcAccessToken String?
gcEnvironment String @default("sandbox")
stripeSecretKey String?
stripeWebhookSecret String?
whatsappConnected Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -16,7 +16,7 @@ export async function GET(
if (token === "demo") {
const event = await prisma.event.findFirst({
where: { status: "active" },
include: { organization: { select: { name: true } } },
include: { organization: { select: { name: true, stripeSecretKey: true } } },
orderBy: { createdAt: "asc" },
})
if (!event) {
@@ -32,7 +32,7 @@ export async function GET(
externalUrl: event.externalUrl || null,
externalPlatform: event.externalPlatform || null,
zakatEligible: event.zakatEligible || false,
hasStripe: !!event.organization.stripeSecretKey,
})
}
@@ -41,7 +41,7 @@ export async function GET(
include: {
event: {
include: {
organization: { select: { name: true } },
organization: { select: { name: true, stripeSecretKey: true } },
},
},
},
@@ -67,7 +67,7 @@ export async function GET(
externalUrl: qrSource.event.externalUrl || null,
externalPlatform: qrSource.event.externalPlatform || null,
zakatEligible: qrSource.event.zakatEligible || false,
hasStripe: !!qrSource.event.organization.stripeSecretKey,
})
} catch (error) {
console.error("QR resolve error:", error)

View File

@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
primaryColor: org.primaryColor,
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
gcEnvironment: org.gcEnvironment,
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
orgType: org.orgType || "charity",
})
} catch (error) {
@@ -85,7 +87,7 @@ export async function PATCH(request: NextRequest) {
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
const body = await request.json()
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"]
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: Record<string, any> = {}
for (const key of stringKeys) {

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createCheckoutSession } from "@/lib/stripe"
import { generateReference } from "@/lib/reference"
/**
* POST /api/stripe/checkout
*
* Creates a Stripe Checkout Session using the ORG'S own Stripe account.
* The money goes directly to the charity's Stripe balance.
* PNPL never touches the funds.
*
* If the org hasn't connected Stripe, returns { mode: "not_configured" }.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Get event + org (with Stripe key)
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { organization: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const org = event.organization
// Check if org has Stripe configured
if (!org.stripeSecretKey) {
return NextResponse.json({ mode: "not_configured" })
}
// Generate reference
let reference = ""
let attempts = 0
while (attempts < 10) {
reference = generateReference(org.refPrefix || "PNPL", amountPence)
const exists = await prisma.pledge.findUnique({ where: { reference } })
if (!exists) break
attempts++
}
// Create pledge in DB
const pledge = await prisma.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail: "card",
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid: giftAid || false,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail: "card" },
},
})
// Create Stripe Checkout Session using the org's key
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const session = await createCheckoutSession({
stripeSecretKey: org.stripeSecretKey,
amountPence,
currency: "GBP",
pledgeId: pledge.id,
reference,
eventName: event.name,
organizationName: org.name,
donorEmail: donorEmail || undefined,
successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`,
})
if (session) {
// Save Stripe session reference
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: "stripe",
providerRef: session.sessionId,
amountPence,
status: "pending",
matchedBy: "auto",
},
})
await prisma.pledge.update({
where: { id: pledge.id },
data: { status: "initiated" },
})
return NextResponse.json({
mode: "live",
pledgeId: pledge.id,
reference,
checkoutUrl: session.checkoutUrl,
sessionId: session.sessionId,
})
}
// Stripe call failed — clean up the pledge
await prisma.pledge.delete({ where: { id: pledge.id } })
return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 })
} catch (error) {
console.error("Stripe checkout error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { constructWebhookEvent } from "@/lib/stripe"
/**
* POST /api/stripe/webhook
*
* Handles Stripe webhooks. Each org has their own webhook secret.
* We look up the org by the pledge_id in the session metadata.
*
* The org configures their Stripe webhook endpoint to point here:
* https://pledge.quikcue.com/api/stripe/webhook
*
* Events handled:
* - checkout.session.completed → mark pledge as paid
* - checkout.session.expired → mark pledge as cancelled
*/
export async function POST(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "DB not configured" }, { status: 503 })
}
const body = await request.text()
const signature = request.headers.get("stripe-signature") || ""
// We need to try all orgs' webhook secrets to find the right one
// In practice, most deployments have 1-5 orgs with Stripe configured
const orgsWithStripe = await prisma.organization.findMany({
where: { stripeWebhookSecret: { not: null } },
select: { id: true, stripeWebhookSecret: true },
})
let event = null
let matchedOrgId: string | null = null
for (const org of orgsWithStripe) {
if (!org.stripeWebhookSecret) continue
const parsed = constructWebhookEvent(body, signature, org.stripeWebhookSecret)
if (parsed) {
event = parsed
matchedOrgId = org.id
break
}
}
if (!event || !matchedOrgId) {
return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 })
}
// Handle checkout.session.completed
if (event.type === "checkout.session.completed") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = event.data.object as any
const pledgeId = session.metadata?.pledge_id
if (pledgeId) {
// Verify this pledge belongs to the matched org
const pledge = await prisma.pledge.findFirst({
where: { id: pledgeId, organizationId: matchedOrgId },
})
if (pledge) {
await prisma.pledge.update({
where: { id: pledgeId },
data: { status: "paid", paidAt: new Date() },
})
// Update payment record
await prisma.payment.updateMany({
where: { pledgeId, provider: "stripe" },
data: { status: "completed" },
})
// Skip remaining reminders
await prisma.reminder.updateMany({
where: { pledgeId, status: "pending" },
data: { status: "skipped" },
})
// Log
await prisma.analyticsEvent.create({
data: {
eventType: "activity.stripe_payment_confirmed",
pledgeId,
eventId: pledge.eventId,
metadata: { provider: "stripe", sessionId: session.id },
},
})
}
}
}
// Handle checkout.session.expired
if (event.type === "checkout.session.expired") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = event.data.object as any
const pledgeId = session.metadata?.pledge_id
if (pledgeId) {
// Reset to "new" so they can try again
await prisma.pledge.updateMany({
where: { id: pledgeId, organizationId: matchedOrgId, status: "initiated" },
data: { status: "new" },
})
await prisma.payment.updateMany({
where: { pledgeId, provider: "stripe", status: "pending" },
data: { status: "expired" },
})
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("Stripe webhook error:", error)
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
}
}

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">

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 (

View File

@@ -0,0 +1,165 @@
"use client"
import { useState } from "react"
import { CreditCard, Lock, Loader2, ExternalLink } from "lucide-react"
/**
* Card Payment Step
*
* Redirects to the charity's own Stripe Checkout.
* No card form on our side — Stripe handles PCI compliance.
* The money goes directly to the charity's Stripe account.
*
* Flow:
* 1. Collect donor name + email (for receipt)
* 2. POST /api/stripe/checkout → get checkoutUrl
* 3. Redirect to Stripe Checkout
* 4. Stripe redirects back to /p/success
*/
interface Props {
amount: number
eventName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId }: Props) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [processing, setProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const pounds = (amount / 100).toFixed(2)
const isReady = name.trim().length > 1 && email.includes("@")
const handleSubmit = async () => {
if (!isReady) return
setProcessing(true)
setError(null)
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: name.trim(),
donorEmail: email.trim(),
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.checkoutUrl) {
// Redirect to Stripe Checkout (the charity's Stripe account)
window.location.href = data.checkoutUrl
return
}
if (data.mode === "not_configured") {
setError("This charity hasn't set up card payments yet. Please use bank transfer instead.")
setProcessing(false)
return
}
setError("Something went wrong creating the payment. Please try bank transfer instead.")
setProcessing(false)
} catch {
setError("Connection error. Please try again.")
setProcessing(false)
}
}
return (
<div className="max-w-md mx-auto pt-2 space-y-6 animate-fade-up">
<div className="text-center space-y-2">
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
Pay by card
</h1>
<p className="text-base text-muted-foreground">
£{pounds} for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
<div className="border-2 border-gray-200 bg-white p-5 space-y-4">
{/* Name */}
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Your name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Full name"
autoComplete="name"
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
/>
</div>
{/* Email */}
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Email</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
inputMode="email"
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
/>
<p className="text-[10px] text-gray-400 mt-1">For your payment receipt</p>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 border-2 border-gray-200 p-3 cursor-pointer hover:border-[#1E40AF]/30 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={e => setGiftAid(e.target.checked)}
className="mt-0.5 h-4 w-4 border-gray-300"
/>
<div>
<span className="text-sm font-bold text-[#111827]">Add Gift Aid</span>
<p className="text-[10px] text-gray-500 mt-0.5">Boost your donation by 25% at no cost to you. You must be a UK taxpayer.</p>
</div>
</label>
</div>
{error && (
<div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>
)}
{/* Pay button */}
<button
onClick={handleSubmit}
disabled={!isReady || processing}
className="w-full bg-[#111827] px-4 py-3.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-2"
>
{processing ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Redirecting to Stripe</>
) : (
<><CreditCard className="h-4 w-4" /> Pay £{pounds} by card</>
)}
</button>
<div className="flex items-center justify-center gap-4 text-[10px] text-gray-400">
<span className="flex items-center gap-1"><Lock className="h-3 w-3" /> Secure checkout</span>
<span className="flex items-center gap-1"><ExternalLink className="h-3 w-3" /> Powered by Stripe</span>
</div>
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-400">
<p>You&apos;ll be redirected to Stripe&apos;s secure checkout page. The payment goes directly to <strong className="text-gray-600">{eventName}</strong>&apos;s account. Visa, Mastercard, Amex, Apple Pay, and Google Pay accepted.</p>
</div>
</div>
)
}

View File

@@ -61,6 +61,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
const railLabels: Record<string, string> = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
}
const deferredMessage = isDeferred
@@ -72,6 +73,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
const nextStepMessages: Record<string, string> = {
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`,
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
}
// Send WhatsApp receipt if phone provided
@@ -138,7 +140,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
<h1 className="text-2xl font-black text-gray-900">
{isDeferred
? "Pledge Locked In!"
: rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous support of{" "}

View File

@@ -1,13 +1,14 @@
"use client"
import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react"
import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react"
interface Props {
onSelect: (rail: "bank" | "gocardless") => void
onSelect: (rail: "bank" | "gocardless" | "card") => void
amount: number
hasStripe?: boolean
}
export function PaymentStep({ onSelect, amount }: Props) {
export function PaymentStep({ onSelect, amount, hasStripe }: Props) {
const pounds = (amount / 100).toFixed(0)
const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0)
@@ -40,6 +41,20 @@ export function PaymentStep({ onSelect, amount }: Props) {
highlight: false,
benefits: ["No action needed", "DD Guarantee"],
},
...(hasStripe ? [{
id: "card" as const,
icon: CreditCard,
title: "Card Payment",
subtitle: "Visa, Mastercard, Amex, Apple Pay",
tag: "Instant",
tagClass: "bg-purple-100 text-purple-700",
detail: "Secure checkout via Stripe. Receipt emailed instantly.",
fee: "Card fees apply",
feeClass: "text-muted-foreground",
iconBg: "bg-midnight",
highlight: false,
benefits: ["Instant confirmation", "Apple Pay & Google Pay"],
}] : []),
]
return (

View File

@@ -74,13 +74,15 @@ function SuccessContent() {
}
const railLabels: Record<string, string> = {
gocardless: "Direct Debit",
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
}
const nextStepMessages: Record<string, string> = {
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
bank: "Please complete the bank transfer using the reference provided.",
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
}
return (

View File

@@ -663,6 +663,13 @@ export default function HomePage() {
desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.",
tag: "Fundraising",
},
{
name: "Stripe",
logo: "/images/logos/stripe.svg",
color: "#635BFF",
desc: "Connect your own Stripe account. Donors pay by card, Apple Pay, or Google Pay. Money goes to you.",
tag: "Card payments",
},
{
name: "UK Bank Transfer",
logo: null,

View File

@@ -112,6 +112,7 @@ export default function PrivacyPage() {
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
<ul className="list-disc pl-5 space-y-1">
<li><strong>GoCardless</strong> for Direct Debit mandate processing (if enabled by charity)</li>
<li><strong>Stripe</strong> for card payment processing (if enabled by charity the charity connects their own Stripe account)</li>
<li><strong>OpenAI</strong> for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI only anonymised context.</li>
</ul>

View File

@@ -38,7 +38,7 @@ export default function TermsPage() {
<section>
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account or GoCardless. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account, GoCardless, or your own Stripe account. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
</section>
<section>

View File

@@ -0,0 +1,100 @@
import Stripe from "stripe"
/**
* Stripe integration — per-org.
*
* Each charity connects their OWN Stripe account.
* PNPL never touches the money. The charity's Stripe processes the payment,
* the money lands in the charity's Stripe balance.
*
* The secret key is stored in Organization.stripeSecretKey.
*/
/**
* Create a Stripe client using the org's own secret key.
* Returns null if no key is configured.
*/
export function getStripeForOrg(secretKey: string | null | undefined): Stripe | null {
if (!secretKey || secretKey.trim() === "") return null
return new Stripe(secretKey, {
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
typescript: true,
})
}
/**
* Create a Stripe Checkout Session using the org's Stripe account.
* Returns the checkout URL to redirect the donor to.
*/
export async function createCheckoutSession(opts: {
stripeSecretKey: string
amountPence: number
currency: string
pledgeId: string
reference: string
eventName: string
organizationName: string
donorEmail?: string
successUrl: string
cancelUrl: string
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
const stripe = getStripeForOrg(opts.stripeSecretKey)
if (!stripe) return null
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: opts.currency.toLowerCase(),
unit_amount: opts.amountPence,
product_data: {
name: `Donation — ${opts.eventName}`,
description: `Ref: ${opts.reference} to ${opts.organizationName}`,
},
},
quantity: 1,
},
],
customer_email: opts.donorEmail || undefined,
metadata: {
pledge_id: opts.pledgeId,
reference: opts.reference,
},
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
})
return {
sessionId: session.id,
checkoutUrl: session.url!,
}
} catch (error) {
console.error("Stripe checkout error:", error)
return null
}
}
/**
* Verify a Stripe webhook signature using the org's webhook secret.
*/
export function constructWebhookEvent(
body: string | Buffer,
signature: string,
webhookSecret: string
): Stripe.Event | null {
// We need a Stripe instance just for webhook verification — use a dummy key
// The webhook secret is what matters for signature verification
try {
const stripe = new Stripe("sk_dummy_for_webhook_verify", {
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
})
return stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (error) {
console.error("Stripe webhook signature verification failed:", error)
return null
}
}