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
@@ -0,0 +1,184 @@
import { prisma } from '@/lib/prisma';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import AdminNav from '@/components/AdminNav';
import Link from 'next/link';
import Price from '@/components/Price';
import { getReconciliationSummary } from '@/lib/bankReconciliation';
import ReconciliationClient from './client';
export default async function ReconciliationPage({
searchParams,
}: {
searchParams: { id?: string };
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const statementId = searchParams.id;
if (!statementId) {
// Show list of statements
const statements = await prisma.bankStatement.findMany({
orderBy: { uploadedAt: 'desc' },
include: {
lines: true,
reconciliations: true,
},
});
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3 mb-8">
<h1 className="font-display text-3xl">Bank Reconciliation</h1>
<Link href="/admin/accounts/reconciliation/upload" className="border border-ink px-4 py-1.5 text-sm hover:border-clay hover:bg-clay hover:text-paper">
Upload Statement
</Link>
</div>
<p className="text-sm text-muted mb-6">
Upload your bank statements to match against system transactions and verify account accuracy.
</p>
{statements.length === 0 ? (
<div className="border border-line p-8 text-center">
<p className="text-muted mb-4">No bank statements uploaded yet.</p>
<Link href="/admin/accounts/reconciliation/upload" className="text-clay hover:text-clay-dark">
Upload your first statement
</Link>
</div>
) : (
<div className="space-y-4">
{statements.map((stmt) => {
const matched = stmt.reconciliations.length;
const total = stmt.lines.length;
const reconciled = stmt.status === 'RECONCILED';
return (
<Link
key={stmt.id}
href={`/admin/accounts/reconciliation?id=${stmt.id}`}
className="block border border-line p-4 hover:bg-surface"
>
<div className="flex items-baseline justify-between mb-2">
<p className="font-medium">{stmt.filename}</p>
<span className={`text-xs px-2 py-1 rounded-full ${
reconciled ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{stmt.status}
</span>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<p className="tag-label">Period</p>
<p className="font-mono text-xs">
{stmt.startDate.toLocaleDateString('en-GB')} to {stmt.endDate.toLocaleDateString('en-GB')}
</p>
</div>
<div>
<p className="tag-label">Transactions</p>
<p className="font-mono">{total}</p>
</div>
<div>
<p className="tag-label">Matched</p>
<p className={`font-mono ${matched === total ? 'text-green-600' : 'text-orange-600'}`}>
{matched}/{total}
</p>
</div>
<div>
<p className="tag-label">Total</p>
<p className="font-mono">
<Price cents={stmt.totalAmount || 0} />
</p>
</div>
</div>
{stmt.reconciliedAt && (
<p className="text-xs text-muted mt-2">
Reconciled: {stmt.reconciliedAt.toLocaleDateString('en-GB')}
</p>
)}
</Link>
);
})}
</div>
)}
<p className="mt-8 text-xs text-muted">
💡 Tip: Each uploaded statement is automatically scanned to match transactions. Review unmatched items and correct any discrepancies before archiving.
</p>
</div>
);
}
// Show statement detail
const statement = await prisma.bankStatement.findUnique({
where: { id: statementId },
include: {
lines: { orderBy: { date: 'desc' } },
reconciliations: true,
},
});
if (!statement) {
return <div className="px-6 py-12 text-center">Statement not found</div>;
}
const summary = await getReconciliationSummary(statementId);
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3 mb-8">
<div>
<Link href="/admin/accounts/reconciliation" className="text-sm text-muted hover:text-ink mb-2">
Back to statements
</Link>
<h1 className="font-display text-3xl">{statement.filename}</h1>
</div>
<span className={`text-sm px-3 py-1 rounded-full font-medium ${
statement.status === 'RECONCILED' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{statement.status}
</span>
</div>
{/* Summary Stats */}
<div className="grid gap-3 sm:grid-cols-5 mb-8">
<div className="border border-line p-4">
<p className="tag-label">Period</p>
<p className="mt-2 text-sm font-mono">
{statement.startDate.toLocaleDateString('en-GB')} to {statement.endDate.toLocaleDateString('en-GB')}
</p>
</div>
<div className="border border-line p-4">
<p className="tag-label">Bank Total</p>
<p className="mt-2 font-mono text-lg">
<Price cents={summary?.totalBankAmount || 0} />
</p>
</div>
<div className="border border-line p-4">
<p className="tag-label">Matched</p>
<p className={`mt-2 font-mono text-lg ${summary?.matched === statement.lines.length ? 'text-green-600' : 'text-orange-600'}`}>
{summary?.matched || 0}/{statement.lines.length}
</p>
</div>
<div className="border border-line p-4">
<p className="tag-label">Unmatched</p>
<p className={`mt-2 font-mono text-lg ${summary?.unmatched === 0 ? 'text-green-600' : 'text-splash-pink'}`}>
{summary?.unmatched || 0}
</p>
</div>
<div className="border border-line p-4">
<p className="tag-label">Discrepancies</p>
<p className={`mt-2 font-mono text-lg ${summary?.discrepancies === 0 ? 'text-green-600' : 'text-splash-pink'}`}>
{summary?.discrepancies || 0}
</p>
</div>
</div>
<ReconciliationClient statement={statement} />
</div>
);
}