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:
co-authored by
Claude Haiku 4.5
parent
2d55203c1c
commit
7f88379d3b
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user