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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}</>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import Stripe from "stripe"
|
||||
|
||||
let stripeClient: Stripe | null = null
|
||||
|
||||
export function getStripe(): Stripe | null {
|
||||
if (stripeClient) return stripeClient
|
||||
|
||||
const key = process.env.STRIPE_SECRET_KEY
|
||||
if (!key || key === "sk_test_REPLACE_ME") return null
|
||||
|
||||
stripeClient = new Stripe(key, {
|
||||
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||
typescript: true,
|
||||
})
|
||||
return stripeClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a card payment.
|
||||
* Returns the checkout URL to redirect the donor to.
|
||||
*/
|
||||
export async function createCheckoutSession(opts: {
|
||||
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 = getStripe()
|
||||
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: `Pledge 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 session error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Payment Intent for embedded payment (Stripe Elements).
|
||||
* Returns client secret for frontend confirmation.
|
||||
*/
|
||||
export async function createPaymentIntent(opts: {
|
||||
amountPence: number
|
||||
currency: string
|
||||
pledgeId: string
|
||||
reference: string
|
||||
donorEmail?: string
|
||||
}): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripe()
|
||||
if (!stripe) return null
|
||||
|
||||
try {
|
||||
const pi = await stripe.paymentIntents.create({
|
||||
amount: opts.amountPence,
|
||||
currency: opts.currency.toLowerCase(),
|
||||
metadata: {
|
||||
pledge_id: opts.pledgeId,
|
||||
reference: opts.reference,
|
||||
},
|
||||
receipt_email: opts.donorEmail || undefined,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
clientSecret: pi.client_secret!,
|
||||
paymentIntentId: pi.id,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stripe payment intent error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Stripe webhook signature.
|
||||
*/
|
||||
export function constructWebhookEvent(
|
||||
body: string | Buffer,
|
||||
signature: string
|
||||
): Stripe.Event | null {
|
||||
const stripe = getStripe()
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
if (!stripe || !secret || secret === "whsec_REPLACE_ME") return null
|
||||
|
||||
try {
|
||||
return stripe.webhooks.constructEvent(body, signature, secret)
|
||||
} catch (error) {
|
||||
console.error("Stripe webhook signature verification failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user