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:
28
pledge-now-pay-later/package-lock.json
generated
28
pledge-now-pay-later/package-lock.json
generated
@@ -11,7 +11,6 @@
|
|||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"@stripe/stripe-js": "^8.8.0",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.4.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -1794,15 +1792,6 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@stripe/stripe-js": {
|
|
||||||
"version": "8.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz",
|
|
||||||
"integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -7792,23 +7781,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"@stripe/stripe-js": "^8.8.0",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.4.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { ConfirmationStep } from "./steps/confirmation-step"
|
||||||
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
||||||
import { ExternalRedirectStep } from "./steps/external-redirect-step"
|
import { ExternalRedirectStep } from "./steps/external-redirect-step"
|
||||||
import { CardPaymentStep } from "./steps/card-payment-step"
|
|
||||||
import { DirectDebitStep } from "./steps/direct-debit-step"
|
import { DirectDebitStep } from "./steps/direct-debit-step"
|
||||||
|
|
||||||
export type Rail = "bank" | "gocardless" | "card"
|
export type Rail = "bank" | "gocardless"
|
||||||
|
|
||||||
export interface PledgeData {
|
export interface PledgeData {
|
||||||
amountPence: number
|
amountPence: number
|
||||||
@@ -143,7 +142,7 @@ export default function PledgePage() {
|
|||||||
// Step 2: Payment method selected (only for "now" self-payment mode)
|
// Step 2: Payment method selected (only for "now" self-payment mode)
|
||||||
const handleRailSelected = (rail: Rail) => {
|
const handleRailSelected = (rail: Rail) => {
|
||||||
setPledgeData((d) => ({ ...d, 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)
|
// 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}
|
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} />,
|
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} />,
|
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 backableSteps = new Set([1, 2, 3, 6, 8])
|
||||||
const getBackStep = (s: number): number => {
|
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 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
|
||||||
if (s === 3) return 2 // bank identity → payment method
|
if (s === 3) return 2 // bank identity → payment method
|
||||||
return s - 1
|
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
|
const progressPercent = progressMap[step] || 10
|
||||||
|
|
||||||
return (
|
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> = {
|
const railLabels: Record<string, string> = {
|
||||||
bank: "Bank Transfer",
|
bank: "Bank Transfer",
|
||||||
gocardless: "Direct Debit",
|
gocardless: "Direct Debit",
|
||||||
card: "Card Payment",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deferredMessage = isDeferred
|
const deferredMessage = isDeferred
|
||||||
@@ -73,7 +72,6 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
const nextStepMessages: Record<string, string> = {
|
const nextStepMessages: Record<string, string> = {
|
||||||
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
|
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.`,
|
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
|
// 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">
|
<h1 className="text-2xl font-black text-gray-900">
|
||||||
{isDeferred
|
{isDeferred
|
||||||
? "Pledge Locked In!"
|
? "Pledge Locked In!"
|
||||||
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
: rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Thank you for your generous support of{" "}
|
Thank you for your generous support of{" "}
|
||||||
@@ -178,14 +176,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (rail: "bank" | "gocardless" | "card") => void
|
onSelect: (rail: "bank" | "gocardless") => void
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +40,6 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
highlight: false,
|
highlight: false,
|
||||||
benefits: ["No action needed", "DD Guarantee"],
|
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 (
|
return (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface PledgeInfo {
|
|||||||
function SuccessContent() {
|
function SuccessContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const pledgeId = searchParams.get("pledge_id")
|
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 cancelled = searchParams.get("cancelled") === "true"
|
||||||
const [pledge, setPledge] = useState<PledgeInfo | null>(null)
|
const [pledge, setPledge] = useState<PledgeInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -74,16 +74,12 @@ function SuccessContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const railLabels: Record<string, string> = {
|
const railLabels: Record<string, string> = {
|
||||||
card: "Card Payment",
|
|
||||||
gocardless: "Direct Debit",
|
gocardless: "Direct Debit",
|
||||||
fpx: "FPX Online Banking",
|
|
||||||
bank: "Bank Transfer",
|
bank: "Bank Transfer",
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStepMessages: Record<string, string> = {
|
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.",
|
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.",
|
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">
|
<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 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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Need help? Contact the charity directly.{pledge && <> Ref: {pledge.reference}</>}
|
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.",
|
desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.",
|
||||||
tag: "Fundraising",
|
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",
|
name: "UK Bank Transfer",
|
||||||
logo: null,
|
logo: null,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function PrivacyPage() {
|
|||||||
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
|
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<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>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>
|
<li><strong>OpenAI</strong> — for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI — only anonymised context.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function TermsPage() {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
|
<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>
|
||||||
|
|
||||||
<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