Stripe integration: charity connects their own Stripe account
Model: PNPL never touches the money. Each charity connects their own Stripe account by pasting their API key in Settings. When a donor chooses card payment, they're redirected to Stripe Checkout. The money lands in the charity's Stripe balance. ## Schema - Organization.stripeSecretKey (new column) - Organization.stripeWebhookSecret (new column) ## New/rewritten files - src/lib/stripe.ts — getStripeForOrg(secretKey), per-org client - src/app/api/stripe/checkout/route.ts — uses org's key, not env var - src/app/api/stripe/webhook/route.ts — tries all org webhook secrets - src/app/p/[token]/steps/card-payment-step.tsx — redirect to Stripe Checkout (no fake card form — Stripe handles PCI) ## Settings page - New 'Card payments' section between Bank and Charity - Instructions: how to get your Stripe API key - Webhook setup in collapsed <details> (optional, for auto-confirm) - 'Card payments live' green banner when connected - Readiness bar shows Stripe status (5 columns now) ## Pledge flow - PaymentStep shows card option ONLY if org has Stripe configured - hasStripe flag passed from /api/qr/[token] → PaymentStep - Secret key never exposed to frontend (only boolean hasStripe) ## How it works 1. Charity pastes sk_live_... in Settings → Save 2. Donor opens pledge link → sees 'Bank Transfer', 'Direct Debit', 'Card' 3. Donor picks card → enters name + email → redirects to Stripe Checkout 4. Stripe processes payment → money in charity's Stripe balance 5. (Optional) Webhook auto-confirms pledge as paid Payment options: - Bank Transfer: zero fees (default, always available) - Direct Debit via GoCardless: 1% + 20p (if org configured) - Card via Stripe: standard Stripe fees (if org configured)
This commit is contained in:
28
pledge-now-pay-later/package-lock.json
generated
28
pledge-now-pay-later/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@stripe/stripe-js": "^8.9.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -1792,6 +1794,15 @@
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.9.0.tgz",
|
||||
"integrity": "sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@@ -7781,6 +7792,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "20.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz",
|
||||
"integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@stripe/stripe-js": "^8.9.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ model Organization {
|
||||
primaryColor String @default("#1e40af")
|
||||
gcAccessToken String?
|
||||
gcEnvironment String @default("sandbox")
|
||||
stripeSecretKey String?
|
||||
stripeWebhookSecret String?
|
||||
whatsappConnected Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function GET(
|
||||
if (token === "demo") {
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { status: "active" },
|
||||
include: { organization: { select: { name: true } } },
|
||||
include: { organization: { select: { name: true, stripeSecretKey: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
})
|
||||
if (!event) {
|
||||
@@ -32,7 +32,7 @@ export async function GET(
|
||||
externalUrl: event.externalUrl || null,
|
||||
externalPlatform: event.externalPlatform || null,
|
||||
zakatEligible: event.zakatEligible || false,
|
||||
|
||||
hasStripe: !!event.organization.stripeSecretKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function GET(
|
||||
include: {
|
||||
event: {
|
||||
include: {
|
||||
organization: { select: { name: true } },
|
||||
organization: { select: { name: true, stripeSecretKey: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -67,7 +67,7 @@ export async function GET(
|
||||
externalUrl: qrSource.event.externalUrl || null,
|
||||
externalPlatform: qrSource.event.externalPlatform || null,
|
||||
zakatEligible: qrSource.event.zakatEligible || false,
|
||||
|
||||
hasStripe: !!qrSource.event.organization.stripeSecretKey,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("QR resolve error:", error)
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
primaryColor: org.primaryColor,
|
||||
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
|
||||
gcEnvironment: org.gcEnvironment,
|
||||
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
||||
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
||||
orgType: org.orgType || "charity",
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -85,7 +87,7 @@ export async function PATCH(request: NextRequest) {
|
||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||
|
||||
const body = await request.json()
|
||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"]
|
||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: Record<string, any> = {}
|
||||
for (const key of stringKeys) {
|
||||
|
||||
129
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal file
129
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { createCheckoutSession } from "@/lib/stripe"
|
||||
import { generateReference } from "@/lib/reference"
|
||||
|
||||
/**
|
||||
* POST /api/stripe/checkout
|
||||
*
|
||||
* Creates a Stripe Checkout Session using the ORG'S own Stripe account.
|
||||
* The money goes directly to the charity's Stripe balance.
|
||||
* PNPL never touches the funds.
|
||||
*
|
||||
* If the org hasn't connected Stripe, returns { mode: "not_configured" }.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
|
||||
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
|
||||
}
|
||||
|
||||
// Get event + org (with Stripe key)
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { organization: true },
|
||||
})
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const org = event.organization
|
||||
|
||||
// Check if org has Stripe configured
|
||||
if (!org.stripeSecretKey) {
|
||||
return NextResponse.json({ mode: "not_configured" })
|
||||
}
|
||||
|
||||
// Generate reference
|
||||
let reference = ""
|
||||
let attempts = 0
|
||||
while (attempts < 10) {
|
||||
reference = generateReference(org.refPrefix || "PNPL", amountPence)
|
||||
const exists = await prisma.pledge.findUnique({ where: { reference } })
|
||||
if (!exists) break
|
||||
attempts++
|
||||
}
|
||||
|
||||
// Create pledge in DB
|
||||
const pledge = await prisma.pledge.create({
|
||||
data: {
|
||||
reference,
|
||||
amountPence,
|
||||
currency: "GBP",
|
||||
rail: "card",
|
||||
status: "new",
|
||||
donorName: donorName || null,
|
||||
donorEmail: donorEmail || null,
|
||||
donorPhone: donorPhone || null,
|
||||
giftAid: giftAid || false,
|
||||
eventId,
|
||||
qrSourceId: qrSourceId || null,
|
||||
organizationId: org.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Track analytics
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
eventType: "pledge_completed",
|
||||
pledgeId: pledge.id,
|
||||
eventId,
|
||||
qrSourceId: qrSourceId || null,
|
||||
metadata: { amountPence, rail: "card" },
|
||||
},
|
||||
})
|
||||
|
||||
// Create Stripe Checkout Session using the org's key
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
|
||||
|
||||
const session = await createCheckoutSession({
|
||||
stripeSecretKey: org.stripeSecretKey,
|
||||
amountPence,
|
||||
currency: "GBP",
|
||||
pledgeId: pledge.id,
|
||||
reference,
|
||||
eventName: event.name,
|
||||
organizationName: org.name,
|
||||
donorEmail: donorEmail || undefined,
|
||||
successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`,
|
||||
})
|
||||
|
||||
if (session) {
|
||||
// Save Stripe session reference
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
pledgeId: pledge.id,
|
||||
provider: "stripe",
|
||||
providerRef: session.sessionId,
|
||||
amountPence,
|
||||
status: "pending",
|
||||
matchedBy: "auto",
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.pledge.update({
|
||||
where: { id: pledge.id },
|
||||
data: { status: "initiated" },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
mode: "live",
|
||||
pledgeId: pledge.id,
|
||||
reference,
|
||||
checkoutUrl: session.checkoutUrl,
|
||||
sessionId: session.sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Stripe call failed — clean up the pledge
|
||||
await prisma.pledge.delete({ where: { id: pledge.id } })
|
||||
return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 })
|
||||
} catch (error) {
|
||||
console.error("Stripe checkout error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
118
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal file
118
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { constructWebhookEvent } from "@/lib/stripe"
|
||||
|
||||
/**
|
||||
* POST /api/stripe/webhook
|
||||
*
|
||||
* Handles Stripe webhooks. Each org has their own webhook secret.
|
||||
* We look up the org by the pledge_id in the session metadata.
|
||||
*
|
||||
* The org configures their Stripe webhook endpoint to point here:
|
||||
* https://pledge.quikcue.com/api/stripe/webhook
|
||||
*
|
||||
* Events handled:
|
||||
* - checkout.session.completed → mark pledge as paid
|
||||
* - checkout.session.expired → mark pledge as cancelled
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||
}
|
||||
|
||||
const body = await request.text()
|
||||
const signature = request.headers.get("stripe-signature") || ""
|
||||
|
||||
// We need to try all orgs' webhook secrets to find the right one
|
||||
// In practice, most deployments have 1-5 orgs with Stripe configured
|
||||
const orgsWithStripe = await prisma.organization.findMany({
|
||||
where: { stripeWebhookSecret: { not: null } },
|
||||
select: { id: true, stripeWebhookSecret: true },
|
||||
})
|
||||
|
||||
let event = null
|
||||
let matchedOrgId: string | null = null
|
||||
|
||||
for (const org of orgsWithStripe) {
|
||||
if (!org.stripeWebhookSecret) continue
|
||||
const parsed = constructWebhookEvent(body, signature, org.stripeWebhookSecret)
|
||||
if (parsed) {
|
||||
event = parsed
|
||||
matchedOrgId = org.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!event || !matchedOrgId) {
|
||||
return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Handle checkout.session.completed
|
||||
if (event.type === "checkout.session.completed") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = event.data.object as any
|
||||
const pledgeId = session.metadata?.pledge_id
|
||||
|
||||
if (pledgeId) {
|
||||
// Verify this pledge belongs to the matched org
|
||||
const pledge = await prisma.pledge.findFirst({
|
||||
where: { id: pledgeId, organizationId: matchedOrgId },
|
||||
})
|
||||
|
||||
if (pledge) {
|
||||
await prisma.pledge.update({
|
||||
where: { id: pledgeId },
|
||||
data: { status: "paid", paidAt: new Date() },
|
||||
})
|
||||
|
||||
// Update payment record
|
||||
await prisma.payment.updateMany({
|
||||
where: { pledgeId, provider: "stripe" },
|
||||
data: { status: "completed" },
|
||||
})
|
||||
|
||||
// Skip remaining reminders
|
||||
await prisma.reminder.updateMany({
|
||||
where: { pledgeId, status: "pending" },
|
||||
data: { status: "skipped" },
|
||||
})
|
||||
|
||||
// Log
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
eventType: "activity.stripe_payment_confirmed",
|
||||
pledgeId,
|
||||
eventId: pledge.eventId,
|
||||
metadata: { provider: "stripe", sessionId: session.id },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle checkout.session.expired
|
||||
if (event.type === "checkout.session.expired") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = event.data.object as any
|
||||
const pledgeId = session.metadata?.pledge_id
|
||||
|
||||
if (pledgeId) {
|
||||
// Reset to "new" so they can try again
|
||||
await prisma.pledge.updateMany({
|
||||
where: { id: pledgeId, organizationId: matchedOrgId, status: "initiated" },
|
||||
data: { status: "new" },
|
||||
})
|
||||
await prisma.payment.updateMany({
|
||||
where: { pledgeId, provider: "stripe", status: "pending" },
|
||||
data: { status: "expired" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error("Stripe webhook error:", error)
|
||||
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"
|
||||
import {
|
||||
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
||||
Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy,
|
||||
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight
|
||||
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap
|
||||
} from "lucide-react"
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,7 @@ interface OrgSettings {
|
||||
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||
stripeSecretKey: string; stripeWebhookSecret: string
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
@@ -148,6 +149,7 @@ export default function SettingsPage() {
|
||||
const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName)
|
||||
const whatsappReady = waStatus === "CONNECTED"
|
||||
const charityReady = !!settings.name
|
||||
const stripeReady = !!settings.stripeSecretKey
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -161,11 +163,12 @@ export default function SettingsPage() {
|
||||
{/* ── Readiness bar — "Am I set up?" ── */}
|
||||
<div className="bg-[#111827] p-5">
|
||||
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Setup progress</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-700">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-700">
|
||||
{[
|
||||
{ label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" },
|
||||
{ label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "Not set" },
|
||||
{ label: "Charity name", ready: charityReady, detail: charityReady ? settings.name : "Not set" },
|
||||
{ label: "Card payments", ready: stripeReady, detail: stripeReady ? "Stripe connected" : "Not set up" },
|
||||
{ label: "Team", ready: team.length > 0, detail: `${team.length} member${team.length !== 1 ? "s" : ""}` },
|
||||
].map(item => (
|
||||
<div key={item.label} className="bg-[#111827] p-3">
|
||||
@@ -429,7 +432,95 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 4. Charity details ── */}
|
||||
{/* ── 4. Card payments (Stripe) ── */}
|
||||
<section className="bg-white border border-gray-200">
|
||||
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
|
||||
<div className={`w-8 h-8 flex items-center justify-center shrink-0 ${stripeReady ? "bg-[#635BFF]/10" : "bg-gray-100"}`}>
|
||||
<CreditCard className={`h-4 w-4 ${stripeReady ? "text-[#635BFF]" : "text-gray-400"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold text-[#111827]">Card payments</h2>
|
||||
{stripeReady && <div className="w-2 h-2 bg-[#635BFF]" />}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">Let donors pay by card using your Stripe account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="border-l-2 border-[#635BFF] pl-3 text-xs text-gray-500 space-y-1">
|
||||
<p>Connect your <strong className="text-gray-700">own Stripe account</strong> to accept card payments. Money goes directly to your Stripe balance — we never touch it.</p>
|
||||
<p>When connected, donors see a third payment option: <strong className="text-gray-700">Bank Transfer</strong>, <strong className="text-gray-700">Direct Debit</strong>, and <strong className="text-gray-700">Card Payment</strong>.</p>
|
||||
</div>
|
||||
|
||||
{!stripeReady && (
|
||||
<div className="bg-[#F9FAFB] border border-gray-100 p-4 space-y-3">
|
||||
<p className="text-xs font-bold text-[#111827]">How to get your Stripe API key</p>
|
||||
<ol className="text-[10px] text-gray-500 space-y-1.5 list-decimal list-inside">
|
||||
<li>Go to <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-[#635BFF] font-bold hover:underline">dashboard.stripe.com/apikeys</a></li>
|
||||
<li>Copy your <strong className="text-gray-700">Secret key</strong> (starts with <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_live_</code> or <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_test_</code>)</li>
|
||||
<li>Paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Stripe secret key"
|
||||
value={settings.stripeSecretKey || ""}
|
||||
onChange={v => update("stripeSecretKey", v)}
|
||||
placeholder="sk_live_... or sk_test_..."
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<details className="text-xs">
|
||||
<summary className="text-gray-400 cursor-pointer hover:text-gray-600 font-bold">Webhook setup (optional — for auto-confirmation)</summary>
|
||||
<div className="mt-3 space-y-3 pt-3 border-t border-gray-100">
|
||||
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-500 space-y-1">
|
||||
<p>If you want pledges to auto-confirm when the card is charged:</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
<li>In Stripe Dashboard → Developers → Webhooks</li>
|
||||
<li>Add endpoint: <code className="bg-gray-100 px-1 py-0.5 font-mono">{typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook</code></li>
|
||||
<li>Select events: <code className="bg-gray-100 px-1 py-0.5 font-mono">checkout.session.completed</code></li>
|
||||
<li>Copy the signing secret and paste below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Field
|
||||
label="Webhook signing secret"
|
||||
value={settings.stripeWebhookSecret || ""}
|
||||
onChange={v => update("stripeWebhookSecret", v)}
|
||||
placeholder="whsec_..."
|
||||
type="password"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400">Without webhooks, you confirm card payments manually in the Money page (same as bank transfers).</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{stripeReady && (
|
||||
<div className="bg-[#635BFF]/5 border border-[#635BFF]/20 p-3 flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 text-[#635BFF] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#635BFF]">Card payments are live</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">Donors will see “Pay by Card” as an option. They'll be redirected to Stripe's checkout page. Apple Pay and Google Pay work automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-[10px] text-gray-400">{stripeReady ? "Key stored securely · never shown to donors" : "Optional — bank transfer works without this"}</p>
|
||||
<SaveBtn
|
||||
section="stripe"
|
||||
saving={saving}
|
||||
saved={saved}
|
||||
onSave={() => save("stripe", {
|
||||
stripeSecretKey: settings.stripeSecretKey || "",
|
||||
stripeWebhookSecret: settings.stripeWebhookSecret || "",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 5. Charity details ── */}
|
||||
<section className="bg-white border border-gray-200">
|
||||
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gray-100 flex items-center justify-center shrink-0">
|
||||
|
||||
@@ -9,9 +9,10 @@ import { IdentityStep } from "./steps/identity-step"
|
||||
import { ConfirmationStep } from "./steps/confirmation-step"
|
||||
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
||||
import { ExternalRedirectStep } from "./steps/external-redirect-step"
|
||||
import { CardPaymentStep } from "./steps/card-payment-step"
|
||||
import { DirectDebitStep } from "./steps/direct-debit-step"
|
||||
|
||||
export type Rail = "bank" | "gocardless"
|
||||
export type Rail = "bank" | "gocardless" | "card"
|
||||
|
||||
export interface PledgeData {
|
||||
amountPence: number
|
||||
@@ -44,6 +45,7 @@ interface EventInfo {
|
||||
externalUrl: string | null
|
||||
externalPlatform: string | null
|
||||
zakatEligible: boolean
|
||||
hasStripe: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -142,7 +144,7 @@ export default function PledgePage() {
|
||||
// Step 2: Payment method selected (only for "now" self-payment mode)
|
||||
const handleRailSelected = (rail: Rail) => {
|
||||
setPledgeData((d) => ({ ...d, rail }))
|
||||
setStep(rail === "bank" ? 3 : 8)
|
||||
setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8)
|
||||
}
|
||||
|
||||
// Submit pledge (from identity step, or card/DD steps)
|
||||
@@ -225,7 +227,7 @@ export default function PledgePage() {
|
||||
const steps: Record<number, React.ReactNode> = {
|
||||
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
|
||||
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
||||
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
||||
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
|
||||
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
|
||||
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
||||
5: pledgeResult && (
|
||||
@@ -242,19 +244,20 @@ export default function PledgePage() {
|
||||
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
||||
/>
|
||||
),
|
||||
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
7: pledgeResult && <ExternalRedirectStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} externalUrl={eventInfo?.externalUrl || ""} externalPlatform={eventInfo?.externalPlatform} donorPhone={pledgeData.donorPhone} />,
|
||||
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
}
|
||||
|
||||
const backableSteps = new Set([1, 2, 3, 6, 8])
|
||||
const getBackStep = (s: number): number => {
|
||||
if (s === 8) return 2 // DD → payment method
|
||||
if (s === 6 || s === 8) return 2 // card/DD → payment method
|
||||
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
|
||||
if (s === 3) return 2 // bank identity → payment method
|
||||
return s - 1
|
||||
}
|
||||
|
||||
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 7: 100, 8: 60 }
|
||||
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 }
|
||||
const progressPercent = progressMap[step] || 10
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { CreditCard, Lock, Loader2, ExternalLink } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Card Payment Step
|
||||
*
|
||||
* Redirects to the charity's own Stripe Checkout.
|
||||
* No card form on our side — Stripe handles PCI compliance.
|
||||
* The money goes directly to the charity's Stripe account.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Collect donor name + email (for receipt)
|
||||
* 2. POST /api/stripe/checkout → get checkoutUrl
|
||||
* 3. Redirect to Stripe Checkout
|
||||
* 4. Stripe redirects back to /p/success
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
amount: number
|
||||
eventName: string
|
||||
eventId: string
|
||||
qrSourceId: string | null
|
||||
onComplete: (identity: {
|
||||
donorName: string
|
||||
donorEmail: string
|
||||
donorPhone: string
|
||||
giftAid: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId }: Props) {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [giftAid, setGiftAid] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pounds = (amount / 100).toFixed(2)
|
||||
const isReady = name.trim().length > 1 && email.includes("@")
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isReady) return
|
||||
setProcessing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amountPence: amount,
|
||||
donorName: name.trim(),
|
||||
donorEmail: email.trim(),
|
||||
donorPhone: "",
|
||||
giftAid,
|
||||
eventId,
|
||||
qrSourceId,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.mode === "live" && data.checkoutUrl) {
|
||||
// Redirect to Stripe Checkout (the charity's Stripe account)
|
||||
window.location.href = data.checkoutUrl
|
||||
return
|
||||
}
|
||||
|
||||
if (data.mode === "not_configured") {
|
||||
setError("This charity hasn't set up card payments yet. Please use bank transfer instead.")
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setError("Something went wrong creating the payment. Please try bank transfer instead.")
|
||||
setProcessing(false)
|
||||
} catch {
|
||||
setError("Connection error. Please try again.")
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-2 space-y-6 animate-fade-up">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
|
||||
Pay by card
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
£{pounds} for <span className="font-semibold text-foreground">{eventName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-gray-200 bg-white p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Your name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
autoComplete="name"
|
||||
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">For your payment receipt</p>
|
||||
</div>
|
||||
|
||||
{/* Gift Aid */}
|
||||
<label className="flex items-start gap-3 border-2 border-gray-200 p-3 cursor-pointer hover:border-[#1E40AF]/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={giftAid}
|
||||
onChange={e => setGiftAid(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-bold text-[#111827]">Add Gift Aid</span>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">Boost your donation by 25% at no cost to you. You must be a UK taxpayer.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Pay button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isReady || processing}
|
||||
className="w-full bg-[#111827] px-4 py-3.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" /> Redirecting to Stripe…</>
|
||||
) : (
|
||||
<><CreditCard className="h-4 w-4" /> Pay £{pounds} by card</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 text-[10px] text-gray-400">
|
||||
<span className="flex items-center gap-1"><Lock className="h-3 w-3" /> Secure checkout</span>
|
||||
<span className="flex items-center gap-1"><ExternalLink className="h-3 w-3" /> Powered by Stripe</span>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-400">
|
||||
<p>You'll be redirected to Stripe's secure checkout page. The payment goes directly to <strong className="text-gray-600">{eventName}</strong>'s account. Visa, Mastercard, Amex, Apple Pay, and Google Pay accepted.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
||||
const railLabels: Record<string, string> = {
|
||||
bank: "Bank Transfer",
|
||||
gocardless: "Direct Debit",
|
||||
card: "Card Payment",
|
||||
}
|
||||
|
||||
const deferredMessage = isDeferred
|
||||
@@ -72,6 +73,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
||||
const nextStepMessages: Record<string, string> = {
|
||||
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
|
||||
gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`,
|
||||
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
|
||||
}
|
||||
|
||||
// Send WhatsApp receipt if phone provided
|
||||
@@ -138,7 +140,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
||||
<h1 className="text-2xl font-black text-gray-900">
|
||||
{isDeferred
|
||||
? "Pledge Locked In!"
|
||||
: rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
||||
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Thank you for your generous support of{" "}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
||||
import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
onSelect: (rail: "bank" | "gocardless") => void
|
||||
onSelect: (rail: "bank" | "gocardless" | "card") => void
|
||||
amount: number
|
||||
hasStripe?: boolean
|
||||
}
|
||||
|
||||
export function PaymentStep({ onSelect, amount }: Props) {
|
||||
export function PaymentStep({ onSelect, amount, hasStripe }: Props) {
|
||||
const pounds = (amount / 100).toFixed(0)
|
||||
const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0)
|
||||
|
||||
@@ -40,6 +41,20 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
||||
highlight: false,
|
||||
benefits: ["No action needed", "DD Guarantee"],
|
||||
},
|
||||
...(hasStripe ? [{
|
||||
id: "card" as const,
|
||||
icon: CreditCard,
|
||||
title: "Card Payment",
|
||||
subtitle: "Visa, Mastercard, Amex, Apple Pay",
|
||||
tag: "Instant",
|
||||
tagClass: "bg-purple-100 text-purple-700",
|
||||
detail: "Secure checkout via Stripe. Receipt emailed instantly.",
|
||||
fee: "Card fees apply",
|
||||
feeClass: "text-muted-foreground",
|
||||
iconBg: "bg-midnight",
|
||||
highlight: false,
|
||||
benefits: ["Instant confirmation", "Apple Pay & Google Pay"],
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,13 +74,15 @@ function SuccessContent() {
|
||||
}
|
||||
|
||||
const railLabels: Record<string, string> = {
|
||||
gocardless: "Direct Debit",
|
||||
bank: "Bank Transfer",
|
||||
gocardless: "Direct Debit",
|
||||
card: "Card Payment",
|
||||
}
|
||||
|
||||
const nextStepMessages: Record<string, string> = {
|
||||
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
|
||||
bank: "Please complete the bank transfer using the reference provided.",
|
||||
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
|
||||
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -663,6 +663,13 @@ export default function HomePage() {
|
||||
desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.",
|
||||
tag: "Fundraising",
|
||||
},
|
||||
{
|
||||
name: "Stripe",
|
||||
logo: "/images/logos/stripe.svg",
|
||||
color: "#635BFF",
|
||||
desc: "Connect your own Stripe account. Donors pay by card, Apple Pay, or Google Pay. Money goes to you.",
|
||||
tag: "Card payments",
|
||||
},
|
||||
{
|
||||
name: "UK Bank Transfer",
|
||||
logo: null,
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function PrivacyPage() {
|
||||
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li><strong>GoCardless</strong> — for Direct Debit mandate processing (if enabled by charity)</li>
|
||||
<li><strong>Stripe</strong> — for card payment processing (if enabled by charity — the charity connects their own Stripe account)</li>
|
||||
|
||||
<li><strong>OpenAI</strong> — for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI — only anonymised context.</li>
|
||||
</ul>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function TermsPage() {
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
|
||||
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account or GoCardless. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
|
||||
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account, GoCardless, or your own Stripe account. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
100
pledge-now-pay-later/src/lib/stripe.ts
Normal file
100
pledge-now-pay-later/src/lib/stripe.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import Stripe from "stripe"
|
||||
|
||||
/**
|
||||
* Stripe integration — per-org.
|
||||
*
|
||||
* Each charity connects their OWN Stripe account.
|
||||
* PNPL never touches the money. The charity's Stripe processes the payment,
|
||||
* the money lands in the charity's Stripe balance.
|
||||
*
|
||||
* The secret key is stored in Organization.stripeSecretKey.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a Stripe client using the org's own secret key.
|
||||
* Returns null if no key is configured.
|
||||
*/
|
||||
export function getStripeForOrg(secretKey: string | null | undefined): Stripe | null {
|
||||
if (!secretKey || secretKey.trim() === "") return null
|
||||
|
||||
return new Stripe(secretKey, {
|
||||
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||
typescript: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session using the org's Stripe account.
|
||||
* Returns the checkout URL to redirect the donor to.
|
||||
*/
|
||||
export async function createCheckoutSession(opts: {
|
||||
stripeSecretKey: string
|
||||
amountPence: number
|
||||
currency: string
|
||||
pledgeId: string
|
||||
reference: string
|
||||
eventName: string
|
||||
organizationName: string
|
||||
donorEmail?: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
|
||||
const stripe = getStripeForOrg(opts.stripeSecretKey)
|
||||
if (!stripe) return null
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: opts.currency.toLowerCase(),
|
||||
unit_amount: opts.amountPence,
|
||||
product_data: {
|
||||
name: `Donation — ${opts.eventName}`,
|
||||
description: `Ref: ${opts.reference} to ${opts.organizationName}`,
|
||||
},
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
customer_email: opts.donorEmail || undefined,
|
||||
metadata: {
|
||||
pledge_id: opts.pledgeId,
|
||||
reference: opts.reference,
|
||||
},
|
||||
success_url: opts.successUrl,
|
||||
cancel_url: opts.cancelUrl,
|
||||
})
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
checkoutUrl: session.url!,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stripe checkout error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Stripe webhook signature using the org's webhook secret.
|
||||
*/
|
||||
export function constructWebhookEvent(
|
||||
body: string | Buffer,
|
||||
signature: string,
|
||||
webhookSecret: string
|
||||
): Stripe.Event | null {
|
||||
// We need a Stripe instance just for webhook verification — use a dummy key
|
||||
// The webhook secret is what matters for signature verification
|
||||
try {
|
||||
const stripe = new Stripe("sk_dummy_for_webhook_verify", {
|
||||
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||
})
|
||||
return stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
||||
} catch (error) {
|
||||
console.error("Stripe webhook signature verification failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user