Files
craft2prints/src/lib/bankReconciliation.ts
T
Andymick 560fbb3978 Fix build errors and prepare for deployment
- 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.
2026-07-17 16:58:51 +01:00

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,
};
}