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" import { sendPledgeReceipt } from "@/lib/whatsapp" export async function GET(request: NextRequest) { try { if (!prisma) return NextResponse.json({ pledges: [] }) const sp = request.nextUrl.searchParams const eventId = sp.get("eventId") const status = sp.get("status") const limit = parseInt(sp.get("limit") || "50") const offset = parseInt(sp.get("offset") || "0") const sort = sp.get("sort") || "createdAt" const dir = sp.get("dir") === "asc" ? "asc" as const : "desc" as const const dueSoon = sp.get("dueSoon") === "true" // pledges due in next 7 days const overdue = sp.get("overdue") === "true" const search = sp.get("search") // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: any = {} if (eventId) where.eventId = eventId if (status && status !== "all") where.status = status if (overdue) where.status = "overdue" if (dueSoon) { const now = new Date() const weekFromNow = new Date(now.getTime() + 7 * 86400000) where.dueDate = { gte: now, lte: weekFromNow } where.status = { in: ["new", "initiated"] } } if (search) { where.OR = [ { donorName: { contains: search, mode: "insensitive" } }, { donorEmail: { contains: search, mode: "insensitive" } }, { reference: { contains: search, mode: "insensitive" } }, { donorPhone: { contains: search } }, ] } const orderBy = sort === "dueDate" ? { dueDate: dir } : sort === "amountPence" ? { amountPence: dir } : { createdAt: dir } const [pledges, total] = await Promise.all([ prisma.pledge.findMany({ where, include: { event: { select: { name: true } }, qrSource: { select: { label: true, volunteerName: true } }, }, orderBy, take: limit, skip: offset, }), prisma.pledge.count({ where }), ]) return NextResponse.json({ pledges: pledges.map(p => ({ id: p.id, reference: p.reference, amountPence: p.amountPence, status: p.status, rail: p.rail, donorName: p.donorName, donorEmail: p.donorEmail, donorPhone: p.donorPhone, giftAid: p.giftAid, dueDate: p.dueDate, planId: p.planId, installmentNumber: p.installmentNumber, installmentTotal: p.installmentTotal, eventName: p.event.name, qrSourceLabel: p.qrSource?.label || null, volunteerName: p.qrSource?.volunteerName || null, createdAt: p.createdAt, paidAt: p.paidAt, })), total, limit, offset, }) } catch (error) { console.error("Pledges GET error:", error) return NextResponse.json({ pledges: [], total: 0, error: "Failed to load pledges" }) } } 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, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data // Capture IP for consent audit trail const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown" const consentMetaWithIp = consentMeta ? { ...consentMeta, ip } : undefined // 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 // --- INSTALLMENT MODE: create N linked pledges --- // Auto-generate dates if not provided (1st of each month starting next month) let resolvedDates = installmentDates if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && !resolvedDates?.length) { resolvedDates = [] const now = new Date() for (let i = 0; i < installmentCount; i++) { const d = new Date(now.getFullYear(), now.getMonth() + 1 + i, 1) resolvedDates.push(d.toISOString().split("T")[0]) } } if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && resolvedDates?.length) { const perInstallment = Math.ceil(amountPence / installmentCount) const planId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` let firstRef = "" // eslint-disable-next-line @typescript-eslint/no-explicit-any await prisma.$transaction(async (tx: any) => { for (let i = 0; i < installmentCount; i++) { let ref = "" let attempts = 0 while (attempts < 10) { ref = generateReference(org.refPrefix || "PNPL", perInstallment) const exists = await tx.pledge.findUnique({ where: { reference: ref } }) if (!exists) break attempts++ } if (i === 0) firstRef = ref const installmentDue = new Date(resolvedDates[i]) const p = await tx.pledge.create({ data: { reference: ref, amountPence: perInstallment, currency: "GBP", rail, status: "new", donorName: donorName || null, donorEmail: donorEmail || null, donorPhone: donorPhone || null, donorAddressLine1: donorAddressLine1 || null, donorPostcode: donorPostcode || null, giftAid, giftAidAt: giftAid ? new Date() : null, isZakat: isZakat || false, emailOptIn: emailOptIn || false, whatsappOptIn: whatsappOptIn || false, consentMeta: consentMetaWithIp || undefined, eventId, qrSourceId: qrSourceId || null, organizationId: org.id, dueDate: installmentDue, planId, installmentNumber: i + 1, installmentTotal: installmentCount, }, }) // Reminders scheduled relative to due date (2 days before, on day, 2 days after, 7 days after) const dueDateMs = installmentDue.getTime() await tx.reminder.createMany({ data: [ { pledgeId: p.id, step: 0, channel: "whatsapp", scheduledAt: new Date(dueDateMs - 2 * 86400000), status: "pending", payload: { templateKey: "upcoming_installment" } }, { pledgeId: p.id, step: 1, channel: "whatsapp", scheduledAt: installmentDue, status: "pending", payload: { templateKey: "installment_due" } }, { pledgeId: p.id, step: 2, channel: "email", scheduledAt: new Date(dueDateMs + 2 * 86400000), status: "pending", payload: { templateKey: "gentle_nudge" } }, { pledgeId: p.id, step: 3, channel: "email", scheduledAt: new Date(dueDateMs + 7 * 86400000), status: "pending", payload: { templateKey: "urgency_impact" } }, ], }) await tx.analyticsEvent.create({ data: { eventType: "pledge_completed", pledgeId: p.id, eventId, qrSourceId: qrSourceId || null, metadata: { amountPence: perInstallment, rail, installment: i + 1, of: installmentCount, planId } }, }) } }) // WhatsApp receipt for the plan (only if they consented) if (donorPhone && whatsappOptIn) { const name = donorName?.split(" ")[0] || "there" const { sendWhatsAppMessage } = await import("@/lib/whatsapp") sendWhatsAppMessage(donorPhone, `🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(resolvedDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.` ).catch(err => console.error("[WAHA] Installment receipt failed:", err)) } return NextResponse.json({ id: planId, reference: firstRef }, { status: 201 }) } // --- SINGLE PLEDGE (immediate or deferred) --- const parsedDueDate = scheduleMode === "date" && dueDate ? new Date(dueDate) : null // 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, donorAddressLine1: donorAddressLine1 || null, donorPostcode: donorPostcode || null, giftAid, giftAidAt: giftAid ? new Date() : null, isZakat: isZakat || false, emailOptIn: emailOptIn || false, whatsappOptIn: whatsappOptIn || false, consentMeta: consentMetaWithIp || undefined, eventId, qrSourceId: qrSourceId || null, organizationId: org.id, dueDate: parsedDueDate, }, }) // 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 — based on due date for deferred, or now for immediate if (parsedDueDate) { // DEFERRED: reminders relative to due date const dueDateMs = parsedDueDate.getTime() await tx.reminder.createMany({ data: [ { pledgeId: p.id, step: 0, channel: donorPhone ? "whatsapp" : "email", scheduledAt: new Date(dueDateMs - 2 * 86400000), status: "pending", payload: { templateKey: "upcoming_payment", subject: "Payment reminder — 2 days to go" } }, { pledgeId: p.id, step: 1, channel: donorPhone ? "whatsapp" : "email", scheduledAt: parsedDueDate, status: "pending", payload: { templateKey: "payment_due_today", subject: "Your payment is due today" } }, { pledgeId: p.id, step: 2, channel: "email", scheduledAt: new Date(dueDateMs + 3 * 86400000), status: "pending", payload: { templateKey: "gentle_nudge", subject: "Quick reminder about your pledge" } }, { pledgeId: p.id, step: 3, channel: "email", scheduledAt: new Date(dueDateMs + 10 * 86400000), status: "pending", payload: { templateKey: "final_reminder", subject: "Final reminder about your pledge" } }, ], }) } else { // IMMEDIATE: reminders from now const schedule = calculateReminderSchedule(new Date()) await tx.reminder.createMany({ data: schedule.map((s) => ({ pledgeId: p.id, step: s.step, channel: donorPhone ? "whatsapp" : s.channel, // prefer WhatsApp if phone given 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 = { 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, } } // Async: Send WhatsApp receipt to donor (only if they consented) if (donorPhone && whatsappOptIn) { sendPledgeReceipt(donorPhone, { donorName: donorName || undefined, amountPounds: (amountPence / 100).toFixed(0), eventName: event.name, reference: pledge.reference, rail, bankDetails: rail === "bank" && org.bankSortCode ? { sortCode: org.bankSortCode, accountNo: org.bankAccountNo || "", accountName: org.bankAccountName || org.name, } : undefined, orgName: org.name, }).catch(err => console.error("[WAHA] Receipt send failed:", err)) } // Async: Notify volunteer if QR source has volunteer info if (qrSourceId) { prisma?.qrSource.findUnique({ where: { id: qrSourceId }, select: { volunteerName: true, label: true, pledges: { select: { amountPence: true } } }, }).then(qr => { // In future: if volunteer has a phone number stored, send WhatsApp notification // For now, this is a no-op unless volunteer phone is added to schema if (qr) { console.log(`[PLEDGE] ${qr.volunteerName || qr.label}: +1 pledge (£${(amountPence / 100).toFixed(0)})`) } }).catch(() => {}) } return NextResponse.json(response, { status: 201 }) } catch (error) { console.error("Pledge creation error:", error) return NextResponse.json({ error: "Internal error" }, { status: 500 }) } }