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,164 @@
'use server';
import { prisma } from '@/lib/prisma';
import { parseCSV, autoMatchTransactions, getReconciliationSummary } from '@/lib/bankReconciliation';
import { redirect } from 'next/navigation';
export async function uploadBankStatement(formData: FormData) {
const file = formData.get('file') as File;
const bankName = String(formData.get('bankName') ?? '').trim();
const accountNumber = String(formData.get('accountNumber') ?? '').trim();
if (!file) throw new Error('No file selected');
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
throw new Error('Only CSV files are supported');
}
const content = await file.text();
const transactions = parseCSV(content);
if (transactions.length === 0) {
throw new Error('No valid transactions found in CSV');
}
// Create the statement
const statement = await prisma.bankStatement.create({
data: {
filename: file.name,
bankName: bankName || null,
accountNumber: accountNumber ? accountNumber.slice(-4) : null,
statementMonth: new Date(),
startDate: transactions[0].date,
endDate: transactions[transactions.length - 1].date,
totalTransactions: transactions.length,
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
lines: {
create: transactions.map((t) => ({
date: t.date,
description: t.description,
amount: t.amount,
balance: t.balance,
reference: t.reference,
})),
},
},
});
// Auto-match transactions
await autoMatchTransactions(statement.id);
// Update statement status
const summary = await getReconciliationSummary(statement.id);
if (summary?.reconciled) {
await prisma.bankStatement.update({
where: { id: statement.id },
data: { status: 'RECONCILED', reconciliedAt: new Date() },
});
} else {
await prisma.bankStatement.update({
where: { id: statement.id },
data: { status: 'IN_PROGRESS' },
});
}
redirect(`/admin/accounts/reconciliation?id=${statement.id}`);
}
export async function manualMatch(
bankLineId: string,
transactionType: string,
transactionId: string,
notes?: string
) {
const bankLine = await prisma.bankStatementLine.findUnique({
where: { id: bankLineId },
});
if (!bankLine) throw new Error('Bank line not found');
// Get system transaction amount
let systemAmount = 0;
let description = '';
if (transactionType === 'ORDER') {
const order = await prisma.order.findUnique({
where: { id: transactionId },
select: { total: true, id: true },
});
if (!order) throw new Error('Order not found');
systemAmount = order.total;
description = `Order #${order.id.slice(0, 8)}`;
} else if (transactionType === 'MANUAL_SALE') {
const sale = await prisma.manualSale.findUnique({
where: { id: transactionId },
select: { total: true, id: true },
});
if (!sale) throw new Error('Manual sale not found');
systemAmount = sale.total;
description = `Manual sale #${sale.id.slice(0, 8)}`;
} else if (transactionType === 'EXPENSE') {
const expense = await prisma.expense.findUnique({
where: { id: transactionId },
select: { amount: true, category: true },
});
if (!expense) throw new Error('Expense not found');
systemAmount = expense.amount;
description = expense.category;
}
// Delete existing match if any
await prisma.bankReconciliation.deleteMany({
where: { bankLineId },
});
// Create new match
await prisma.bankReconciliation.create({
data: {
statementId: bankLine.statementId,
bankLineId,
transactionType,
transactionId,
description,
systemAmount,
bankAmount: bankLine.amount,
amountMatches: Math.abs(systemAmount - bankLine.amount) < 1,
matchedBy: 'ADMIN', // In real app, would capture IP
notes,
confidence: Math.abs(systemAmount - bankLine.amount) < 1 ? 100 : 50,
},
});
// Mark as matched
await prisma.bankStatementLine.update({
where: { id: bankLineId },
data: { matched: true },
});
}
export async function unmatchBankLine(bankLineId: string) {
await prisma.bankReconciliation.deleteMany({
where: { bankLineId },
});
await prisma.bankStatementLine.update({
where: { id: bankLineId },
data: { matched: false },
});
}
export async function archiveStatement(statementId: string) {
await prisma.bankStatement.update({
where: { id: statementId },
data: { status: 'ARCHIVED' },
});
}
export async function markReconciled(statementId: string) {
await prisma.bankStatement.update({
where: { id: statementId },
data: {
status: 'RECONCILED',
reconciliedAt: new Date(),
},
});
}
@@ -0,0 +1,339 @@
'use client';
import { useState, useMemo } from 'react';
import Price from '@/components/Price';
import { manualMatch, unmatchBankLine, markReconciled, archiveStatement } from './actions';
interface BankStatement {
id: string;
filename: string;
status: string;
lines: {
id: string;
date: Date;
description: string;
amount: number;
matched: boolean;
}[];
reconciliations: {
id: string;
bankLineId: string;
transactionType: string;
transactionId?: string;
description: string;
systemAmount: number;
bankAmount: number;
amountMatches: boolean;
confidence: number;
notes?: string;
}[];
}
export default function ReconciliationClient({ statement }: { statement: BankStatement }) {
const [activeTab, setActiveTab] = useState<'matched' | 'unmatched' | 'discrepancies'>('unmatched');
const [matchingBankLineId, setMatchingBankLineId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<{ field: string; order: 'asc' | 'desc' }>({ field: 'date', order: 'desc' });
// Categorize lines
const unmatchedLines = useMemo(() => {
return statement.lines.filter((line) => !line.matched).sort((a, b) => {
if (sortBy.order === 'desc') return b.date.getTime() - a.date.getTime();
return a.date.getTime() - b.date.getTime();
});
}, [statement.lines, sortBy]);
const matchedLines = useMemo(() => {
return statement.lines
.filter((line) => line.matched)
.map((line) => {
const recon = statement.reconciliations.find((r) => r.bankLineId === line.id);
return { line, recon };
})
.sort((a, b) => {
if (sortBy.order === 'desc') return b.line.date.getTime() - a.line.date.getTime();
return a.line.date.getTime() - b.line.date.getTime();
});
}, [statement.lines, statement.reconciliations, sortBy]);
const discrepancies = useMemo(() => {
return matchedLines.filter((m) => m.recon && !m.recon.amountMatches);
}, [matchedLines]);
const isReconciled = unmatchedLines.length === 0 && discrepancies.length === 0;
const handleUnmatch = async (bankLineId: string) => {
try {
await unmatchBankLine(bankLineId);
window.location.reload();
} catch (err) {
alert(`Failed to unmatch: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleMarkReconciled = async () => {
if (!isReconciled) {
alert('Please resolve all unmatched items and discrepancies first');
return;
}
try {
await markReconciled(statement.id);
window.location.reload();
} catch (err) {
alert(`Failed to mark reconciled: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleArchive = async () => {
if (!confirm('Archive this statement? You can still view it later.')) return;
try {
await archiveStatement(statement.id);
window.location.href = '/admin/accounts/reconciliation';
} catch (err) {
alert(`Failed to archive: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
return (
<div className="space-y-6">
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
{isReconciled && statement.status !== 'RECONCILED' && (
<button
onClick={handleMarkReconciled}
className="border border-green-600 bg-green-600 text-white px-4 py-2 text-sm hover:bg-green-700"
>
Mark Reconciled
</button>
)}
{statement.status === 'RECONCILED' && (
<button
onClick={handleArchive}
className="border border-line px-4 py-2 text-sm hover:bg-surface"
>
📦 Archive Statement
</button>
)}
</div>
{/* Tabs */}
<div className="inline-flex border border-line">
{[
{ id: 'unmatched', label: `Unmatched (${unmatchedLines.length})`, color: unmatchedLines.length > 0 ? 'text-splash-pink' : '' },
{ id: 'matched', label: `Matched (${matchedLines.length})`, color: matchedLines.length > 0 ? 'text-green-600' : '' },
{ id: 'discrepancies', label: `Discrepancies (${discrepancies.length})`, color: discrepancies.length > 0 ? 'text-orange-600' : '' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-4 py-1.5 text-sm ${activeTab === tab.id ? 'bg-ink text-paper' : `hover:text-clay ${tab.color}`}`}
>
{tab.label}
</button>
))}
</div>
{/* Unmatched Items */}
{activeTab === 'unmatched' && (
<div>
{unmatchedLines.length === 0 ? (
<p className="text-sm text-muted py-8 text-center">All transactions matched! </p>
) : (
<div className="space-y-3">
<p className="text-sm text-muted">Manually match these transactions to system records:</p>
{unmatchedLines.map((line) => (
<div key={line.id} className="border border-line p-4 space-y-3">
<div className="flex items-baseline justify-between">
<div>
<p className="font-medium">{line.description}</p>
<p className="text-xs text-muted font-mono">
{new Date(line.date).toLocaleDateString('en-GB')} <Price cents={line.amount} />
</p>
</div>
<button
onClick={() => setMatchingBankLineId(matchingBankLineId === line.id ? null : line.id)}
className="text-sm text-clay hover:text-clay-dark"
>
{matchingBankLineId === line.id ? '✕ Close' : '🔗 Match'}
</button>
</div>
{matchingBankLineId === line.id && (
<ManualMatchForm
bankLineId={line.id}
bankAmount={line.amount}
onSuccess={() => {
setMatchingBankLineId(null);
window.location.reload();
}}
/>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Matched Items */}
{activeTab === 'matched' && (
<div className="overflow-x-auto">
{matchedLines.length === 0 ? (
<p className="text-sm text-muted py-8 text-center">No matched transactions yet</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line bg-surface">
<th className="text-left py-2 px-3 font-medium">Bank Date</th>
<th className="text-left py-2 px-3 font-medium">Description</th>
<th className="text-right py-2 px-3 font-medium">Bank Amount</th>
<th className="text-right py-2 px-3 font-medium">System Amount</th>
<th className="text-left py-2 px-3 font-medium">Type</th>
<th className="text-center py-2 px-3 font-medium">Action</th>
</tr>
</thead>
<tbody>
{matchedLines.map(({ line, recon }) => (
<tr key={line.id} className="border-b border-line hover:bg-surface">
<td className="py-2 px-3 font-mono text-xs">{new Date(line.date).toLocaleDateString('en-GB')}</td>
<td className="py-2 px-3 text-xs">{line.description}</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={line.amount} />
</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={recon?.systemAmount || 0} />
</td>
<td className="py-2 px-3 text-xs">{recon?.transactionType || '—'}</td>
<td className="text-center py-2 px-3">
<button
onClick={() => handleUnmatch(line.id)}
className="text-xs text-muted hover:text-splash-pink"
>
Unmatch
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Discrepancies */}
{activeTab === 'discrepancies' && (
<div className="space-y-3">
{discrepancies.length === 0 ? (
<p className="text-sm text-muted py-8 text-center">No discrepancies! </p>
) : (
<>
<p className="text-sm text-orange-600">
These items matched but the amounts don't match exactly. Review and correct if needed.
</p>
{discrepancies.map(({ line, recon }) => (
<div key={line.id} className="border-2 border-orange-200 bg-orange-50 p-4 space-y-2">
<div className="flex items-baseline justify-between">
<div>
<p className="font-medium">{line.description}</p>
<p className="text-xs text-muted font-mono">{new Date(line.date).toLocaleDateString('en-GB')}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted">Bank vs System</p>
<p className="font-mono">
<Price cents={line.amount} /> vs <Price cents={recon?.systemAmount || 0} />
</p>
<p className="text-xs text-orange-600 font-medium">
Diff: <Price cents={Math.abs((line.amount || 0) - (recon?.systemAmount || 0))} />
</p>
</div>
</div>
<button
onClick={() => handleUnmatch(line.id)}
className="text-xs text-orange-600 hover:text-orange-700 hover:underline"
>
Unmatch and re-assign
</button>
</div>
))}
</>
)}
</div>
)}
</div>
);
}
function ManualMatchForm({
bankLineId,
bankAmount,
onSuccess,
}: {
bankLineId: string;
bankAmount: number;
onSuccess: () => void;
}) {
const [transactionType, setTransactionType] = useState<string>('ORDER');
const [transactionId, setTransactionId] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!transactionId) {
alert('Please enter a transaction ID');
return;
}
setLoading(true);
try {
await manualMatch(bankLineId, transactionType, transactionId, notes);
onSuccess();
} catch (err) {
alert(`Failed to match: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-3 bg-surface p-4 rounded">
<div className="grid gap-3 sm:grid-cols-2">
<select
value={transactionType}
onChange={(e) => setTransactionType(e.target.value)}
className="border border-line bg-paper px-3 py-2 text-sm"
>
<option value="ORDER">Order</option>
<option value="MANUAL_SALE">Manual Sale</option>
<option value="EXPENSE">Expense</option>
<option value="OTHER">Other (specify in notes)</option>
</select>
<input
type="text"
value={transactionId}
onChange={(e) => setTransactionId(e.target.value)}
placeholder="Transaction ID or reference"
className="border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes about this match..."
className="w-full border border-line bg-paper px-3 py-2 text-sm"
rows={2}
/>
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="border border-green-600 bg-green-600 text-white px-4 py-2 text-sm hover:bg-green-700 disabled:opacity-50"
>
{loading ? 'Matching...' : ' Confirm Match'}
</button>
</div>
</form>
);
}
@@ -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>
);
}
@@ -0,0 +1,104 @@
import AdminNav from '@/components/AdminNav';
import Link from 'next/link';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import { uploadBankStatement } from '../actions';
export default async function UploadStatementPage() {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<Link href="/admin/accounts/reconciliation" className="text-sm text-muted hover:text-ink mb-4 inline-block">
Back to reconciliation
</Link>
<h1 className="font-display text-3xl mb-2">Upload Bank Statement</h1>
<p className="text-sm text-muted mb-8">
Upload a CSV export of your bank statement. The system will automatically match transactions and flag discrepancies.
</p>
<form action={uploadBankStatement} className="space-y-6 border border-line bg-surface p-6">
<div>
<label className="block text-sm font-medium mb-2">CSV File</label>
<input
type="file"
name="file"
accept=".csv"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="text-xs text-muted mt-1">
💡 Most banks let you export statements as CSV. Required columns: Date, Description, Amount
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-2">Bank Name (optional)</label>
<input
type="text"
name="bankName"
placeholder="e.g., HSBC, Barclays, Stripe"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Account Number (last 4 digits)</label>
<input
type="text"
name="accountNumber"
placeholder="e.g., 1234"
maxLength={4}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div className="bg-splash-blue/5 border border-splash-blue/40 p-4 text-sm text-splash-blue space-y-2">
<p className="font-medium">📋 CSV Format Tips:</p>
<ul className="list-disc pl-5 space-y-1">
<li>First row should contain column headers (Date, Description, Amount, etc.)</li>
<li>Dates can be: DD/MM/YYYY, YYYY-MM-DD, or MM/DD/YYYY</li>
<li>Amounts can include commas (1,234.56) or be negative</li>
<li>Negative amounts in parentheses: (1,234.56) = -1,234.56</li>
<li>Extra columns (Balance, Reference) are optional</li>
</ul>
</div>
<div className="bg-green-50 border border-green-200 p-4 text-sm text-green-700 space-y-2">
<p className="font-medium"> Automatic Matching:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Orders and manual sales are matched by amount + date</li>
<li>Confidence scoring: exact amount = 100%, fuzzy = 50-99%</li>
<li>You can manually adjust any incorrect matches</li>
<li>The system helps you verify nothing is missing</li>
</ul>
</div>
<button
type="submit"
className="w-full border border-ink bg-ink text-paper px-4 py-2 text-sm font-medium hover:border-clay hover:bg-clay"
>
Upload Statement
</button>
</form>
<div className="mt-8 space-y-4">
<p className="text-sm font-medium">How reconciliation works:</p>
<ol className="list-decimal pl-5 space-y-2 text-sm text-muted">
<li>Upload a CSV export from your bank</li>
<li>System auto-matches transactions based on amount + date</li>
<li>Review unmatched items and manually match them</li>
<li>Check for discrepancies (amount mismatches)</li>
<li>Mark as reconciled when everything matches</li>
<li>Archive statement for record-keeping</li>
</ol>
</div>
</div>
);
}