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)
1017 lines
36 KiB
TypeScript
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
|
|
}
|