diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1516aa4..a98ddef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app/admin/accounts/reconciliation/actions.ts b/src/app/admin/accounts/reconciliation/actions.ts new file mode 100644 index 0000000..2e7417a --- /dev/null +++ b/src/app/admin/accounts/reconciliation/actions.ts @@ -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(), + }, + }); +} diff --git a/src/app/admin/accounts/reconciliation/client.tsx b/src/app/admin/accounts/reconciliation/client.tsx new file mode 100644 index 0000000..d793deb --- /dev/null +++ b/src/app/admin/accounts/reconciliation/client.tsx @@ -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(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 ( +
+ {/* Action Buttons */} +
+ {isReconciled && statement.status !== 'RECONCILED' && ( + + )} + {statement.status === 'RECONCILED' && ( + + )} +
+ + {/* Tabs */} +
+ {[ + { 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) => ( + + ))} +
+ + {/* Unmatched Items */} + {activeTab === 'unmatched' && ( +
+ {unmatchedLines.length === 0 ? ( +

All transactions matched! ✓

+ ) : ( +
+

Manually match these transactions to system records:

+ {unmatchedLines.map((line) => ( +
+
+
+

{line.description}

+

+ {new Date(line.date).toLocaleDateString('en-GB')} • +

+
+ +
+ + {matchingBankLineId === line.id && ( + { + setMatchingBankLineId(null); + window.location.reload(); + }} + /> + )} +
+ ))} +
+ )} +
+ )} + + {/* Matched Items */} + {activeTab === 'matched' && ( +
+ {matchedLines.length === 0 ? ( +

No matched transactions yet

+ ) : ( + + + + + + + + + + + + + {matchedLines.map(({ line, recon }) => ( + + + + + + + + + ))} + +
Bank DateDescriptionBank AmountSystem AmountTypeAction
{new Date(line.date).toLocaleDateString('en-GB')}{line.description} + + + + {recon?.transactionType || '—'} + +
+ )} +
+ )} + + {/* Discrepancies */} + {activeTab === 'discrepancies' && ( +
+ {discrepancies.length === 0 ? ( +

No discrepancies! ✓

+ ) : ( + <> +

+ ⚠ These items matched but the amounts don't match exactly. Review and correct if needed. +

+ {discrepancies.map(({ line, recon }) => ( +
+
+
+

{line.description}

+

{new Date(line.date).toLocaleDateString('en-GB')}

+
+
+

Bank vs System

+

+ vs +

+

+ Diff: +

+
+
+ +
+ ))} + + )} +
+ )} +
+ ); +} + +function ManualMatchForm({ + bankLineId, + bankAmount, + onSuccess, +}: { + bankLineId: string; + bankAmount: number; + onSuccess: () => void; +}) { + const [transactionType, setTransactionType] = useState('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 ( +
+
+ + + setTransactionId(e.target.value)} + placeholder="Transaction ID or reference" + className="border border-line bg-paper px-3 py-2 text-sm" + /> +
+ +