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:
co-authored by
Claude Haiku 4.5
parent
e69759081c
commit
d56f942a88
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user