Phase 3: Bank reconciliation system

Added complete bank statement reconciliation workflow:

Database Schema:
- BankStatement: metadata for uploaded statements
- BankStatementLine: individual transactions from statements
- BankReconciliation: links system transactions to bank lines

Features:
- CSV import with flexible format detection (DD/MM/YYYY, YYYY-MM-DD dates)
- Auto-matching algorithm: exact amount+date = 100% confidence, fuzzy matching up to 7 days
- Manual matching UI for unmatched transactions
- Discrepancy detection (amount mismatches)
- Reconciliation dashboard with summary stats
- Statement history view with status tracking

Pages:
- /admin/accounts/reconciliation: Dashboard and statement list
- /admin/accounts/reconciliation?id=X: Statement detail with matching UI
- /admin/accounts/reconciliation/upload: CSV upload form

Server Actions:
- uploadBankStatement: CSV parsing and auto-matching
- manualMatch: User-initiated transaction linking
- unmatchBankLine: Remove incorrect match
- archiveStatement: Mark statement as complete
- markReconciled: Finalize reconciliation

Utils:
- parseCSV: Flexible bank statement parser
- autoMatchTransactions: Fuzzy matching algorithm
- getReconciliationSummary: Stats calculation

UI Components:
- ReconciliationClient: Interactive matching interface with tabs
- Manual match form: User-friendly transaction linking

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-17 15:58:23 +01:00
co-authored by Claude Haiku 4.5
parent 2d55203c1c
commit 7f88379d3b
7 changed files with 1154 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
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;
}
}
return keywords === ['date', 'transaction date', 'date of transaction'] ? 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,
};
}