/** * 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 { 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 { 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 { 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 { const name = vars.donorName?.split(" ")[0] || "there" // Templates with AI enhancement const templates: Record = { 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 { 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 { 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 { 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 { 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> { const anomalies: Array<{ type: string; severity: "low" | "medium" | "high"; description: string }> = [] // Rule-based checks (no AI needed) // Duplicate email check const emailCounts = new Map() 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 { // Default schedule: step 1 = T+2d, step 2 = T+7d, step 3 = T+14d, step 4 = T+21d const defaultDelayDays: Record = { 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() // Pass 1: Exact email match const byEmail = new Map() 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() 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 = { 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 timestamp: Date }