feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
135
pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts
Normal file
135
pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
82
pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts
Normal file
82
pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user