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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user