From d56f942a88801162d633a7ba1bf6f7def49c5b40 Mon Sep 17 00:00:00 2001 From: Andymick Date: Fri, 17 Jul 2026 15:23:05 +0100 Subject: [PATCH] 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 --- prisma/schema.prisma | 13 ++ src/app/admin/accounts/purchases/actions.ts | 32 ++- src/app/admin/financial-audit/page.tsx | 217 ++++++++++++++++++++ src/components/AdminMenu.tsx | 1 + src/lib/financialAudit.ts | 124 +++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/financial-audit/page.tsx create mode 100644 src/lib/financialAudit.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9f3c8fc..1516aa4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -404,3 +404,16 @@ model CustomerLoginLog { userAgent String? 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 +} diff --git a/src/app/admin/accounts/purchases/actions.ts b/src/app/admin/accounts/purchases/actions.ts index e736dce..da70d6f 100644 --- a/src/app/admin/accounts/purchases/actions.ts +++ b/src/app/admin/accounts/purchases/actions.ts @@ -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); diff --git a/src/app/admin/financial-audit/page.tsx b/src/app/admin/financial-audit/page.tsx new file mode 100644 index 0000000..dd72dd6 --- /dev/null +++ b/src/app/admin/financial-audit/page.tsx @@ -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
Unauthorized
; + } + + 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 ( +
+ +
+

Financial Audit Trail

+ + Back to admin + +
+ +

+ Complete record of all changes to financial data (orders, sales, purchases, expenses). Use this + to track who changed what and when. +

+ + {/* Stats */} +
+
+

Total Changes

+

{allLogs.length}

+
+
+

Last 30 Days

+

{last30Days.length}

+
+
+

Deletions

+

0 ? 'text-splash-pink' : ''}`}> + {deletions.length} +

+
+
+

Creates/Updates

+

{allLogs.length - deletions.length}

+
+
+ + {/* Filters */} +
+
+ + + + + + + + + + + {(entityType || action || searchParams.from || searchParams.to) && ( + + Clear filters + + )} +
+
+ + {/* Logs Table */} + {logs.length === 0 ? ( +

No audit logs found.

+ ) : ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
Date & TimeActionEntity TypeEntity IDChanged By (IP)Details
+ {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', + })} + + + {log.action} + + {log.entityType}{log.entityId}{log.changedBy || '—'} + {changeList || log.reason || '—'} +
+
+ )} + +

+ Showing last 200 changes. Audit logs help you track financial data integrity and detect + unauthorized changes. +

+
+ ); +} diff --git a/src/components/AdminMenu.tsx b/src/components/AdminMenu.tsx index 981f49a..54b4105 100644 --- a/src/components/AdminMenu.tsx +++ b/src/components/AdminMenu.tsx @@ -24,6 +24,7 @@ const ADMIN_LINKS = [ { href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/login-logs', label: 'Admin login activity' }, { href: '/admin/customer-login-logs', label: 'Customer login activity' }, + { href: '/admin/financial-audit', label: 'Financial audit trail' }, ]; export default function AdminMenu() { diff --git a/src/lib/financialAudit.ts b/src/lib/financialAudit.ts new file mode 100644 index 0000000..6360a88 --- /dev/null +++ b/src/lib/financialAudit.ts @@ -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; +}