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
+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;
}