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.
189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import prisma from "@/lib/prisma"
|
|
import Papa from "papaparse"
|
|
import { matchBankRow } from "@/lib/matching"
|
|
import { resolveOrgId } from "@/lib/org"
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
if (!prisma) {
|
|
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
|
|
}
|
|
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
|
|
if (!orgId) {
|
|
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
|
}
|
|
const formData = await request.formData()
|
|
const file = formData.get("file") as File
|
|
const mappingJson = formData.get("mapping") as string
|
|
|
|
if (!file) {
|
|
return NextResponse.json({ error: "No file uploaded" }, { status: 400 })
|
|
}
|
|
|
|
let mapping: Record<string, string> = {}
|
|
try {
|
|
mapping = mappingJson ? JSON.parse(mappingJson) : {}
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid column mapping JSON" }, { status: 400 })
|
|
}
|
|
const csvText = await file.text()
|
|
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
|
|
|
|
if (parsed.errors.length > 0 && parsed.data.length === 0) {
|
|
return NextResponse.json({ error: "CSV parse error", details: parsed.errors }, { status: 400 })
|
|
}
|
|
|
|
// Get all unmatched pledges for this org
|
|
const openPledges = await prisma.pledge.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
status: { in: ["new", "initiated", "overdue"] },
|
|
},
|
|
select: { id: true, reference: true, amountPence: true },
|
|
})
|
|
|
|
const pledgeMap = new Map<string, { id: string; amountPence: number }>(
|
|
openPledges.map((p: { id: string; reference: string; amountPence: number }) => [p.reference, { id: p.id, amountPence: p.amountPence }])
|
|
)
|
|
|
|
// Convert rows and match
|
|
const rows = (parsed.data as Record<string, string>[]).map((raw) => ({
|
|
date: raw[mapping.dateCol || "Date"] || "",
|
|
description: raw[mapping.descriptionCol || "Description"] || "",
|
|
amount: parseFloat(raw[mapping.creditCol || mapping.amountCol || "Amount"] || "0"),
|
|
reference: raw[mapping.referenceCol || "Reference"] || "",
|
|
raw,
|
|
}))
|
|
|
|
const results = rows
|
|
.filter((r) => r.amount > 0) // only credits
|
|
.map((r) => matchBankRow(r, pledgeMap))
|
|
|
|
// Create import record
|
|
const importRecord = await prisma.import.create({
|
|
data: {
|
|
organizationId: orgId,
|
|
kind: "bank_statement",
|
|
fileName: file.name,
|
|
rowCount: rows.length,
|
|
matchedCount: results.filter((r) => r.confidence === "exact").length,
|
|
unmatchedCount: results.filter((r) => r.confidence === "none").length,
|
|
mappingConfig: mapping,
|
|
status: "completed",
|
|
stats: {
|
|
totalRows: rows.length,
|
|
credits: rows.filter((r) => r.amount > 0).length,
|
|
exactMatches: results.filter((r) => r.confidence === "exact").length,
|
|
partialMatches: results.filter((r) => r.confidence === "partial").length,
|
|
unmatched: results.filter((r) => r.confidence === "none").length,
|
|
},
|
|
},
|
|
})
|
|
|
|
// AI-1: Smart match unmatched rows using AI fuzzy matching
|
|
const unmatchedRows = results.filter(r => r.confidence === "none" && r.bankRow.amount > 0)
|
|
if (unmatchedRows.length > 0 && unmatchedRows.length <= 30) {
|
|
try {
|
|
const { smartMatch } = await import("@/lib/ai")
|
|
const candidates = openPledges.map((p: { id: string; reference: string; amountPence: number }) => ({
|
|
ref: p.reference,
|
|
amount: p.amountPence,
|
|
donor: "", // We don't have donor name in the query above, but AI can match by amount + description
|
|
}))
|
|
|
|
for (const row of unmatchedRows) {
|
|
const aiResult = await smartMatch(
|
|
`${row.bankRow.description} ${row.bankRow.reference}`.trim(),
|
|
candidates
|
|
)
|
|
if (aiResult.matchedRef && aiResult.confidence >= 0.85) {
|
|
const pledgeInfo = pledgeMap.get(aiResult.matchedRef)
|
|
if (pledgeInfo) {
|
|
// Mark as AI match (partial confidence, needs review)
|
|
const idx = results.indexOf(row)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const updated: any = {
|
|
...row,
|
|
pledgeId: pledgeInfo.id,
|
|
pledgeReference: aiResult.matchedRef,
|
|
confidence: "partial",
|
|
matchedAmount: row.bankRow.amount,
|
|
}
|
|
results[idx] = updated
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[AI] Smart match failed:", err)
|
|
}
|
|
}
|
|
|
|
// Update import stats with AI matches
|
|
await prisma.import.update({
|
|
where: { id: importRecord.id },
|
|
data: {
|
|
matchedCount: results.filter(r => r.confidence === "exact").length,
|
|
unmatchedCount: results.filter(r => r.confidence === "none").length,
|
|
stats: {
|
|
totalRows: rows.length,
|
|
credits: rows.filter(r => r.amount > 0).length,
|
|
exactMatches: results.filter(r => r.confidence === "exact").length,
|
|
partialMatches: results.filter(r => r.confidence === "partial").length,
|
|
aiMatches: results.filter(r => r.confidence === "partial").length,
|
|
unmatched: results.filter(r => r.confidence === "none").length,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Auto-confirm exact matches
|
|
const confirmed: string[] = []
|
|
for (const result of results) {
|
|
if (result.confidence === "exact" && result.pledgeId) {
|
|
await prisma.$transaction([
|
|
prisma.pledge.update({
|
|
where: { id: result.pledgeId },
|
|
data: { status: "paid", paidAt: new Date() },
|
|
}),
|
|
prisma.payment.create({
|
|
data: {
|
|
pledgeId: result.pledgeId,
|
|
provider: "bank",
|
|
amountPence: Math.round(result.matchedAmount * 100),
|
|
status: "confirmed",
|
|
matchedBy: "auto",
|
|
receivedAt: new Date(result.bankRow.date) || new Date(),
|
|
importId: importRecord.id,
|
|
},
|
|
}),
|
|
// Skip remaining reminders
|
|
prisma.reminder.updateMany({
|
|
where: { pledgeId: result.pledgeId, status: "pending" },
|
|
data: { status: "skipped" },
|
|
}),
|
|
])
|
|
confirmed.push(result.pledgeId)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
importId: importRecord.id,
|
|
summary: {
|
|
totalRows: rows.length,
|
|
credits: rows.filter((r) => r.amount > 0).length,
|
|
exactMatches: results.filter((r) => r.confidence === "exact").length,
|
|
partialMatches: results.filter((r) => r.confidence === "partial").length,
|
|
unmatched: results.filter((r) => r.confidence === "none").length,
|
|
autoConfirmed: confirmed.length,
|
|
},
|
|
matches: results.map((r) => ({
|
|
...r,
|
|
autoConfirmed: r.pledgeId ? confirmed.includes(r.pledgeId) : false,
|
|
})),
|
|
})
|
|
} catch (error) {
|
|
console.error("Bank import error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|