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 */}
+
+
+
+
+ {/* Logs Table */}
+ {logs.length === 0 ? (
+
No audit logs found.
+ ) : (
+
+
+
+
+ | Date & Time |
+ Action |
+ Entity Type |
+ Entity ID |
+ Changed By (IP) |
+ Details |
+
+
+
+ {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 (
+
+ |
+ {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;
+}