Phase 1: Financial audit logging foundation

- Add FinancialAuditLog model to track all financial changes
- Log action (CREATE, UPDATE, DELETE) with before/after values
- Capture who made changes (IP address)
- Record timestamp and reason for change
- Create /admin/financial-audit page to view audit trail
- Add filtering by entity type, action, date range
- Show summary stats (total changes, deletions, etc)
- Integrate with purchases: log create and delete operations
- Red-flag deletions for security awareness
- Add "Financial audit trail" link to admin menu

This provides foundation for Phase 2 (reporting UI) and Phase 3 (bank reconciliation).
All financial changes are now traceable and auditable for compliance.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-17 15:23:05 +01:00
co-authored by Claude Haiku 4.5
parent e69759081c
commit d56f942a88
5 changed files with 386 additions and 1 deletions
+31 -1
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit';
export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…)
@@ -57,7 +58,7 @@ export async function createPurchase(formData: FormData) {
const purchaseDate = purchasedAtInput ? new Date(purchasedAtInput) : new Date();
await prisma.purchase.create({
const purchase = await prisma.purchase.create({
data: {
supplierName,
purchasedAt: purchaseDate,
@@ -88,6 +89,20 @@ export async function createPurchase(formData: FormData) {
},
});
// Log the purchase creation
await logFinancialChange(
'CREATE',
'PURCHASE',
purchase.id,
{
supplier: { old: null, new: supplierName },
total: { old: 0, new: total },
items: { old: 0, new: items.length },
},
total,
'New purchase recorded'
);
// Product lines add to stock; material lines are just logged.
await applyStockMovement(items, 1);
@@ -99,6 +114,21 @@ export async function deletePurchase(formData: FormData) {
if (!id) return;
const purchase = await prisma.purchase.findUnique({ where: { id }, include: { items: true } });
if (!purchase) return;
// Log the deletion before deleting
await logFinancialChange(
'DELETE',
'PURCHASE',
id,
{
supplier: { old: purchase.supplierName, new: null },
total: { old: purchase.total, new: 0 },
items: { old: purchase.items.length, new: 0 },
},
purchase.total,
'Purchase deleted'
);
await prisma.purchase.delete({ where: { id } });
// Removing a purchase takes its stock back out.
await applyStockMovement(purchase.items, -1);
+217
View File
@@ -0,0 +1,217 @@
import { prisma } from '@/lib/prisma';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import AdminNav from '@/components/AdminNav';
import Link from 'next/link';
export default async function FinancialAuditPage({
searchParams,
}: {
searchParams: { type?: string; action?: string; from?: string; to?: string };
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const entityType = searchParams.type as any;
const action = searchParams.action as any;
const fromDate = searchParams.from ? new Date(searchParams.from) : null;
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
const logs = await prisma.financialAuditLog.findMany({
where: {
...(entityType && { entityType }),
...(action && { action }),
...(fromDate || toDate
? {
changedAt: {
...(fromDate && { gte: fromDate }),
...(toDate && { lte: toDate }),
},
}
: {}),
},
orderBy: { changedAt: 'desc' },
take: 200,
});
// Summary stats
const allLogs = await prisma.financialAuditLog.findMany({
orderBy: { changedAt: 'desc' },
});
const last30Days = allLogs.filter(
(l) => l.changedAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
);
const deletions = logs.filter((l) => l.action === 'DELETE');
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Financial Audit Trail</h1>
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
Back to admin
</Link>
</div>
<p className="mt-4 text-sm text-muted">
Complete record of all changes to financial data (orders, sales, purchases, expenses). Use this
to track who changed what and when.
</p>
{/* Stats */}
<div className="mt-6 grid gap-3 sm:grid-cols-4">
<div className="border border-line bg-surface p-4">
<p className="tag-label">Total Changes</p>
<p className="mt-1 font-mono text-xl">{allLogs.length}</p>
</div>
<div className="border border-line bg-surface p-4">
<p className="tag-label">Last 30 Days</p>
<p className="mt-1 font-mono text-xl">{last30Days.length}</p>
</div>
<div className="border border-line bg-surface p-4">
<p className="tag-label">Deletions</p>
<p className={`mt-1 font-mono text-xl ${deletions.length > 0 ? 'text-splash-pink' : ''}`}>
{deletions.length}
</p>
</div>
<div className="border border-line bg-surface p-4">
<p className="tag-label">Creates/Updates</p>
<p className="mt-1 font-mono text-xl">{allLogs.length - deletions.length}</p>
</div>
</div>
{/* Filters */}
<div className="mt-6 space-y-4">
<form method="get" className="flex flex-wrap gap-3">
<select
name="type"
defaultValue={entityType ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
>
<option value="">All entity types</option>
<option value="ORDER">Orders</option>
<option value="MANUAL_SALE">Manual Sales</option>
<option value="PURCHASE">Purchases</option>
<option value="EXPENSE">Expenses</option>
</select>
<select
name="action"
defaultValue={action ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
>
<option value="">All actions</option>
<option value="CREATE">Created</option>
<option value="UPDATE">Updated</option>
<option value="DELETE">Deleted</option>
</select>
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="From"
/>
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="To"
/>
<button
type="submit"
className="border border-line px-4 py-2 text-sm hover:border-clay hover:text-clay"
>
Filter
</button>
{(entityType || action || searchParams.from || searchParams.to) && (
<Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink">
Clear filters
</Link>
)}
</form>
</div>
{/* Logs Table */}
{logs.length === 0 ? (
<p className="mt-8 text-sm text-muted">No audit logs found.</p>
) : (
<div className="mt-8 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
<th className="text-left py-2 px-3 font-medium">Action</th>
<th className="text-left py-2 px-3 font-medium">Entity Type</th>
<th className="text-left py-2 px-3 font-medium">Entity ID</th>
<th className="text-left py-2 px-3 font-medium">Changed By (IP)</th>
<th className="text-left py-2 px-3 font-medium">Details</th>
</tr>
</thead>
<tbody>
{logs.map((log) => {
const changes = JSON.parse(log.changes || '{}');
const changeList = Object.entries(changes)
.map(([field, change]: [string, any]) => `${field}: ${change.old}${change.new}`)
.slice(0, 2)
.join('; ');
return (
<tr
key={log.id}
className={`border-b border-line hover:bg-surface ${
log.action === 'DELETE' ? 'bg-splash-pink/5' : ''
}`}
>
<td className="py-2 px-3 font-mono text-xs">
{log.changedAt.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}{' '}
{log.changedAt.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</td>
<td className="py-2 px-3">
<span
className={`tag-label rounded-full px-2 py-1 text-xs ${
log.action === 'CREATE'
? 'bg-green-100 text-green-700'
: log.action === 'UPDATE'
? 'bg-blue-100 text-blue-700'
: 'bg-splash-pink/10 text-splash-pink'
}`}
>
{log.action}
</span>
</td>
<td className="py-2 px-3 text-xs">{log.entityType}</td>
<td className="py-2 px-3 font-mono text-xs">{log.entityId}</td>
<td className="py-2 px-3 font-mono text-xs">{log.changedBy || '—'}</td>
<td className="py-2 px-3 text-xs text-muted truncate" title={changeList}>
{changeList || log.reason || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<p className="mt-8 text-xs text-muted">
Showing last 200 changes. Audit logs help you track financial data integrity and detect
unauthorized changes.
</p>
</div>
);
}