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
+55
View File
@@ -417,3 +417,58 @@ model FinancialAuditLog {
changedBy String? // "SYSTEM" or IP address if admin
reason String? // why was this changed
}
// Bank statement uploaded for reconciliation
model BankStatement {
id String @id @default(cuid())
filename String // original filename
bankName String? // e.g., "HSBC", "Barclays"
accountNumber String? // last 4 digits for safety
statementMonth DateTime // the month this statement covers
startDate DateTime // first transaction date in statement
endDate DateTime // last transaction date in statement
totalTransactions Int // count of lines in statement
totalAmount Int? // cents, total of all transactions (for verification)
status String @default("PENDING") // PENDING, IN_PROGRESS, RECONCILED, ARCHIVED
uploadedAt DateTime @default(now())
reconciliedAt DateTime? // when reconciliation was completed
lines BankStatementLine[]
reconciliations BankReconciliation[]
}
// Individual transaction line from a bank statement
model BankStatementLine {
id String @id @default(cuid())
statementId String
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
date DateTime // transaction date
description String // e.g., "STRIPE DEPOSIT", "TRANSFER TO SAVINGS"
amount Int // cents, positive for credit, negative for debit
balance Int? // account balance after this transaction (if provided)
reference String? // transaction reference from bank
matched Boolean @default(false) // whether this line has been reconciled
reconciliation BankReconciliation? // the matched transaction, if any
createdAt DateTime @default(now())
}
// Link between system transaction (order/sale/expense) and bank statement line
model BankReconciliation {
id String @id @default(cuid())
statementId String
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
bankLineId String @unique
bankLine BankStatementLine @relation(fields: [bankLineId], references: [id], onDelete: Cascade)
// Which system transaction this was matched to
transactionType String // "ORDER", "MANUAL_SALE", "REFUND", "EXPENSE", "TRANSFER_OUT", "FEE", "OTHER"
transactionId String? // ID of the order/sale/expense if applicable
description String // what the match was (e.g., "Order #12345", "Stripe fee")
systemAmount Int // cents, amount in our system
bankAmount Int // cents, amount on bank statement
amountMatches Boolean // whether system and bank amounts are equal
matchedAt DateTime @default(now())
matchedBy String? // IP/admin who manually matched this (null if auto-matched)
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match)
notes String? // manual notes about the match
}
@@ -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>
);
}
+1
View File
@@ -7,6 +7,7 @@ import { logoutAdmin } from '@/app/admin/login/actions';
const ADMIN_LINKS = [
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/accounts', label: 'Accounts' },
{ href: '/admin/accounts/reconciliation', label: 'Bank reconciliation' },
{ href: '/admin/products/new', label: 'Add product' },
{ href: '/admin/products', label: 'All products' },
{ href: '/admin/categories/new', label: 'Add category' },
+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,
};
}