Remove dead Stripe integration

Stripe was wired up but never used:
- No STRIPE_SECRET_KEY in .env
- Card payment step had a 'simulated fallback' that pretended to charge
- Stripe fees (1.4% + 20p) contradict '100% goes to charity' brand promise
- Bank transfer is the primary rail, GoCardless (DD) is the secondary

Removed:
- src/lib/stripe.ts (Stripe client, checkout sessions, webhooks)
- src/app/api/stripe/checkout/route.ts
- src/app/api/stripe/webhook/route.ts
- src/app/p/[token]/steps/card-payment-step.tsx (263 lines)
- 'stripe' and '@stripe/stripe-js' npm packages
- Card option from PaymentStep (payment-step.tsx)
- Card references from confirmation-step.tsx, success/page.tsx
- Stripe from landing page integrations grid
- Stripe from privacy policy sub-processors
- Stripe from terms of service payment references

Type Rail changed: 'bank' | 'gocardless' | 'card' → 'bank' | 'gocardless'
Pledge flow bundle: 19.5kB → 18.2kB (-1.3kB)

Payment options donors now see:
1. Bank Transfer (recommended, zero fees)
2. Direct Debit via GoCardless (1% + 20p, hassle-free)
This commit is contained in:
2026-03-04 22:29:49 +08:00
parent f75cc29980
commit 62be460643
13 changed files with 12 additions and 716 deletions

View File

@@ -1,118 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createCheckoutSession } from "@/lib/stripe"
import { generateReference } from "@/lib/reference"
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
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
// 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" },
},
})
// Try real Stripe checkout
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const session = await createCheckoutSession({
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,
})
}
// Fallback: no Stripe configured — return pledge for simulated flow
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
} catch (error) {
console.error("Stripe checkout error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -1,88 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { constructWebhookEvent } from "@/lib/stripe"
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const signature = request.headers.get("stripe-signature") || ""
const event = constructWebhookEvent(body, signature)
if (!event) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as { id: string; metadata: Record<string, string>; payment_status: string }
const pledgeId = session.metadata?.pledge_id
if (pledgeId && session.payment_status === "paid") {
await prisma.pledge.update({
where: { id: pledgeId },
data: {
status: "paid",
paidAt: new Date(),
},
})
// Update payment record
await prisma.payment.updateMany({
where: {
pledgeId,
providerRef: session.id,
},
data: {
status: "confirmed",
receivedAt: new Date(),
},
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "payment_matched",
pledgeId,
metadata: { provider: "stripe", sessionId: session.id },
},
})
}
break
}
case "payment_intent.succeeded": {
const pi = event.data.object as { id: string; metadata: Record<string, string> }
const pledgeId = pi.metadata?.pledge_id
if (pledgeId) {
await prisma.pledge.update({
where: { id: pledgeId },
data: {
status: "paid",
paidAt: new Date(),
},
})
}
break
}
case "payment_intent.payment_failed": {
const pi = event.data.object as { id: string; metadata: Record<string, string> }
const pledgeId = pi.metadata?.pledge_id
if (pledgeId) {
await prisma.pledge.update({
where: { id: pledgeId },
data: { status: "overdue" },
})
}
break
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("Stripe webhook error:", error)
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
}
}

View File

@@ -9,10 +9,9 @@ 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" | "card"
export type Rail = "bank" | "gocardless"
export interface PledgeData {
amountPence: number
@@ -143,7 +142,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 : rail === "card" ? 6 : 8)
setStep(rail === "bank" ? 3 : 8)
}
// Submit pledge (from identity step, or card/DD steps)
@@ -243,20 +242,19 @@ 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 === 6 || s === 8) return 2 // card/DD → payment method
if (s === 8) return 2 // 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, 6: 60, 7: 100, 8: 60 }
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 7: 100, 8: 60 }
const progressPercent = progressMap[step] || 10
return (

View File

@@ -1,305 +0,0 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { CreditCard, Lock } from "lucide-react"
interface Props {
amount: number
eventName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 16)
return digits.replace(/(\d{4})(?=\d)/g, "$1 ")
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 4)
if (digits.length >= 3) return digits.slice(0, 2) + "/" + digits.slice(2)
return digits
}
function luhnCheck(num: string): boolean {
const digits = num.replace(/\D/g, "")
if (digits.length < 13) return false
let sum = 0
let alt = false
for (let i = digits.length - 1; i >= 0; i--) {
let n = parseInt(digits[i], 10)
if (alt) {
n *= 2
if (n > 9) n -= 9
}
sum += n
alt = !alt
}
return sum % 10 === 0
}
function getCardBrand(num: string): string {
const d = num.replace(/\D/g, "")
if (/^4/.test(d)) return "Visa"
if (/^5[1-5]/.test(d) || /^2[2-7]/.test(d)) return "Mastercard"
if (/^3[47]/.test(d)) return "Amex"
if (/^6(?:011|5)/.test(d)) return "Discover"
return ""
}
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId, onComplete }: Props) {
const [cardNumber, setCardNumber] = useState("")
const [expiry, setExpiry] = useState("")
const [cvc, setCvc] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [processing, setProcessing] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const expiryRef = useRef<HTMLInputElement>(null)
const cvcRef = useRef<HTMLInputElement>(null)
const pounds = (amount / 100).toFixed(2)
const brand = getCardBrand(cardNumber)
const validate = (): boolean => {
const errs: Record<string, string> = {}
const digits = cardNumber.replace(/\D/g, "")
if (!luhnCheck(digits)) errs.card = "Invalid card number"
if (digits.length < 13) errs.card = "Card number too short"
const expiryDigits = expiry.replace(/\D/g, "")
if (expiryDigits.length < 4) {
errs.expiry = "Invalid expiry"
} else {
const month = parseInt(expiryDigits.slice(0, 2), 10)
const year = parseInt("20" + expiryDigits.slice(2, 4), 10)
const now = new Date()
if (month < 1 || month > 12) errs.expiry = "Invalid month"
else if (year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth() + 1))
errs.expiry = "Card expired"
}
if (cvc.length < 3) errs.cvc = "Invalid CVC"
if (!name.trim()) errs.name = "Name required"
if (!email.includes("@")) errs.email = "Valid email required"
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSubmit = async () => {
if (!validate()) return
setProcessing(true)
// Try real Stripe Checkout first
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.checkoutUrl) {
// Redirect to Stripe Checkout
window.location.href = data.checkoutUrl
return
}
// Simulated mode — fall through to onComplete
} catch {
// Fall through to simulated
}
// Simulated fallback
await new Promise((r) => setTimeout(r, 1500))
onComplete({
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
})
}
const handleCardNumberChange = (value: string) => {
const formatted = formatCardNumber(value)
setCardNumber(formatted)
// Auto-advance to expiry when complete
if (formatted.replace(/\s/g, "").length === 16) {
expiryRef.current?.focus()
}
}
const handleExpiryChange = (value: string) => {
const formatted = formatExpiry(value)
setExpiry(formatted)
if (formatted.length === 5) {
cvcRef.current?.focus()
}
}
const isReady = cardNumber.replace(/\D/g, "").length >= 13 && expiry.length === 5 && cvc.length >= 3 && name.trim() && email.includes("@")
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Pay by Card</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Card form */}
<div className="rounded-lg border-2 border-gray-200 bg-white p-5 space-y-4">
{/* Card number */}
<div className="space-y-2">
<Label htmlFor="card-number">Card Number</Label>
<div className="relative">
<CreditCard className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
id="card-number"
placeholder="1234 5678 9012 3456"
value={cardNumber}
onChange={(e) => handleCardNumberChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-number"
className={`pl-11 font-mono text-base ${errors.card ? "border-red-500" : ""}`}
/>
{brand && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-trust-blue bg-trust-blue/10 px-2 py-0.5 rounded-full">
{brand}
</span>
)}
</div>
{errors.card && <p className="text-xs text-red-500">{errors.card}</p>}
</div>
{/* Expiry + CVC row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
ref={expiryRef}
placeholder="MM/YY"
value={expiry}
onChange={(e) => handleExpiryChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-exp"
maxLength={5}
className={`font-mono text-base ${errors.expiry ? "border-red-500" : ""}`}
/>
{errors.expiry && <p className="text-xs text-red-500">{errors.expiry}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="cvc">CVC</Label>
<Input
id="cvc"
ref={cvcRef}
placeholder="123"
value={cvc}
onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))}
inputMode="numeric"
autoComplete="cc-csc"
maxLength={4}
className={`font-mono text-base ${errors.cvc ? "border-red-500" : ""}`}
/>
{errors.cvc && <p className="text-xs text-red-500">{errors.cvc}</p>}
</div>
</div>
{/* Cardholder name */}
<div className="space-y-2">
<Label htmlFor="card-name">Name on Card</Label>
<Input
id="card-name"
placeholder="J. Smith"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="cc-name"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="card-email">Email</Label>
<Input
id="card-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-lg border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
</p>
</div>
</label>
{/* Pay button */}
<Button
size="xl"
className="w-full"
disabled={!isReady || processing}
onClick={handleSubmit}
>
{processing ? (
<span className="flex items-center gap-2">
<span className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Processing...
</span>
) : (
<>
<Lock className="h-5 w-5 mr-2" />
Pay £{pounds}
</>
)}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured with 256-bit encryption</span>
</div>
</div>
)
}

View File

@@ -61,7 +61,6 @@ 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
@@ -73,7 +72,6 @@ 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. Confirmation email is on its way.",
}
// Send WhatsApp receipt if phone provided
@@ -140,7 +138,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
<h1 className="text-2xl font-black text-gray-900">
{isDeferred
? "Pledge Locked In!"
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
: rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous support of{" "}
@@ -178,14 +176,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
</span>
</div>
)}
{rail === "card" && !isDeferred && (
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-muted-foreground">Status</span>
<span className="text-success-green font-bold flex items-center gap-1">
<Check className="h-4 w-4" /> Paid
</span>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,9 +1,9 @@
"use client"
import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react"
import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react"
interface Props {
onSelect: (rail: "bank" | "gocardless" | "card") => void
onSelect: (rail: "bank" | "gocardless") => void
amount: number
}
@@ -40,20 +40,6 @@ export function PaymentStep({ onSelect, amount }: Props) {
highlight: false,
benefits: ["No action needed", "DD Guarantee"],
},
{
id: "card" as const,
icon: CreditCard,
title: "Card Payment",
subtitle: "Visa, Mastercard, Amex — instant",
tag: "Instant",
tagClass: "bg-purple-100 text-purple-700",
detail: "Powered by Stripe. Receipt emailed instantly.",
fee: "1.4% + 20p",
feeClass: "text-muted-foreground",
iconBg: "bg-midnight",
highlight: false,
benefits: ["Instant confirmation", "All major cards"],
},
]
return (

View File

@@ -19,7 +19,7 @@ interface PledgeInfo {
function SuccessContent() {
const searchParams = useSearchParams()
const pledgeId = searchParams.get("pledge_id")
const rail = searchParams.get("rail") || "card"
const rail = searchParams.get("rail") || "bank"
const cancelled = searchParams.get("cancelled") === "true"
const [pledge, setPledge] = useState<PledgeInfo | null>(null)
const [loading, setLoading] = useState(true)
@@ -74,16 +74,12 @@ function SuccessContent() {
}
const railLabels: Record<string, string> = {
card: "Card Payment",
gocardless: "Direct Debit",
fpx: "FPX Online Banking",
bank: "Bank Transfer",
}
const nextStepMessages: Record<string, string> = {
card: "Your card payment has been processed. You'll receive a confirmation email shortly.",
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
fpx: "Your FPX payment has been received and verified.",
bank: "Please complete the bank transfer using the reference provided.",
}
@@ -127,7 +123,7 @@ function SuccessContent() {
)}
<div className="rounded-lg bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">{nextStepMessages[rail] || nextStepMessages.card}</p>
<p className="text-sm text-muted-foreground">{nextStepMessages[rail] || nextStepMessages.bank}</p>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.{pledge && <> Ref: {pledge.reference}</>}

View File

@@ -663,13 +663,6 @@ 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: "Accept card payments directly. PCI compliant. Money lands in your Stripe account.",
tag: "Card payments",
},
{
name: "UK Bank Transfer",
logo: null,

View File

@@ -112,7 +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)</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>
</section>

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, GoCardless, or Stripe. 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 or GoCardless. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
</section>
<section>