feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { eventType, pledgeId, eventId, qrSourceId, metadata } = body
// Fire and forget - don't block on errors
if (pledgeId?.startsWith("demo-")) {
return NextResponse.json({ ok: true })
}
if (!prisma) {
return NextResponse.json({ ok: true })
}
await prisma.analyticsEvent.create({
data: {
eventType: eventType || "unknown",
pledgeId: pledgeId || null,
eventId: eventId || null,
qrSourceId: qrSourceId || null,
metadata: metadata || {},
},
})
return NextResponse.json({ ok: true })
} catch {
// Never fail analytics
return NextResponse.json({ ok: true })
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { resolveOrgId } from "@/lib/org"
interface PledgeRow {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
createdAt: Date
paidAt: Date | null
event: { name: string }
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
reminders: Array<{ step: number; status: string; scheduledAt: Date }>
}
interface AnalyticsRow {
eventType: string
_count: number
}
interface ReminderRow {
step: number
status: string
scheduledAt: Date
}
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({
summary: {
totalPledges: 12,
totalPledgedPence: 2450000,
totalCollectedPence: 1820000,
collectionRate: 74,
overdueRate: 8,
},
byStatus: { paid: 8, pending: 2, overdue: 1, cancelled: 1 },
byRail: { bank_transfer: 10, card: 2 },
topSources: [
{ label: "Table 1 - Ahmed", count: 4, amount: 850000 },
{ label: "Table 2 - Fatima", count: 3, amount: 620000 },
],
funnel: { qr_scan: 45, pledge_started: 32, pledge_completed: 12 },
pledges: [],
})
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const where = {
organizationId: orgId,
...(eventId ? { eventId } : {}),
}
const [pledges, analytics] = await Promise.all([
prisma.pledge.findMany({
where,
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
reminders: { select: { step: true, status: true, scheduledAt: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.analyticsEvent.groupBy({
by: ["eventType"],
where: eventId ? { eventId } : {},
_count: true,
}),
]) as [PledgeRow[], AnalyticsRow[]]
const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalCollected = pledges
.filter((p: PledgeRow) => p.status === "paid")
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const collectionRate = totalPledged > 0 ? totalCollected / totalPledged : 0
const overdueCount = pledges.filter((p: PledgeRow) => p.status === "overdue").length
const overdueRate = pledges.length > 0 ? overdueCount / pledges.length : 0
// Status breakdown
const byStatus: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byStatus[p.status] = (byStatus[p.status] || 0) + 1
})
// Rail breakdown
const byRail: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byRail[p.rail] = (byRail[p.rail] || 0) + 1
})
// Top QR sources
const qrStats: Record<string, { label: string; count: number; amount: number }> = {}
pledges.forEach((p: PledgeRow) => {
if (p.qrSource) {
const key = p.qrSource.label
if (!qrStats[key]) qrStats[key] = { label: key, count: 0, amount: 0 }
qrStats[key].count++
qrStats[key].amount += p.amountPence
}
})
// Funnel from analytics
const funnel = Object.fromEntries(analytics.map((a: AnalyticsRow) => [a.eventType, a._count]))
return NextResponse.json({
summary: {
totalPledges: pledges.length,
totalPledgedPence: totalPledged,
totalCollectedPence: totalCollected,
collectionRate: Math.round(collectionRate * 100),
overdueRate: Math.round(overdueRate * 100),
},
byStatus,
byRail,
topSources: Object.values(qrStats).sort((a: { amount: number }, b: { amount: number }) => b.amount - a.amount).slice(0, 10),
funnel,
pledges: pledges.map((p: PledgeRow) => ({
id: p.id,
reference: p.reference,
amountPence: p.amountPence,
status: p.status,
rail: p.rail,
donorName: p.donorName,
donorEmail: p.donorEmail,
donorPhone: p.donorPhone,
eventName: p.event.name,
source: p.qrSource?.label || null,
volunteerName: p.qrSource?.volunteerName || null,
giftAid: p.giftAid,
createdAt: p.createdAt,
paidAt: p.paidAt,
nextReminder: p.reminders
.filter((r: ReminderRow) => r.status === "pending")
.sort((a: ReminderRow, b: ReminderRow) => a.scheduledAt.getTime() - b.scheduledAt.getTime())[0]?.scheduledAt || null,
lastTouch: p.reminders
.filter((r: ReminderRow) => r.status === "sent")
.sort((a: ReminderRow, b: ReminderRow) => b.scheduledAt.getTime() - a.scheduledAt.getTime())[0]?.scheduledAt || null,
})),
})
} catch (error) {
console.error("Dashboard error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server"
import { generateQrBuffer } from "@/lib/qr"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; qrId: string }> }
) {
try {
const { qrId } = await params
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
// qrId is actually used to look up the code, but for simplicity use the code from query
const code = request.nextUrl.searchParams.get("code") || qrId
const buffer = await generateQrBuffer({
baseUrl,
code,
width: 800,
margin: 2,
})
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `attachment; filename="qr-${code}.png"`,
},
})
} catch (error) {
console.error("QR download error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createQrSourceSchema } from "@/lib/validators"
import { customAlphabet } from "nanoid"
const generateCode = customAlphabet("23456789abcdefghjkmnpqrstuvwxyz", 8)
interface QrPledge {
amountPence: number
status: string
}
interface QrRow {
id: string
label: string
code: string
volunteerName: string | null
tableName: string | null
scanCount: number
createdAt: Date
_count: { pledges: number }
pledges: QrPledge[]
}
// GET QR sources for event
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
if (!prisma) {
return NextResponse.json([])
}
const sources = await prisma.qrSource.findMany({
where: { eventId: id },
include: {
_count: { select: { pledges: true } },
pledges: { select: { amountPence: true, status: true } },
},
orderBy: { createdAt: "desc" },
}) as QrRow[]
return NextResponse.json(
sources.map((s: QrRow) => ({
id: s.id,
label: s.label,
code: s.code,
volunteerName: s.volunteerName,
tableName: s.tableName,
scanCount: s.scanCount,
pledgeCount: s._count.pledges,
totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
createdAt: s.createdAt,
}))
)
} catch (error) {
console.error("QR sources GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
// POST create QR source
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const body = await request.json()
const parsed = createQrSourceSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 })
}
const event = await prisma.event.findUnique({
where: { id },
select: { id: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const code = generateCode()
const qrSource = await prisma.qrSource.create({
data: {
...parsed.data,
code,
eventId: id,
},
})
return NextResponse.json(qrSource, { status: 201 })
} catch (error) {
console.error("QR source creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createEventSchema } from "@/lib/validators"
import { resolveOrgId } from "@/lib/org"
interface PledgeSummary {
amountPence: number
status: string
}
interface EventRow {
id: string
name: string
slug: string
eventDate: Date | null
location: string | null
goalAmount: number | null
status: string
createdAt: Date
_count: { pledges: number; qrSources: number }
pledges: PledgeSummary[]
}
// GET all events for org (TODO: auth middleware)
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json([])
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const events = await prisma.event.findMany({
where: { organizationId: orgId },
include: {
_count: { select: { pledges: true, qrSources: true } },
pledges: {
select: { amountPence: true, status: true },
},
},
orderBy: { createdAt: "desc" },
}) as EventRow[]
const formatted = events.map((e: EventRow) => ({
id: e.id,
name: e.name,
slug: e.slug,
eventDate: e.eventDate,
location: e.location,
goalAmount: e.goalAmount,
status: e.status,
pledgeCount: e._count.pledges,
qrSourceCount: e._count.qrSources,
totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
totalCollected: e.pledges
.filter((p: PledgeSummary) => p.status === "paid")
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
createdAt: e.createdAt,
}))
return NextResponse.json(formatted)
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
// POST create event
export async function POST(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const body = await request.json()
const parsed = createEventSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 })
}
const slug = parsed.data.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50)
const event = await prisma.event.create({
data: {
...parsed.data,
slug: slug + "-" + Date.now().toString(36),
eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null,
organizationId: orgId,
},
})
return NextResponse.json(event, { status: 201 })
} catch (error) {
console.error("Event creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { formatCrmExportCsv, type CrmExportRow } from "@/lib/exports"
import { resolveOrgId } from "@/lib/org"
interface ExportPledge {
reference: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
amountPence: number
rail: string
status: string
giftAid: boolean
createdAt: Date
paidAt: Date | null
event: { name: string }
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
}
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(
request.headers.get("x-org-id") || request.nextUrl.searchParams.get("orgId") || "demo"
)
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const pledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
...(eventId ? { eventId } : {}),
},
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
},
orderBy: { createdAt: "desc" },
}) as ExportPledge[]
const rows: CrmExportRow[] = pledges.map((p: ExportPledge) => ({
pledge_reference: p.reference,
donor_name: p.donorName || "",
donor_email: p.donorEmail || "",
donor_phone: p.donorPhone || "",
amount_gbp: (p.amountPence / 100).toFixed(2),
payment_method: p.rail,
status: p.status,
event_name: p.event.name,
source_label: p.qrSource?.label || "",
volunteer_name: p.qrSource?.volunteerName || "",
table_name: p.qrSource?.tableName || "",
gift_aid: p.giftAid ? "Yes" : "No",
pledged_at: p.createdAt.toISOString(),
paid_at: p.paidAt?.toISOString() || "",
days_to_collect: p.paidAt
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
: "",
}))
const csv = formatCrmExportCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`,
},
})
} catch (error) {
console.error("CRM export error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { completeRedirectFlow, createPayment } from "@/lib/gocardless"
export async function GET(request: NextRequest) {
try {
const pledgeId = request.nextUrl.searchParams.get("pledge_id")
const redirectFlowId = request.nextUrl.searchParams.get("redirect_flow_id")
if (!pledgeId) {
return NextResponse.redirect(new URL("/", request.url))
}
const pledge = await prisma.pledge.findUnique({
where: { id: pledgeId },
include: { event: true, paymentInstruction: true },
})
if (!pledge) {
return NextResponse.redirect(new URL("/", request.url))
}
// If we have a redirect flow ID, complete the GoCardless flow
if (redirectFlowId) {
const result = await completeRedirectFlow(redirectFlowId, pledgeId)
if (result) {
// Save mandate ID
if (pledge.paymentInstruction) {
await prisma.paymentInstruction.update({
where: { id: pledge.paymentInstruction.id },
data: { gcMandateId: result.mandateId },
})
}
// Create the payment against the mandate
const payment = await createPayment({
amountPence: pledge.amountPence,
mandateId: result.mandateId,
reference: pledge.reference,
pledgeId: pledge.id,
description: `${pledge.event.name}${pledge.reference}`,
})
if (payment) {
// Update pledge status
await prisma.pledge.update({
where: { id: pledgeId },
data: { status: "initiated" },
})
// Record payment
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: "gocardless",
providerRef: payment.paymentId,
amountPence: pledge.amountPence,
status: "pending",
matchedBy: "auto",
},
})
}
}
}
// Redirect to success page
const successUrl = `/p/success?pledge_id=${pledgeId}&rail=gocardless`
return NextResponse.redirect(new URL(successUrl, request.url))
} catch (error) {
console.error("GoCardless callback error:", error)
return NextResponse.redirect(new URL("/", request.url))
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createRedirectFlow } from "@/lib/gocardless"
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: "gocardless",
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid: giftAid || false,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Create reminder schedule
const { calculateReminderSchedule } = await import("@/lib/reminders")
const schedule = calculateReminderSchedule(new Date())
await prisma.reminder.createMany({
data: schedule.map((s) => ({
pledgeId: pledge.id,
step: s.step,
channel: s.channel,
scheduledAt: s.scheduledAt,
status: "pending",
payload: { templateKey: s.templateKey, subject: s.subject },
})),
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail: "gocardless" },
},
})
// Try real GoCardless flow
// GoCardless live mode requires HTTPS redirect URLs
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const isHttps = baseUrl.startsWith("https://")
const redirectUrl = `${baseUrl}/api/gocardless/callback?pledge_id=${pledge.id}`
if (!isHttps && process.env.GOCARDLESS_ENVIRONMENT === "live") {
// Can't use GC live with HTTP — return simulated mode
// Set BASE_URL to your HTTPS domain to enable live GoCardless
console.warn("GoCardless live mode requires HTTPS BASE_URL. Falling back to simulated.")
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
}
const flow = await createRedirectFlow({
description: `${event.name}${reference}`,
reference,
pledgeId: pledge.id,
successRedirectUrl: redirectUrl,
})
if (flow) {
// Save the redirect flow ID for completion
await prisma.paymentInstruction.create({
data: {
pledgeId: pledge.id,
bankReference: reference,
bankDetails: {},
gcMandateUrl: flow.redirectUrl,
},
})
return NextResponse.json({
mode: "live",
pledgeId: pledge.id,
reference,
redirectUrl: flow.redirectUrl,
redirectFlowId: flow.redirectFlowId,
})
}
// Fallback: no GoCardless configured — return pledge for simulated flow
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
} catch (error) {
console.error("GoCardless create flow error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
// GoCardless sends webhook events for payment status changes
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const events = body.events || []
for (const event of events) {
const { resource_type, action, links } = event
if (resource_type === "payments") {
const paymentId = links?.payment
if (!paymentId) continue
// Find our payment record
const payment = await prisma.payment.findFirst({
where: { providerRef: paymentId, provider: "gocardless" },
include: { pledge: true },
})
if (!payment) continue
switch (action) {
case "confirmed":
case "paid_out":
await prisma.pledge.update({
where: { id: payment.pledgeId },
data: { status: "paid", paidAt: new Date() },
})
await prisma.payment.update({
where: { id: payment.id },
data: { status: "confirmed", receivedAt: new Date() },
})
await prisma.analyticsEvent.create({
data: {
eventType: "payment_matched",
pledgeId: payment.pledgeId,
metadata: { provider: "gocardless", action, paymentId },
},
})
break
case "failed":
case "cancelled":
await prisma.pledge.update({
where: { id: payment.pledgeId },
data: { status: action === "cancelled" ? "cancelled" : "overdue" },
})
await prisma.payment.update({
where: { id: payment.id },
data: { status: "failed" },
})
break
}
}
if (resource_type === "mandates" && action === "cancelled") {
// Mandate cancelled by bank/customer
const mandateId = links?.mandate
if (mandateId) {
const instruction = await prisma.paymentInstruction.findFirst({
where: { gcMandateId: mandateId },
})
if (instruction) {
await prisma.pledge.update({
where: { id: instruction.pledgeId },
data: { status: "cancelled", cancelledAt: new Date() },
})
}
}
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("GoCardless webhook error:", error)
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import Papa from "papaparse"
import { matchBankRow } from "@/lib/matching"
import { resolveOrgId } from "@/lib/org"
export async function POST(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const formData = await request.formData()
const file = formData.get("file") as File
const mappingJson = formData.get("mapping") as string
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 })
}
let mapping: Record<string, string> = {}
try {
mapping = mappingJson ? JSON.parse(mappingJson) : {}
} catch {
return NextResponse.json({ error: "Invalid column mapping JSON" }, { status: 400 })
}
const csvText = await file.text()
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
if (parsed.errors.length > 0 && parsed.data.length === 0) {
return NextResponse.json({ error: "CSV parse error", details: parsed.errors }, { status: 400 })
}
// Get all unmatched pledges for this org
const openPledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
status: { in: ["new", "initiated", "overdue"] },
},
select: { id: true, reference: true, amountPence: true },
})
const pledgeMap = new Map<string, { id: string; amountPence: number }>(
openPledges.map((p: { id: string; reference: string; amountPence: number }) => [p.reference, { id: p.id, amountPence: p.amountPence }])
)
// Convert rows and match
const rows = (parsed.data as Record<string, string>[]).map((raw) => ({
date: raw[mapping.dateCol || "Date"] || "",
description: raw[mapping.descriptionCol || "Description"] || "",
amount: parseFloat(raw[mapping.creditCol || mapping.amountCol || "Amount"] || "0"),
reference: raw[mapping.referenceCol || "Reference"] || "",
raw,
}))
const results = rows
.filter((r) => r.amount > 0) // only credits
.map((r) => matchBankRow(r, pledgeMap))
// Create import record
const importRecord = await prisma.import.create({
data: {
organizationId: orgId,
kind: "bank_statement",
fileName: file.name,
rowCount: rows.length,
matchedCount: results.filter((r) => r.confidence === "exact").length,
unmatchedCount: results.filter((r) => r.confidence === "none").length,
mappingConfig: mapping,
status: "completed",
stats: {
totalRows: rows.length,
credits: rows.filter((r) => r.amount > 0).length,
exactMatches: results.filter((r) => r.confidence === "exact").length,
partialMatches: results.filter((r) => r.confidence === "partial").length,
unmatched: results.filter((r) => r.confidence === "none").length,
},
},
})
// Auto-confirm exact matches
const confirmed: string[] = []
for (const result of results) {
if (result.confidence === "exact" && result.pledgeId) {
await prisma.$transaction([
prisma.pledge.update({
where: { id: result.pledgeId },
data: { status: "paid", paidAt: new Date() },
}),
prisma.payment.create({
data: {
pledgeId: result.pledgeId,
provider: "bank",
amountPence: Math.round(result.matchedAmount * 100),
status: "confirmed",
matchedBy: "auto",
receivedAt: new Date(result.bankRow.date) || new Date(),
importId: importRecord.id,
},
}),
// Skip remaining reminders
prisma.reminder.updateMany({
where: { pledgeId: result.pledgeId, status: "pending" },
data: { status: "skipped" },
}),
])
confirmed.push(result.pledgeId)
}
}
return NextResponse.json({
importId: importRecord.id,
summary: {
totalRows: rows.length,
credits: rows.filter((r) => r.amount > 0).length,
exactMatches: results.filter((r) => r.confidence === "exact").length,
partialMatches: results.filter((r) => r.confidence === "partial").length,
unmatched: results.filter((r) => r.confidence === "none").length,
autoConfirmed: confirmed.length,
},
matches: results.map((r) => ({
...r,
autoConfirmed: r.pledgeId ? confirmed.includes(r.pledgeId) : false,
})),
})
} catch (error) {
console.error("Bank import error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
if (id.startsWith("demo-")) {
return NextResponse.json({ ok: true })
}
await prisma.pledge.update({
where: { id },
data: {
status: "initiated",
iPaidClickedAt: new Date(),
},
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Mark initiated error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { updatePledgeStatusSchema } from "@/lib/validators"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
const pledge = await prisma.pledge.findUnique({
where: { id },
include: { event: { select: { name: true } } },
})
if (!pledge) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json({
id: pledge.id,
reference: pledge.reference,
amountPence: pledge.amountPence,
rail: pledge.rail,
status: pledge.status,
donorName: pledge.donorName,
donorEmail: pledge.donorEmail,
eventName: pledge.event.name,
})
} catch (error) {
console.error("Pledge GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
const body = await request.json()
const parsed = updatePledgeStatusSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 })
}
const existing = await prisma.pledge.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
}
const updateData: Record<string, unknown> = {
status: parsed.data.status,
notes: parsed.data.notes,
}
if (parsed.data.status === "paid") {
updateData.paidAt = new Date()
}
if (parsed.data.status === "cancelled") {
updateData.cancelledAt = new Date()
}
const pledge = await prisma.pledge.update({
where: { id },
data: updateData,
})
// If paid or cancelled, skip remaining reminders
if (["paid", "cancelled"].includes(parsed.data.status)) {
await prisma.reminder.updateMany({
where: { pledgeId: id, status: "pending" },
data: { status: "skipped" },
})
}
return NextResponse.json(pledge)
} catch (error) {
console.error("Pledge update error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createPledgeSchema } from "@/lib/validators"
import { generateReference } from "@/lib/reference"
import { calculateReminderSchedule } from "@/lib/reminders"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const parsed = createPledgeSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid data", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data
// 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 unique reference (retry on collision)
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++
}
if (attempts >= 10) {
return NextResponse.json({ error: "Could not generate unique reference" }, { status: 500 })
}
// Create pledge + payment instruction + reminder schedule in transaction
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pledge = await prisma.$transaction(async (tx: any) => {
const p = await tx.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail,
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Create payment instruction for bank transfers
if (rail === "bank" && org.bankSortCode && org.bankAccountNo) {
await tx.paymentInstruction.create({
data: {
pledgeId: p.id,
bankReference: reference,
bankDetails: {
bankName: org.bankName || "",
sortCode: org.bankSortCode,
accountNo: org.bankAccountNo,
accountName: org.bankAccountName || org.name,
},
},
})
}
// Create reminder schedule
const schedule = calculateReminderSchedule(new Date())
await tx.reminder.createMany({
data: schedule.map((s) => ({
pledgeId: p.id,
step: s.step,
channel: s.channel,
scheduledAt: s.scheduledAt,
status: "pending",
payload: { templateKey: s.templateKey, subject: s.subject },
})),
})
// Track analytics
await tx.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: p.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail },
},
})
return p
})
// Build response
const response: Record<string, unknown> = {
id: pledge.id,
reference: pledge.reference,
}
if (rail === "bank" && org.bankSortCode) {
response.bankDetails = {
bankName: org.bankName || "",
sortCode: org.bankSortCode,
accountNo: org.bankAccountNo || "",
accountName: org.bankAccountName || org.name,
}
}
return NextResponse.json(response, { status: 201 })
} catch (error) {
console.error("Pledge creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const { token } = await params
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Handle "demo" token — resolve to the first active event
if (token === "demo") {
const event = await prisma.event.findFirst({
where: { status: "active" },
include: { organization: { select: { name: true } } },
orderBy: { createdAt: "asc" },
})
if (!event) {
return NextResponse.json({ error: "No active events found" }, { status: 404 })
}
return NextResponse.json({
id: event.id,
name: event.name,
organizationName: event.organization.name,
qrSourceId: null,
qrSourceLabel: null,
})
}
const qrSource = await prisma.qrSource.findUnique({
where: { code: token },
include: {
event: {
include: {
organization: { select: { name: true } },
},
},
},
})
if (!qrSource || qrSource.event.status !== "active") {
return NextResponse.json({ error: "This pledge link is no longer active" }, { status: 404 })
}
// Increment scan count
await prisma.qrSource.update({
where: { id: qrSource.id },
data: { scanCount: { increment: 1 } },
})
return NextResponse.json({
id: qrSource.event.id,
name: qrSource.event.name,
organizationName: qrSource.event.organization.name,
qrSourceId: qrSource.id,
qrSourceLabel: qrSource.label,
})
} catch (error) {
console.error("QR resolve error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { resolveOrgId } from "@/lib/org"
export async function GET(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo")
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,
})
} catch (error) {
console.error("Settings GET 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 orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo")
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
const body = await request.json()
const allowed = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"]
const data: Record<string, string> = {}
for (const key of allowed) {
if (key in body && body[key] !== undefined && body[key] !== "••••••••") {
data[key] = body[key]
}
}
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 })
}
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createCheckoutSession } from "@/lib/stripe"
import { generateReference } from "@/lib/reference"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Get event + org
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { organization: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const org = event.organization
// Generate reference
let reference = ""
let attempts = 0
while (attempts < 10) {
reference = generateReference(org.refPrefix || "PNPL", amountPence)
const exists = await prisma.pledge.findUnique({ where: { reference } })
if (!exists) break
attempts++
}
// Create pledge in DB
const pledge = await prisma.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail: "card",
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid: giftAid || false,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail: "card" },
},
})
// Try real Stripe checkout
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const session = await createCheckoutSession({
amountPence,
currency: "GBP",
pledgeId: pledge.id,
reference,
eventName: event.name,
organizationName: org.name,
donorEmail: donorEmail || undefined,
successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`,
})
if (session) {
// Save Stripe session reference
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: "stripe",
providerRef: session.sessionId,
amountPence,
status: "pending",
matchedBy: "auto",
},
})
await prisma.pledge.update({
where: { id: pledge.id },
data: { status: "initiated" },
})
return NextResponse.json({
mode: "live",
pledgeId: pledge.id,
reference,
checkoutUrl: session.checkoutUrl,
sessionId: session.sessionId,
})
}
// Fallback: no Stripe configured — return pledge for simulated flow
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
} catch (error) {
console.error("Stripe checkout error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

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

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { formatWebhookPayload } from "@/lib/exports"
interface ReminderWithPledge {
id: string
pledgeId: string
step: number
channel: string
scheduledAt: Date
payload: unknown
pledge: {
donorName: string | null
donorEmail: string | null
donorPhone: string | null
reference: string
amountPence: number
rail: string
event: { name: string }
organization: { name: string }
}
}
// GET pending webhook events (for external polling)
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json([])
}
const since = request.nextUrl.searchParams.get("since")
const limit = parseInt(request.nextUrl.searchParams.get("limit") || "50")
const reminders = await prisma.reminder.findMany({
where: {
status: "pending",
scheduledAt: { lte: new Date() },
...(since ? { scheduledAt: { gte: new Date(since) } } : {}),
},
include: {
pledge: {
include: {
event: { select: { name: true } },
organization: { select: { name: true } },
},
},
},
take: limit,
orderBy: { scheduledAt: "asc" },
}) as ReminderWithPledge[]
const events = reminders.map((r: ReminderWithPledge) =>
formatWebhookPayload("reminder.due", {
reminderId: r.id,
pledgeId: r.pledgeId,
step: r.step,
channel: r.channel,
scheduledAt: r.scheduledAt,
donor: {
name: r.pledge.donorName,
email: r.pledge.donorEmail,
phone: r.pledge.donorPhone,
},
pledge: {
reference: r.pledge.reference,
amount: r.pledge.amountPence,
rail: r.pledge.rail,
},
event: r.pledge.event.name,
organization: r.pledge.organization.name,
payload: r.payload,
})
)
return NextResponse.json({ events, count: events.length })
} catch (error) {
console.error("Webhooks error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}