Files
calvana/pledge-now-pay-later/src/app/api/pledges/route.ts
Omair Saleh 250221b530 feat: deferred payments & installment plans — pledge = promise to pay on a date
CORE PRODUCT SHIFT:
A pledge is now a promise to pay on a future date, not just 'pay now'.

NEW FLOW: Amount → Schedule → Payment/Identity → Confirmation

SCHEDULE STEP (/p/[token] step 1):
- 'Pay right now' — existing card/DD/bank flow
- 'Pay on a specific date' — calendar picker with smart suggestions
  (This Friday, End of month, Payday 1st, In 2 weeks, In 1 month)
- 'Split into monthly payments' — 2/3/4/6/12 month installment plans
  with per-installment breakdown and date schedule

SCHEMA CHANGES:
- Pledge.dueDate — when the donor promises to pay (null = now)
- Pledge.planId — groups installment pledges together
- Pledge.installmentNumber / installmentTotal — e.g. 2 of 4
- Pledge.reminderSentForDueDate — tracking flag
- New indexes on dueDate+status and planId

INSTALLMENT PLANS:
- Creates N linked Pledge records with shared planId
- Each installment gets its own reference, due date, reminders
- Reminders: 2 days before, on due date, 3 days after, 10 days after
- WhatsApp receipt shows full plan summary

DEFERRED SINGLE PLEDGES:
- Reminders anchored to due date, not creation date
- 'Pay on date' → reminders: 2 days before, on day, +3d nudge, +10d final
- WhatsApp preferred when phone number provided

DASHBOARD:
- API returns dueDate, planId, installment info for each pledge
- Confirmation step shows schedule details for deferred pledges
2026-03-03 04:43:19 +08:00

255 lines
10 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 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, scheduleMode, dueDate, installmentCount, installmentDates } = 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
// --- INSTALLMENT MODE: create N linked pledges ---
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && installmentDates?.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(installmentDates[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,
giftAid,
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
if (donorPhone) {
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(installmentDates[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,
giftAid,
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 (non-blocking)
if (donorPhone) {
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 })
}
}