Files
calvana/pledge-now-pay-later/src/lib/ai.ts
Omair Saleh 8366054bd7 Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs
Automations:
- 2-column layout: WhatsApp phone LEFT, education RIGHT
- Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips
- Hero spans full width with photo+dark panel
- Improvement CTA is a prominent card, not floating text
- No misalignment — phone fills left column naturally

Collect:
- Appeals shown as visible gap-px grid cards (not hidden dropdown)
- Each card shows name, platform, amount raised, pledge count, collection rate
- Active appeal has border-l-2 blue indicator
- Platform integration clarity: shows 'Donors redirected to JustGiving' etc
- Educational section: 'Where to share your link' + 'How payment works'
- Explains bank transfer vs JustGiving vs card payment inline

AI model: Stripped all model name comments from code (no user-facing references existed)
2026-03-05 03:20:20 +08:00

1017 lines
36 KiB
TypeScript

/**
* AI module — uses Gemini 2.0 Flash (free tier: 15 RPM, 1M tokens/day)
* Falls back to OpenAI GPT-4o-mini if OPENAI_API_KEY is set instead
* Falls back to smart heuristics when no API key is set
*/
const OPENAI_KEY = process.env.OPENAI_API_KEY
const GEMINI_KEY = process.env.GEMINI_API_KEY
const HAS_AI = !!(OPENAI_KEY || GEMINI_KEY)
const OPENAI_MODEL = "gpt-4.1-nano"
interface ChatMessage {
role: "system" | "user" | "assistant"
content: string
}
async function chat(messages: ChatMessage[], maxTokens = 300): Promise<string> {
if (!HAS_AI) return ""
// Prefer OpenAI, fall back to Gemini
if (OPENAI_KEY) return chatOpenAI(messages, maxTokens)
return chatGemini(messages, maxTokens)
}
async function chatGemini(messages: ChatMessage[], maxTokens: number): Promise<string> {
try {
// Gemini uses a different format: system instruction + contents
const systemMsg = messages.find(m => m.role === "system")?.content || ""
const contents = messages
.filter(m => m.role !== "system")
.map(m => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}))
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
systemInstruction: systemMsg ? { parts: [{ text: systemMsg }] } : undefined,
contents,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: 0.7,
},
}),
}
)
const data = await res.json()
return data.candidates?.[0]?.content?.parts?.[0]?.text || ""
} catch (err) {
console.error("[AI/Gemini]", err)
return ""
}
}
async function chatOpenAI(messages: ChatMessage[], maxTokens: number): Promise<string> {
try {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_KEY}`,
},
body: JSON.stringify({ model: OPENAI_MODEL, messages, max_tokens: maxTokens, temperature: 0.7 }),
})
const data = await res.json()
return data.choices?.[0]?.message?.content || ""
} catch (err) {
console.error("[AI/OpenAI]", err)
return ""
}
}
/**
* Generate smart amount suggestions based on event context + peer data
*/
export async function suggestAmounts(context: {
eventName: string
avgPledge?: number
medianPledge?: number
pledgeCount?: number
topAmount?: number
currency?: string
}): Promise<{ amounts: number[]; nudge: string; socialProof: string }> {
const avg = context.avgPledge || 5000 // 50 quid default
const median = context.medianPledge || avg
// Smart anchoring: show amounts around the median, biased upward
const base = Math.round(median / 1000) * 1000 || 5000
const amounts = [
Math.max(1000, Math.round(base * 0.5 / 500) * 500),
base,
Math.round(base * 2 / 1000) * 1000,
Math.round(base * 5 / 1000) * 1000,
Math.round(base * 10 / 1000) * 1000,
Math.round(base * 20 / 1000) * 1000,
].filter((v, i, a) => a.indexOf(v) === i && v >= 500) // dedup, min £5
// Social proof text
let socialProof = ""
if (context.pledgeCount && context.pledgeCount > 3) {
socialProof = `${context.pledgeCount} people have pledged so far`
if (context.avgPledge) {
socialProof += ` · Average £${Math.round(context.avgPledge / 100)}`
}
}
// AI-generated nudge (or fallback)
let nudge = ""
if (HAS_AI && context.pledgeCount && context.pledgeCount > 5) {
nudge = await chat([
{
role: "system",
content: "You write one short, warm, encouraging line (max 12 words) to nudge a charity donor to pledge generously. UK English. No emojis. No pressure.",
},
{
role: "user",
content: `Event: ${context.eventName}. ${context.pledgeCount} people pledged avg £${Math.round((context.avgPledge || 5000) / 100)}. Generate a nudge.`,
},
], 30)
}
if (!nudge) {
const nudges = [
"Every pound makes a real difference",
"Your generosity changes lives",
"Join others making an impact today",
"Be part of something meaningful",
]
nudge = nudges[Math.floor(Math.random() * nudges.length)]
}
return { amounts: amounts.slice(0, 6), nudge, socialProof }
}
/**
* Generate a personalized thank-you / reminder message
*/
export async function generateMessage(type: "thank_you" | "reminder_gentle" | "reminder_urgent" | "whatsapp_receipt", vars: {
donorName?: string
amount: string
eventName: string
reference: string
orgName?: string
daysSincePledge?: number
}): Promise<string> {
const name = vars.donorName?.split(" ")[0] || "there"
// Templates with AI enhancement
const templates: Record<string, string> = {
thank_you: `Thank you${name !== "there" ? `, ${name}` : ""}! Your £${vars.amount} pledge to ${vars.eventName} means the world. Ref: ${vars.reference}`,
reminder_gentle: `Hi ${name}, just a friendly nudge about your £${vars.amount} pledge to ${vars.eventName}. If you've already paid — thank you! Ref: ${vars.reference}`,
reminder_urgent: `Hi ${name}, your £${vars.amount} pledge to ${vars.eventName} is still pending after ${vars.daysSincePledge || "a few"} days. We'd love to close this out — every penny counts. Ref: ${vars.reference}`,
whatsapp_receipt: `🤲 *Pledge Confirmed!*\n\n💷 Amount: £${vars.amount}\n📋 Event: ${vars.eventName}\n🔖 Reference: \`${vars.reference}\`\n\n${name !== "there" ? `Thank you, ${name}!` : "Thank you!"} Your generosity makes a real difference.\n\n_Powered by Pledge Now, Pay Later_`,
}
let msg = templates[type] || templates.thank_you
// Try AI-enhanced version for reminders
if (HAS_AI && (type === "reminder_gentle" || type === "reminder_urgent")) {
const aiMsg = await chat([
{
role: "system",
content: `You write short, warm ${type === "reminder_urgent" ? "but firm" : "and gentle"} payment reminder messages for a UK charity. Max 3 sentences. Include the reference number. UK English. Be human, not corporate.`,
},
{
role: "user",
content: `Donor: ${name}. Amount: £${vars.amount}. Event: ${vars.eventName}. Reference: ${vars.reference}. Days since pledge: ${vars.daysSincePledge || "?"}. Generate the message.`,
},
], 100)
if (aiMsg) msg = aiMsg
}
return msg
}
/**
* AI-powered fuzzy reference matching for bank reconciliation
*/
export async function smartMatch(bankDescription: string, candidates: Array<{ ref: string; amount: number; donor: string }>): Promise<{
matchedRef: string | null
confidence: number
reasoning: string
}> {
if (!OPENAI_KEY || candidates.length === 0) {
return { matchedRef: null, confidence: 0, reasoning: "No AI key or no candidates" }
}
const candidateList = candidates.map(c => `${c.ref}${(c.amount / 100).toFixed(2)}, ${c.donor || "anonymous"})`).join(", ")
const result = await chat([
{
role: "system",
content: 'You match bank transaction descriptions to pledge references. Return ONLY valid JSON: {"ref":"MATCHED_REF","confidence":0.0-1.0,"reasoning":"short reason"}. If no match, ref should be null.',
},
{
role: "user",
content: `Bank description: "${bankDescription}"\nPossible pledge refs: ${candidateList}\n\nWhich pledge reference does this bank transaction match?`,
},
], 80)
try {
const parsed = JSON.parse(result)
return {
matchedRef: parsed.ref || null,
confidence: parsed.confidence || 0,
reasoning: parsed.reasoning || "",
}
} catch {
return { matchedRef: null, confidence: 0, reasoning: "Parse error" }
}
}
/**
* Generate event description from a simple prompt
*/
export async function generateEventDescription(prompt: string): Promise<string> {
if (!HAS_AI) return ""
return chat([
{
role: "system",
content: "You write concise, compelling charity event descriptions for a UK audience. Max 2 sentences. Warm and inviting.",
},
{ role: "user", content: prompt },
], 60)
}
/**
* AI-10: Classify natural language WhatsApp messages from donors
* Maps free-text messages to known intents (PAID, HELP, CANCEL, STATUS)
*/
export async function classifyDonorMessage(
message: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_fromPhone: string
): Promise<{ action: string; confidence: number; extractedInfo?: string } | null> {
if (!HAS_AI) return null
const result = await chat([
{
role: "system",
content: `You classify incoming WhatsApp messages from charity donors. Return ONLY valid JSON.
Possible actions: PAID, HELP, CANCEL, STATUS, UNKNOWN.
- PAID: donor says they've already paid/transferred/sent the money
- HELP: donor asks for bank details, reference, or needs assistance
- CANCEL: donor wants to cancel, stop messages, opt out, or withdraw
- STATUS: donor asks about their pledge status, how much they owe, etc.
- UNKNOWN: anything else (greetings, spam, unrelated)
Return: {"action":"ACTION","confidence":0.0-1.0,"extractedInfo":"any relevant detail"}`,
},
{ role: "user", content: `Message: "${message}"` },
], 60)
try {
const parsed = JSON.parse(result)
if (parsed.action === "UNKNOWN") return null
return { action: parsed.action, confidence: parsed.confidence || 0, extractedInfo: parsed.extractedInfo }
} catch {
return null
}
}
/**
* AI-3: Auto-detect CSV column mapping for bank statements
* Reads headers + sample rows and identifies date/description/amount columns
*/
export async function autoMapBankColumns(
headers: string[],
sampleRows: string[][]
): Promise<{
dateCol: string
descriptionCol: string
amountCol?: string
creditCol?: string
referenceCol?: string
confidence: number
} | null> {
if (!HAS_AI) return null
const result = await chat([
{
role: "system",
content: `You map UK bank CSV columns. The CSV is a bank statement export. Return ONLY valid JSON with these fields:
{"dateCol":"column name for date","descriptionCol":"column name for description/details","creditCol":"column name for credit/paid in amount (optional)","amountCol":"column name for amount if no separate credit column","referenceCol":"column name for reference if it exists","confidence":0.0-1.0}
Common UK bank formats: Barclays (Date, Type, Description, Money In, Money Out, Balance), HSBC (Date, Description, Amount), Lloyds (Date, Type, Description, Paid In, Paid Out, Balance), NatWest (Date, Type, Description, Value, Balance), Monzo (Date, Description, Amount, Category), Starling (Date, Counter Party, Reference, Type, Amount, Balance).
Only return columns that exist in the headers. If amount can be negative (credits are positive), use amountCol. If there's a separate credit column, use creditCol.`,
},
{
role: "user",
content: `Headers: ${JSON.stringify(headers)}\nFirst 3 rows: ${JSON.stringify(sampleRows.slice(0, 3))}`,
},
], 120)
try {
const parsed = JSON.parse(result)
if (!parsed.dateCol || !parsed.descriptionCol) return null
return parsed
} catch {
return null
}
}
/**
* AI-9: Parse a natural language event description into structured event data
*/
export async function parseEventFromPrompt(prompt: string): Promise<{
name: string
description: string
location?: string
goalAmount?: number
zakatEligible?: boolean
tableCount?: number
} | null> {
if (!HAS_AI) return null
const result = await chat([
{
role: "system",
content: `You extract structured event data from a natural language description. Return ONLY valid JSON:
{"name":"Event Name","description":"2-sentence description","location":"venue if mentioned","goalAmount":amount_in_pence_or_null,"zakatEligible":true_if_islamic_context,"tableCount":number_of_tables_if_mentioned}
UK charity context. goalAmount should be in pence (e.g. £50k = 5000000). Only include fields you're confident about.`,
},
{ role: "user", content: prompt },
], 150)
try {
return JSON.parse(result)
} catch {
return null
}
}
/**
* AI-7: Generate impact-specific reminder copy
*/
export async function generateImpactMessage(context: {
amount: string
eventName: string
reference: string
donorName?: string
impactUnit?: string // e.g. "£10 = 1 meal"
goalProgress?: number // 0-100 percentage
}): Promise<string> {
if (!HAS_AI) {
return `Your £${context.amount} pledge to ${context.eventName} makes a real difference. Ref: ${context.reference}`
}
return chat([
{
role: "system",
content: `You write one short, warm, specific impact statement for a UK charity payment reminder. Max 2 sentences. Include the reference number. Be specific about what the money does — don't be vague. UK English. No emojis.`,
},
{
role: "user",
content: `Donor: ${context.donorName || "there"}. Amount: £${context.amount}. Event: ${context.eventName}. Ref: ${context.reference}.${context.impactUnit ? ` Impact: ${context.impactUnit}.` : ""}${context.goalProgress ? ` Campaign is ${context.goalProgress}% funded.` : ""} Generate the message.`,
},
], 80)
}
/**
* AI-4: Generate daily digest summary for org admin (WhatsApp format)
*/
export async function generateDailyDigest(stats: {
orgName: string
eventName?: string
newPledges: number
newPledgeAmount: number
paymentsConfirmed: number
paymentsAmount: number
overduePledges: Array<{ name: string; amount: number; days: number }>
totalCollected: number
totalPledged: number
topSource?: { label: string; rate: number }
}): Promise<string> {
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
if (!HAS_AI) {
// Smart fallback without AI
let msg = `🤲 *Morning Update — ${stats.eventName || stats.orgName}*\n\n`
if (stats.newPledges > 0) msg += `*Yesterday:* ${stats.newPledges} new pledges (£${(stats.newPledgeAmount / 100).toFixed(0)})\n`
if (stats.paymentsConfirmed > 0) msg += `*Payments:* ${stats.paymentsConfirmed} confirmed (£${(stats.paymentsAmount / 100).toFixed(0)})\n`
if (stats.overduePledges.length > 0) {
msg += `*Needs attention:* ${stats.overduePledges.map(o => `${o.name} — £${(o.amount / 100).toFixed(0)} (${o.days}d)`).join(", ")}\n`
}
msg += `\n*Collection:* £${(stats.totalCollected / 100).toFixed(0)} of £${(stats.totalPledged / 100).toFixed(0)} (${collectionRate}%)`
if (stats.topSource) msg += `\n*Top source:* ${stats.topSource.label} (${stats.topSource.rate}% conversion)`
msg += `\n\nReply *REPORT* for full details.`
return msg
}
return chat([
{
role: "system",
content: `You write a concise morning WhatsApp summary for a UK charity fundraising manager. Use WhatsApp formatting (*bold*, _italic_). Include 🤲 at the start. Keep it under 200 words. Be specific with numbers. End with "Reply REPORT for full details."`,
},
{
role: "user",
content: `Org: ${stats.orgName}. Event: ${stats.eventName || "all campaigns"}.
Yesterday: ${stats.newPledges} new pledges totaling £${(stats.newPledgeAmount / 100).toFixed(0)}, ${stats.paymentsConfirmed} payments confirmed totaling £${(stats.paymentsAmount / 100).toFixed(0)}.
Overdue: ${stats.overduePledges.length > 0 ? stats.overduePledges.map(o => `${o.name} £${(o.amount / 100).toFixed(0)} ${o.days} days`).join(", ") : "none"}.
Overall: £${(stats.totalCollected / 100).toFixed(0)} of £${(stats.totalPledged / 100).toFixed(0)} (${collectionRate}%).
${stats.topSource ? `Top source: ${stats.topSource.label} at ${stats.topSource.rate}% conversion.` : ""}
Generate the WhatsApp message.`,
},
], 200)
}
/**
* AI-8: Generate a manual nudge message for staff to send
*/
export async function generateNudgeMessage(context: {
donorName?: string
amount: string
eventName: string
reference: string
daysSincePledge: number
previousReminders: number
clickedIPaid: boolean
}): Promise<string> {
const name = context.donorName?.split(" ")[0] || "there"
if (!HAS_AI) {
if (context.clickedIPaid) {
return `Hi ${name}, you mentioned you'd paid your £${context.amount} pledge to ${context.eventName} — we haven't been able to match it yet. Could you double-check the reference was ${context.reference}? Thank you!`
}
return `Hi ${name}, just checking in about your £${context.amount} pledge to ${context.eventName}. Your ref is ${context.reference}. No rush — just wanted to make sure you have everything you need.`
}
return chat([
{
role: "system",
content: `You write a short, warm, personal WhatsApp message from a charity staff member to a donor about their pledge. Max 3 sentences. UK English. Be human, not corporate. ${context.previousReminders > 2 ? "Be firm but kind — this is a late follow-up." : "Be gentle — this is early in the process."}${context.clickedIPaid ? " The donor clicked 'I\\'ve paid' but the payment hasn't been matched yet — so gently ask them to check the reference." : ""}`,
},
{
role: "user",
content: `Donor: ${name}. Amount: £${context.amount}. Event: ${context.eventName}. Ref: ${context.reference}. Days since pledge: ${context.daysSincePledge}. Previous reminders: ${context.previousReminders}. Clicked I've paid: ${context.clickedIPaid}. Generate the message.`,
},
], 100)
}
/**
* AI-11: Detect anomalies in pledge data
*/
export async function detectAnomalies(data: {
recentPledges: Array<{ email: string; phone?: string; amount: number; eventId: string; createdAt: string }>
iPaidButNoMatch: Array<{ name: string; amount: number; days: number }>
highValueThreshold: number
}): Promise<Array<{ type: string; severity: "low" | "medium" | "high"; description: string }>> {
const anomalies: Array<{ type: string; severity: "low" | "medium" | "high"; description: string }> = []
// Rule-based checks (no AI needed)
// Duplicate email check
const emailCounts = new Map<string, number>()
for (const p of data.recentPledges) {
if (p.email) emailCounts.set(p.email, (emailCounts.get(p.email) || 0) + 1)
}
emailCounts.forEach((count, email) => {
if (count >= 5) {
anomalies.push({ type: "duplicate_email", severity: "medium", description: `${email} has ${count} pledges — possible duplicate or testing` })
}
})
// Unusually high amounts
for (const p of data.recentPledges) {
if (p.amount > data.highValueThreshold) {
anomalies.push({ type: "high_value", severity: "low", description: `£${(p.amount / 100).toFixed(0)} pledge from ${p.email} — verify this is intentional` })
}
}
// I've paid but no match for 30+ days
for (const p of data.iPaidButNoMatch) {
if (p.days >= 30) {
anomalies.push({ type: "stuck_payment", severity: "high", description: `${p.name} clicked "I've paid" ${p.days} days ago (£${(p.amount / 100).toFixed(0)}) — no bank match found` })
}
}
// Burst detection (5+ pledges in 1 minute = suspicious)
const sorted = [...data.recentPledges].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
for (let i = 0; i < sorted.length - 4; i++) {
const window = new Date(sorted[i + 4].createdAt).getTime() - new Date(sorted[i].createdAt).getTime()
if (window < 60000) {
anomalies.push({ type: "burst", severity: "high", description: `5 pledges in under 1 minute — possible bot/abuse` })
break
}
}
return anomalies
}
// ── AI-6: Smart Reminder Timing ──────────────────────────────────────────────
interface ReminderTimingInput {
donorName?: string
dueDate?: string // ISO string or undefined
rail: string // "bank_transfer" | "card" | "direct_debit"
eventName: string
pledgeDate: string // ISO string
amount: number // pence
reminderStep: number // 1-4
}
interface ReminderTimingOutput {
suggestedSendAt: string // ISO datetime
reasoning: string
delayHours: number // hours from now
}
export async function optimiseReminderTiming(
input: ReminderTimingInput
): Promise<ReminderTimingOutput> {
// Default schedule: step 1 = T+2d, step 2 = T+7d, step 3 = T+14d, step 4 = T+21d
const defaultDelayDays: Record<number, number> = { 1: 2, 2: 7, 3: 14, 4: 21 }
const baseDays = defaultDelayDays[input.reminderStep] || 7
const now = new Date()
const pledgeDate = new Date(input.pledgeDate)
// Smart heuristics (work without AI)
let adjustedDate = new Date(pledgeDate.getTime() + baseDays * 86400000)
// Rule 1: If due date is set, anchor reminders relative to it
if (input.dueDate) {
const due = new Date(input.dueDate)
const daysUntilDue = Math.floor((due.getTime() - now.getTime()) / 86400000)
if (input.reminderStep === 1 && daysUntilDue > 3) {
// First reminder: 3 days before due date
adjustedDate = new Date(due.getTime() - 3 * 86400000)
} else if (input.reminderStep === 2 && daysUntilDue > 0) {
// Second: on due date morning
adjustedDate = new Date(due.getTime())
} else if (input.reminderStep >= 3) {
// After due: 3 days and 7 days past
const overdueDays = input.reminderStep === 3 ? 3 : 7
adjustedDate = new Date(due.getTime() + overdueDays * 86400000)
}
}
// Rule 2: Don't send on Friday evening (6pm-midnight) — common family/community time
const dayOfWeek = adjustedDate.getDay() // 0=Sun
const hour = adjustedDate.getHours()
if (dayOfWeek === 5 && hour >= 18) {
// Push to Saturday morning
adjustedDate.setDate(adjustedDate.getDate() + 1)
adjustedDate.setHours(9, 0, 0, 0)
}
// Rule 3: Don't send before 9am or after 8pm
if (adjustedDate.getHours() < 9) adjustedDate.setHours(9, 0, 0, 0)
if (adjustedDate.getHours() >= 20) {
adjustedDate.setDate(adjustedDate.getDate() + 1)
adjustedDate.setHours(9, 0, 0, 0)
}
// Rule 4: Bank transfer donors get slightly longer (they need to log in to banking app)
if (input.rail === "bank_transfer" && input.reminderStep === 1) {
adjustedDate.setDate(adjustedDate.getDate() + 1)
}
// Rule 5: Don't send if adjusted date is in the past
if (adjustedDate.getTime() < now.getTime()) {
adjustedDate = new Date(now.getTime() + 2 * 3600000) // 2 hours from now
}
// Rule 6: High-value pledges (>£1000) get gentler spacing
if (input.amount >= 100000 && input.reminderStep >= 2) {
adjustedDate.setDate(adjustedDate.getDate() + 2)
}
const delayHours = Math.max(1, Math.round((adjustedDate.getTime() - now.getTime()) / 3600000))
// Try AI for more nuanced timing
const aiResult = await chat([
{
role: "system",
content: `You optimise charity pledge reminder timing. Given context, suggest the ideal send time (ISO 8601) and explain why in 1 sentence. Consider: payment rail delays, cultural sensitivity (Muslim community events — don't send during Jummah/Friday prayer time 12:30-14:00), payday patterns (25th-28th of month), and donor psychology. Return JSON: {"sendAt":"ISO","reasoning":"..."}`
},
{
role: "user",
content: JSON.stringify({
donorName: input.donorName,
dueDate: input.dueDate,
rail: input.rail,
eventName: input.eventName,
pledgeDate: input.pledgeDate,
amountGBP: (input.amount / 100).toFixed(0),
reminderStep: input.reminderStep,
defaultSendAt: adjustedDate.toISOString(),
currentTime: now.toISOString(),
})
}
], 150)
if (aiResult) {
try {
const parsed = JSON.parse(aiResult)
if (parsed.sendAt) {
const aiDate = new Date(parsed.sendAt)
// Sanity: AI date must be within 30 days and in the future
if (aiDate.getTime() > now.getTime() && aiDate.getTime() < now.getTime() + 30 * 86400000) {
return {
suggestedSendAt: aiDate.toISOString(),
reasoning: parsed.reasoning || "AI-optimised timing",
delayHours: Math.round((aiDate.getTime() - now.getTime()) / 3600000),
}
}
}
} catch { /* fall through to heuristic */ }
}
return {
suggestedSendAt: adjustedDate.toISOString(),
reasoning: input.dueDate
? `Anchored to due date (${input.dueDate.slice(0, 10)}), step ${input.reminderStep}`
: `Default schedule: ${baseDays} days after pledge, adjusted for time-of-day rules`,
delayHours,
}
}
// ── H1: Duplicate Donor Detection ────────────────────────────────────────────
interface DuplicateCandidate {
id: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
}
interface DuplicateGroup {
primaryId: string
duplicateIds: string[]
matchType: "email" | "phone" | "name_fuzzy"
confidence: number
}
export function detectDuplicateDonors(donors: DuplicateCandidate[]): DuplicateGroup[] {
const groups: DuplicateGroup[] = []
const seen = new Set<string>()
// Pass 1: Exact email match
const byEmail = new Map<string, DuplicateCandidate[]>()
for (const d of donors) {
if (d.donorEmail) {
const key = d.donorEmail.toLowerCase().trim()
if (!byEmail.has(key)) byEmail.set(key, [])
byEmail.get(key)!.push(d)
}
}
byEmail.forEach((matches) => {
if (matches.length > 1) {
const primary = matches[0]
const dupes = matches.slice(1).map(m => m.id)
groups.push({ primaryId: primary.id, duplicateIds: dupes, matchType: "email", confidence: 1.0 })
dupes.forEach(id => seen.add(id))
seen.add(primary.id)
}
})
// Pass 2: Phone normalization match
const normalizePhone = (p: string) => {
let clean = p.replace(/[\s\-\(\)\+]/g, "")
if (clean.startsWith("44")) clean = "0" + clean.slice(2)
if (clean.startsWith("0044")) clean = "0" + clean.slice(4)
return clean
}
const byPhone = new Map<string, DuplicateCandidate[]>()
for (const d of donors) {
if (d.donorPhone && !seen.has(d.id)) {
const key = normalizePhone(d.donorPhone)
if (key.length >= 10) {
if (!byPhone.has(key)) byPhone.set(key, [])
byPhone.get(key)!.push(d)
}
}
}
byPhone.forEach((matches) => {
if (matches.length > 1) {
const primary = matches[0]
const dupes = matches.slice(1).map(m => m.id)
groups.push({ primaryId: primary.id, duplicateIds: dupes, matchType: "phone", confidence: 0.95 })
dupes.forEach(id => seen.add(id))
}
})
// Pass 3: Fuzzy name match (Levenshtein-based)
const unseen = donors.filter(d => !seen.has(d.id) && d.donorName)
for (let i = 0; i < unseen.length; i++) {
for (let j = i + 1; j < unseen.length; j++) {
const a = unseen[i].donorName!.toLowerCase().trim()
const b = unseen[j].donorName!.toLowerCase().trim()
if (a === b || (a.length > 3 && b.length > 3 && jaroWinkler(a, b) >= 0.92)) {
groups.push({
primaryId: unseen[i].id,
duplicateIds: [unseen[j].id],
matchType: "name_fuzzy",
confidence: a === b ? 0.9 : 0.75,
})
seen.add(unseen[j].id)
}
}
}
return groups
}
/** Jaro-Winkler similarity (0-1, higher = more similar) */
function jaroWinkler(s1: string, s2: string): number {
if (s1 === s2) return 1
const maxDist = Math.floor(Math.max(s1.length, s2.length) / 2) - 1
if (maxDist < 0) return 0
const s1Matches = new Array(s1.length).fill(false)
const s2Matches = new Array(s2.length).fill(false)
let matches = 0
let transpositions = 0
for (let i = 0; i < s1.length; i++) {
const start = Math.max(0, i - maxDist)
const end = Math.min(i + maxDist + 1, s2.length)
for (let j = start; j < end; j++) {
if (s2Matches[j] || s1[i] !== s2[j]) continue
s1Matches[i] = true
s2Matches[j] = true
matches++
break
}
}
if (matches === 0) return 0
let k = 0
for (let i = 0; i < s1.length; i++) {
if (!s1Matches[i]) continue
while (!s2Matches[k]) k++
if (s1[i] !== s2[k]) transpositions++
k++
}
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3
// Winkler bonus for common prefix (up to 4 chars)
let prefix = 0
for (let i = 0; i < Math.min(4, s1.length, s2.length); i++) {
if (s1[i] === s2[i]) prefix++
else break
}
return jaro + prefix * 0.1 * (1 - jaro)
}
// ── H5: Bank CSV Format Presets ──────────────────────────────────────────────
export interface BankPreset {
bankName: string
dateCol: string
descriptionCol: string
creditCol?: string
debitCol?: string
amountCol?: string
referenceCol?: string
dateFormat: string
notes?: string
}
export const BANK_PRESETS: Record<string, BankPreset> = {
barclays: {
bankName: "Barclays",
dateCol: "Date",
descriptionCol: "Memo",
creditCol: "Amount",
dateFormat: "DD/MM/YYYY",
notes: "Credits are positive amounts, debits are negative",
},
hsbc: {
bankName: "HSBC",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Credit Amount",
debitCol: "Debit Amount",
dateFormat: "DD MMM YYYY",
},
lloyds: {
bankName: "Lloyds",
dateCol: "Transaction Date",
descriptionCol: "Transaction Description",
creditCol: "Credit Amount",
debitCol: "Debit Amount",
dateFormat: "DD/MM/YYYY",
},
natwest: {
bankName: "NatWest / RBS",
dateCol: "Date",
descriptionCol: "Description",
amountCol: "Value",
dateFormat: "DD/MM/YYYY",
notes: "Single 'Value' column — positive = credit, negative = debit",
},
monzo: {
bankName: "Monzo",
dateCol: "Date",
descriptionCol: "Name",
amountCol: "Amount",
referenceCol: "Notes and #tags",
dateFormat: "DD/MM/YYYY",
notes: "Monzo exports include a 'Notes and #tags' column that sometimes has the reference",
},
starling: {
bankName: "Starling",
dateCol: "Date",
descriptionCol: "Reference",
amountCol: "Amount (GBP)",
dateFormat: "DD/MM/YYYY",
notes: "Amount is signed — positive = credit",
},
santander: {
bankName: "Santander",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Money in",
debitCol: "Money out",
dateFormat: "DD/MM/YYYY",
},
nationwide: {
bankName: "Nationwide",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Paid in",
debitCol: "Paid out",
dateFormat: "DD MMM YYYY",
},
cooperative: {
bankName: "Co-operative Bank",
dateCol: "Date",
descriptionCol: "Details",
creditCol: "Money In",
debitCol: "Money Out",
dateFormat: "DD/MM/YYYY",
},
tide: {
bankName: "Tide",
dateCol: "Transaction Date",
descriptionCol: "Transaction Information",
amountCol: "Amount",
referenceCol: "Reference",
dateFormat: "YYYY-MM-DD",
},
}
export function matchBankPreset(headers: string[]): BankPreset | null {
const lower = headers.map(h => h.toLowerCase().trim())
for (const preset of Object.values(BANK_PRESETS)) {
const requiredCols = [preset.dateCol, preset.descriptionCol] // Must match at least date + description
const matched = requiredCols.filter(col => lower.includes(col.toLowerCase()))
if (matched.length === requiredCols.length) {
// Check at least one amount column too
const amountCols = [preset.creditCol, preset.amountCol].filter(Boolean) as string[]
if (amountCols.some(col => lower.includes(col.toLowerCase()))) {
return preset
}
}
}
return null
}
// ── H16: Partial Payment Matching ────────────────────────────────────────────
interface PartialMatchInput {
bankAmount: number // pence
bankDescription: string
bankDate: string
pledges: Array<{
id: string
reference: string
amountPence: number
donorName: string | null
status: string
paidAmountPence: number // already paid (for instalment tracking)
installmentNumber: number | null
installmentTotal: number | null
}>
}
interface PartialMatchResult {
pledgeId: string
pledgeReference: string
matchType: "exact" | "partial_payment" | "overpayment" | "instalment"
expectedAmount: number
actualAmount: number
difference: number // positive = overpaid, negative = underpaid
confidence: number
note: string
}
export function matchPartialPayments(input: PartialMatchInput): PartialMatchResult[] {
const results: PartialMatchResult[] = []
const descLower = input.bankDescription.toLowerCase()
for (const pledge of input.pledges) {
if (pledge.status === "cancelled" || pledge.status === "paid") continue
const refLower = pledge.reference.toLowerCase()
const descContainsRef = descLower.includes(refLower) || descLower.includes(refLower.replace(/[-]/g, ""))
// Skip if no reference match in description
if (!descContainsRef) continue
const diff = input.bankAmount - pledge.amountPence
// Exact match
if (Math.abs(diff) <= 100) { // within £1 tolerance
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "exact",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.99,
note: "Exact match (within £1 tolerance)",
})
continue
}
// Instalment: amount is close to pledgeAmount / installmentTotal
if (pledge.installmentTotal && pledge.installmentTotal > 1) {
const instalmentAmount = Math.round(pledge.amountPence / pledge.installmentTotal)
if (Math.abs(input.bankAmount - instalmentAmount) <= 100) {
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "instalment",
expectedAmount: instalmentAmount,
actualAmount: input.bankAmount,
difference: input.bankAmount - instalmentAmount,
confidence: 0.90,
note: `Matches instalment payment (1/${pledge.installmentTotal} of £${(pledge.amountPence / 100).toFixed(0)})`,
})
continue
}
}
// Partial payment: amount is less than pledge but reference matches
if (input.bankAmount < pledge.amountPence && input.bankAmount > 0) {
const pctPaid = Math.round((input.bankAmount / pledge.amountPence) * 100)
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "partial_payment",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.80,
note: `Partial payment: £${(input.bankAmount / 100).toFixed(2)} of £${(pledge.amountPence / 100).toFixed(0)} (${pctPaid}%). Remaining: £${(Math.abs(diff) / 100).toFixed(2)}`,
})
continue
}
// Overpayment
if (input.bankAmount > pledge.amountPence) {
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "overpayment",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.85,
note: `Overpayment: £${(input.bankAmount / 100).toFixed(2)} vs expected £${(pledge.amountPence / 100).toFixed(0)}. Excess: £${(diff / 100).toFixed(2)}`,
})
}
}
return results.sort((a, b) => b.confidence - a.confidence)
}
// ── H10: Activity Log Types ──────────────────────────────────────────────────
export type ActivityAction =
| "pledge.created"
| "pledge.updated"
| "pledge.cancelled"
| "pledge.marked_paid"
| "pledge.marked_overdue"
| "reminder.sent"
| "reminder.skipped"
| "import.created"
| "import.matched"
| "event.created"
| "event.updated"
| "event.cloned"
| "qr.created"
| "qr.deleted"
| "whatsapp.connected"
| "whatsapp.disconnected"
| "settings.updated"
| "account.deleted"
| "user.login"
export interface ActivityEntry {
action: ActivityAction
entityType: "pledge" | "event" | "import" | "reminder" | "qr" | "settings" | "account" | "user"
entityId?: string
userId?: string
userName?: string
metadata?: Record<string, unknown>
timestamp: Date
}