Ship all P0/P1/P2 gaps + 11 AI features
P0 Critical (7): - STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance) - Rate limiting on pledge creation (10/IP/5min) - Terms of Service + Privacy Policy pages - WhatsApp onboarding gate (persistent dashboard banner) - Demo account seeding (demo@pnpl.app) - Footer legal links - Basic accessibility (aria labels on donor flow) P1 Within 2 Weeks (8): - Pledge editing by staff (PATCH amount, name, email, phone, rail) - Donor self-cancel page (/p/cancel) + API - Donor 'My Pledges' lookup page (/p/my-pledges) - Bulk QR code download (print-ready HTML) - Public event progress bar (/e/[slug]/progress) - Email-only donor handling (honest status + WhatsApp fallback) - Email verification (format + disposable domain blocking) - Organisations page rewrite (multi-campaign, not multi-org) P2 Within First Month (10): - Event cloning with QR sources - Account deletion (GDPR Article 17) - Daily digest cron via WhatsApp - AI-6 Smart reminder timing (due date anchoring, cultural sensitivity) - H1 Duplicate donor detection (email, phone, Jaro-Winkler name) - H5 Bank CSV format presets (10 UK banks) - H16 Partial payment matching (underpay, overpay, instalment) - H10 Activity logging (audit trail for staff actions) - AI nudge endpoint + AI column mapping + AI event setup wizard - AI anomaly detection wired into daily digest AI Features (11): smart reconciliation, social proof, auto column mapper, daily digest, impact storyteller, smart timing, nudge composer, event wizard, NLU concierge, anomaly detection, bank presets 22 new files, 15 modified files, 0 TypeScript errors, clean build.
This commit is contained in:
@@ -184,3 +184,789 @@ export async function generateEventDescription(prompt: string): Promise<string>
|
||||
{ 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 (!OPENAI_KEY) 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 (!OPENAI_KEY) 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 (!OPENAI_KEY) 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 (!OPENAI_KEY) {
|
||||
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 (!OPENAI_KEY) {
|
||||
// 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 (!OPENAI_KEY) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user