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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user