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:
2026-03-04 20:10:34 +08:00
parent 59485579ec
commit fcfae1c1a4
36 changed files with 3405 additions and 46 deletions

View File

@@ -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
}