feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
133
pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts
Normal file
133
pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user