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
This commit is contained in:
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data
|
||||
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data
|
||||
|
||||
// Get event + org
|
||||
const event = await prisma.event.findUnique({
|
||||
@@ -35,6 +35,80 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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
|
||||
@@ -65,6 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
eventId,
|
||||
qrSourceId: qrSourceId || null,
|
||||
organizationId: org.id,
|
||||
dueDate: parsedDueDate,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -84,18 +159,32 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 },
|
||||
})),
|
||||
})
|
||||
// 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({
|
||||
|
||||
Reference in New Issue
Block a user