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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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