GIFT AID (HMRC compliance):
- Exact HMRC model declaration text displayed and recorded
- Home address (line 1 + postcode) collected when Gift Aid is ticked
- giftAidAt timestamp recorded separately from the boolean
- Declaration text, donor name, timestamp stored in consentMeta JSON
EMAIL + WHATSAPP (GDPR/PECR compliance):
- Separate, granular opt-in checkboxes (not bundled, not pre-ticked)
- Each consent records: exact text shown, timestamp, consent version
- Consent checkboxes only appear when relevant contact info is provided
- Cron reminders gated on consent — no sends without opt-in
- Pledge creation WhatsApp receipt gated on whatsappOptIn
AUDIT TRAIL (consentMeta JSON on every pledge):
- giftAid: {declared, declarationText, declaredAt}
- email: {granted, consentText, grantedAt}
- whatsapp: {granted, consentText, grantedAt}
- IP address captured server-side from x-forwarded-for
- User agent captured client-side
- consentVersion field for tracking wording changes
EXPORTS:
- CRM CSV now includes: donor_address, donor_postcode, gift_aid_declared_at,
is_zakat, email_opt_in, whatsapp_opt_in
- Gift Aid export has full HMRC-required fields
Schema: 6 new columns on Pledge (donorAddressLine1, donorPostcode,
giftAidAt, emailOptIn, whatsappOptIn, consentMeta)
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
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<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,
|
|
}
|
|
}
|
|
|
|
// 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 })
|
|
}
|
|
}
|