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
+13
View File
@@ -404,3 +404,16 @@ model CustomerLoginLog {
userAgent String? userAgent String?
success Boolean @default(true) // false for failed login attempts success Boolean @default(true) // false for failed login attempts
} }
// Audit log for financial transactions — tracks all changes to financial data
model FinancialAuditLog {
id String @id @default(cuid())
action String // CREATE, UPDATE, DELETE
entityType String // "ORDER", "MANUAL_SALE", "PURCHASE", "EXPENSE"
entityId String // ID of the order/sale/purchase/expense
changes String // JSON: {field: {old: X, new: Y}, ...}
amount Int? // cents, for quick filtering of high-value changes
changedAt DateTime @default(now())
changedBy String? // "SYSTEM" or IP address if admin
reason String? // why was this changed
}
+31 -1
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock'; import { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit';
export type PurchaseItemInput = { export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…) 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(); const purchaseDate = purchasedAtInput ? new Date(purchasedAtInput) : new Date();
await prisma.purchase.create({ const purchase = await prisma.purchase.create({
data: { data: {
supplierName, supplierName,
purchasedAt: purchaseDate, 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. // Product lines add to stock; material lines are just logged.
await applyStockMovement(items, 1); await applyStockMovement(items, 1);
@@ -99,6 +114,21 @@ export async function deletePurchase(formData: FormData) {
if (!id) return; if (!id) return;
const purchase = await prisma.purchase.findUnique({ where: { id }, include: { items: true } }); const purchase = await prisma.purchase.findUnique({ where: { id }, include: { items: true } });
if (!purchase) return; 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 } }); await prisma.purchase.delete({ where: { id } });
// Removing a purchase takes its stock back out. // Removing a purchase takes its stock back out.
await applyStockMovement(purchase.items, -1); 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>
);
}
+1
View File
@@ -24,6 +24,7 @@ const ADMIN_LINKS = [
{ href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/maintenance', label: 'Maintenance' },
{ href: '/admin/login-logs', label: 'Admin login activity' }, { href: '/admin/login-logs', label: 'Admin login activity' },
{ href: '/admin/customer-login-logs', label: 'Customer login activity' }, { href: '/admin/customer-login-logs', label: 'Customer login activity' },
{ href: '/admin/financial-audit', label: 'Financial audit trail' },
]; ];
export default function AdminMenu() { export default function AdminMenu() {
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from '@/lib/prisma';
import { getClientIp } from '@/lib/rateLimit';
import { headers } from 'next/headers';
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type EntityType = 'ORDER' | 'MANUAL_SALE' | 'PURCHASE' | 'EXPENSE';
interface AuditChangeSet {
[field: string]: {
old: any;
new: any;
};
}
/**
* Log a financial transaction change to the audit trail
* @param action CREATE, UPDATE, or DELETE
* @param entityType Type of entity (ORDER, MANUAL_SALE, etc)
* @param entityId ID of the entity
* @param changes Object with {field: {old: X, new: Y}}
* @param amount Optional amount in cents for quick filtering
* @param reason Why was this changed
*/
export async function logFinancialChange(
action: AuditAction,
entityType: EntityType,
entityId: string,
changes: AuditChangeSet,
amount?: number,
reason?: string
) {
try {
const ip = getClientIp(headers());
await prisma.financialAuditLog.create({
data: {
action,
entityType,
entityId,
changes: JSON.stringify(changes),
amount,
changedBy: ip,
reason,
},
});
} catch (err) {
console.error('Failed to log financial change:', err);
// Don't throw - audit logging failure shouldn't break the main transaction
}
}
/**
* Get audit logs for a specific entity
*/
export async function getEntityAuditTrail(entityType: EntityType, entityId: string) {
return prisma.financialAuditLog.findMany({
where: { entityType, entityId },
orderBy: { changedAt: 'desc' },
});
}
/**
* Get all audit logs with optional filtering
*/
export async function getAuditLogs(options: {
entityType?: EntityType;
action?: AuditAction;
fromDate?: Date;
toDate?: Date;
minAmount?: number;
limit?: number;
}) {
return prisma.financialAuditLog.findMany({
where: {
...(options.entityType && { entityType: options.entityType }),
...(options.action && { action: options.action }),
...(options.fromDate || options.toDate
? {
changedAt: {
...(options.fromDate && { gte: options.fromDate }),
...(options.toDate && { lte: options.toDate }),
},
}
: {}),
...(options.minAmount && { amount: { gte: options.minAmount } }),
},
orderBy: { changedAt: 'desc' },
take: options.limit || 100,
});
}
/**
* Get summary of financial changes (useful for spotting issues)
*/
export async function getAuditSummary(days: number = 30) {
const fromDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const logs = await prisma.financialAuditLog.findMany({
where: { changedAt: { gte: fromDate } },
});
const summary = {
total: logs.length,
byAction: {
CREATE: logs.filter((l) => l.action === 'CREATE').length,
UPDATE: logs.filter((l) => l.action === 'UPDATE').length,
DELETE: logs.filter((l) => l.action === 'DELETE').length,
},
byEntity: {
ORDER: logs.filter((l) => l.entityType === 'ORDER').length,
MANUAL_SALE: logs.filter((l) => l.entityType === 'MANUAL_SALE').length,
PURCHASE: logs.filter((l) => l.entityType === 'PURCHASE').length,
EXPENSE: logs.filter((l) => l.entityType === 'EXPENSE').length,
},
deletions: logs.filter((l) => l.action === 'DELETE').map((l) => ({
entity: l.entityType,
id: l.entityId,
at: l.changedAt,
reason: l.reason,
})),
};
return summary;
}