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 = {} 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( 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[]).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 }) } }