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)
111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import prisma from "@/lib/prisma"
|
|
import { getOrgId, requirePermission } from "@/lib/session"
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
|
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
|
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
|
|
|
const org = await prisma.organization.findUnique({ where: { id: orgId } })
|
|
if (!org) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
|
|
|
return NextResponse.json({
|
|
id: org.id,
|
|
name: org.name,
|
|
slug: org.slug,
|
|
country: org.country,
|
|
bankName: org.bankName || "",
|
|
bankSortCode: org.bankSortCode || "",
|
|
bankAccountNo: org.bankAccountNo || "",
|
|
bankAccountName: org.bankAccountName || "",
|
|
refPrefix: org.refPrefix,
|
|
logo: org.logo,
|
|
primaryColor: org.primaryColor,
|
|
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
|
|
gcEnvironment: org.gcEnvironment,
|
|
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
|
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
|
orgType: org.orgType || "charity",
|
|
})
|
|
} catch (error) {
|
|
console.error("Settings GET error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
export async function PUT(request: NextRequest) {
|
|
try {
|
|
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
|
const allowed = await requirePermission("settings.write")
|
|
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
|
|
|
|
const body = await request.json()
|
|
|
|
// Try to find existing org first
|
|
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
|
|
|
if (orgId) {
|
|
// Update existing
|
|
const allowed = ["name", "charityNumber", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo"]
|
|
const data: Record<string, string> = {}
|
|
for (const key of allowed) {
|
|
if (key in body && body[key] !== undefined) data[key] = body[key]
|
|
}
|
|
const org = await prisma.organization.update({ where: { id: orgId }, data })
|
|
return NextResponse.json({ id: org.id, name: org.name, created: false })
|
|
} else {
|
|
// Create new org
|
|
const slug = (body.name || "org").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")
|
|
const org = await prisma.organization.create({
|
|
data: {
|
|
name: body.name || "My Charity",
|
|
slug: slug || "my-charity",
|
|
country: "GB",
|
|
bankName: body.bankName || "",
|
|
bankSortCode: body.bankSortCode || "",
|
|
bankAccountNo: body.bankAccountNo || "",
|
|
bankAccountName: body.bankAccountName || body.name || "",
|
|
refPrefix: slug.substring(0, 4).toUpperCase() || "PNPL",
|
|
},
|
|
})
|
|
return NextResponse.json({ id: org.id, name: org.name, created: true })
|
|
}
|
|
} catch (error) {
|
|
console.error("Settings PUT error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
export async function PATCH(request: NextRequest) {
|
|
try {
|
|
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
|
const allowed = await requirePermission("settings.write")
|
|
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
|
|
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
|
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", "stripeSecretKey", "stripeWebhookSecret"]
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const data: Record<string, any> = {}
|
|
for (const key of stringKeys) {
|
|
if (key in body && body[key] !== undefined && body[key] !== "••••••••") {
|
|
data[key] = body[key]
|
|
}
|
|
}
|
|
// (boolean fields can be added here as needed)
|
|
|
|
const org = await prisma.organization.update({
|
|
where: { id: orgId },
|
|
data,
|
|
})
|
|
|
|
return NextResponse.json({ success: true, name: org.name })
|
|
} catch (error) {
|
|
console.error("Settings PATCH error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|