- Fix const reassignment in Phase 2 reports export (use let for mutable vars) - Fix TypeScript interface to match Prisma reconciliation schema - Fix array comparison logic in bank CSV parsing - Temporarily disable pdf-parse due to module compatibility issue (pre-existing) All phases now build successfully with zero errors.
309 lines
9.2 KiB
TypeScript
309 lines
9.2 KiB
TypeScript
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,
|
|
};
|
|
}
|