import { prisma } from '@/lib/prisma'; import { getClientIp } from '@/lib/rateLimit'; import { headers } from 'next/headers'; export interface BankTransactionLine { date: Date; description: string; amount: number; // cents balance?: number; // cents reference?: string; } /** * Parse CSV bank statement into structured transactions * Expected format: Date,Description,Amount,Balance,Reference * or similar variations */ export function parseCSV(csvContent: string): BankTransactionLine[] { const lines = csvContent.trim().split('\n'); if (lines.length < 2) return []; const transactions: BankTransactionLine[] = []; const headerLine = lines[0].toLowerCase(); // Detect column positions (case-insensitive) const dateIdx = detectColumn(headerLine, ['date', 'transaction date', 'date of transaction']); const descIdx = detectColumn(headerLine, ['description', 'particulars', 'memo']); const amountIdx = detectColumn(headerLine, ['amount', 'debit', 'credit', 'transaction']); const balanceIdx = detectColumn(headerLine, ['balance', 'closing balance', 'running balance']); const refIdx = detectColumn(headerLine, ['reference', 'ref', 'transaction ref']); // Parse data rows for (let i = 1; i < lines.length; i++) { const row = parseCSVRow(lines[i]); if (row.length === 0) continue; try { const dateStr = row[dateIdx]?.trim(); const description = row[descIdx]?.trim() || ''; const amountStr = row[amountIdx]?.trim() || '0'; const balanceStr = row[balanceIdx]?.trim(); const reference = row[refIdx]?.trim(); if (!dateStr || !description) continue; const date = parseDate(dateStr); if (!date) continue; // Parse amount - could be "1,234.56" or "-1234.56" or "(1234.56)" for negative let amount = 0; const cleanAmount = amountStr.replace(/[()]/g, '').replace(/,/g, ''); const parsedAmount = parseFloat(cleanAmount) * 100; if (!isNaN(parsedAmount)) { amount = Math.round(parsedAmount); if (amountStr.includes('(')) amount = -amount; // parentheses = negative } const balance = balanceStr ? Math.round(parseFloat(balanceStr.replace(/,/g, '')) * 100) : undefined; transactions.push({ date, description, amount, balance, reference, }); } catch { // Skip malformed rows } } return transactions; } function detectColumn(headerLine: string, keywords: string[]): number { const cols = headerLine.split(','); for (const keyword of keywords) { for (let i = 0; i < cols.length; i++) { if (cols[i].includes(keyword)) return i; } } // Default to column 0 for date, column 1 for others return keywords[0] === 'date' ? 0 : 1; } function parseCSVRow(line: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { result.push(current); current = ''; } else { current += char; } } result.push(current); return result; } function parseDate(dateStr: string): Date | null { // Try multiple formats: DD/MM/YYYY, YYYY-MM-DD, MM/DD/YYYY const formats = [ /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, // DD/MM/YYYY or MM/DD/YYYY /^(\d{4})-(\d{1,2})-(\d{1,2})$/, // YYYY-MM-DD /^(\d{1,2})-(\d{1,2})-(\d{4})$/, // DD-MM-YYYY ]; for (const format of formats) { const match = dateStr.match(format); if (match) { let year, month, day; if (format === formats[1]) { [, year, month, day] = match; } else if (format === formats[2]) { [, day, month, year] = match; } else { // For DD/MM/YYYY format, we need to guess based on value const [, first, second] = match; const firstNum = parseInt(first); const secondNum = parseInt(second); if (firstNum > 12) { [, day, month] = match; // first must be day } else if (secondNum > 12) { [, month, day] = match; // second must be day } else { // Ambiguous - assume DD/MM/YYYY (common in UK) [, day, month] = match; } [, , , year] = match; } const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); if (!isNaN(date.getTime())) return date; } } return null; } /** * Auto-match bank transactions to system transactions * Uses fuzzy matching on amount + date proximity */ export async function autoMatchTransactions(statementId: string) { const statement = await prisma.bankStatement.findUnique({ where: { id: statementId }, include: { lines: { where: { matched: false } } }, }); if (!statement) throw new Error('Statement not found'); // Get all unmatched system transactions around the statement date const startDate = new Date(statement.startDate); startDate.setDate(startDate.getDate() - 7); // Look back 7 days const endDate = new Date(statement.endDate); endDate.setDate(endDate.getDate() + 7); // Look ahead 7 days const orders = await prisma.order.findMany({ where: { createdAt: { gte: startDate, lte: endDate }, total: { gt: 0 } }, select: { id: true, total: true, createdAt: true }, }); const manualSales = await prisma.manualSale.findMany({ where: { soldAt: { gte: startDate, lte: endDate }, total: { gt: 0 } }, select: { id: true, total: true, soldAt: true }, }); // Try to match each bank line const matches: any[] = []; for (const line of statement.lines) { if (line.matched) continue; const bestMatch = findBestMatch(line, orders, manualSales); if (bestMatch) { matches.push({ bankLineId: line.id, statementId, transactionType: bestMatch.type, transactionId: bestMatch.id, description: bestMatch.description, systemAmount: bestMatch.amount, bankAmount: line.amount, amountMatches: Math.abs(bestMatch.amount - line.amount) < 1, matchedBy: null, confidence: bestMatch.confidence, }); } } // Create matches and update bank lines for (const match of matches) { await prisma.bankReconciliation.create({ data: match }); await prisma.bankStatementLine.update({ where: { id: match.bankLineId }, data: { matched: true }, }); } return matches.length; } interface MatchCandidate { id: string; type: 'ORDER' | 'MANUAL_SALE'; amount: number; date: Date; description: string; } function findBestMatch( bankLine: { date: Date; amount: number; description: string }, orders: any[], manualSales: any[] ): { id: string; type: string; amount: number; description: string; confidence: number } | null { const candidates: MatchCandidate[] = [ ...orders.map((o) => ({ id: o.id, type: 'ORDER' as const, amount: o.total, date: o.createdAt, description: `Order #${o.id.slice(0, 8)}`, })), ...manualSales.map((m) => ({ id: m.id, type: 'MANUAL_SALE' as const, amount: m.total, date: m.soldAt, description: `Manual sale #${m.id.slice(0, 8)}`, })), ]; let bestMatch = null; let bestScore = 0; for (const candidate of candidates) { const amountDiff = Math.abs(candidate.amount - bankLine.amount); const dateDiff = Math.abs(candidate.date.getTime() - bankLine.date.getTime()) / (1000 * 60 * 60 * 24); // Scoring: exact amount match = high confidence, within 3 days = good if (amountDiff === 0 && dateDiff <= 3) { // Perfect match return { id: candidate.id, type: candidate.type, amount: candidate.amount, description: candidate.description, confidence: 100, }; } // Fuzzy scoring if (amountDiff <= 100 && dateDiff <= 7) { // Within £1 and 7 days const score = (100 - amountDiff / 10) * (1 - dateDiff / 7); if (score > bestScore) { bestScore = score; bestMatch = { id: candidate.id, type: candidate.type, amount: candidate.amount, description: candidate.description, confidence: Math.round(Math.max(50, Math.min(99, score))), }; } } } return bestMatch; } /** * Get reconciliation summary for a statement */ export async function getReconciliationSummary(statementId: string) { const statement = await prisma.bankStatement.findUnique({ where: { id: statementId }, include: { lines: true, reconciliations: true, }, }); if (!statement) return null; const matched = statement.reconciliations.filter((r) => r.amountMatches); const unmatched = statement.lines.filter((l) => !l.matched); const discrepancies = statement.reconciliations.filter((r) => !r.amountMatches); const totalBankAmount = statement.lines.reduce((sum, l) => sum + l.amount, 0); const totalMatchedAmount = matched.reduce((sum, m) => sum + m.systemAmount, 0); return { total: statement.lines.length, matched: matched.length, unmatched: unmatched.length, discrepancies: discrepancies.length, totalBankAmount, totalMatchedAmount, unmatchedAmount: totalBankAmount - totalMatchedAmount, reconciled: unmatched.length === 0 && discrepancies.length === 0, }; }