Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions middleware/integrityGuard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const ledgerService = require('../services/ledgerService');
const ResponseFactory = require('../utils/ResponseFactory');

/**
* Integrity Guard Middleware
* Issue #738: Performs a real-time integrity check on an entity's event chain.
* Rejects requests if the ledger has been tampered with or is broken.
*/
const integrityGuard = async (req, res, next) => {
const entityId = req.params.id || req.body.transactionId;

if (!entityId) return next();

try {
const audit = await ledgerService.auditChain(entityId);

if (!audit.valid) {
console.error(`[IntegrityGuard] Ledger breach detected for ${entityId}: ${audit.reason}`);

return res.status(403).json({
success: false,
error: 'Ledger Integrity Violation',
message: 'The cryptographic chain for this record has been compromised. Access is restricted for forensic audit.',
details: {
reason: audit.reason,
brokenAt: audit.sequence
}
});
}

next();
} catch (err) {
console.error('[IntegrityGuard Error]:', err.message);
next(); // Fail open but log
}
};

module.exports = integrityGuard;
82 changes: 41 additions & 41 deletions models/FinancialEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,67 @@ const mongoose = require('mongoose');

/**
* FinancialEvent Model
* Issue #680: The immutable source of truth for all state mutations.
* Each event captures a discrete change in the system.
* Issue #738: Stores every state change as an immutable event record.
* This is the source of truth for the transaction system.
*/
const financialEventSchema = new mongoose.Schema({
userId: {
entityId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
eventType: {
type: String,
required: true,
enum: [
'TX_CREATED', 'TX_UPDATED', 'TX_DELETED',
'WS_CREATED', 'WS_UPDATED', 'WS_MEMBER_ADDED',
'BUDGET_EXCEEDED', 'SYSTEM_AUDIT_LOG'
],
index: true
index: true // The Transaction ID
},
entityType: {
type: String,
required: true, // e.g., 'Transaction', 'Workspace'
index: true
enum: ['TRANSACTION', 'BUDGET', 'WORKSPACE'],
default: 'TRANSACTION'
},
entityId: {
type: mongoose.Schema.Types.ObjectId,
eventType: {
type: String,
required: true,
index: true
enum: [
'CREATED',
'UPDATED',
'DELETED',
'RECONCILED',
'VOIDED',
'FROZEN'
]
},
payload: {
type: mongoose.Schema.Types.Mixed,
required: true // The state AFTER the change or the delta
required: true
},
previousEventId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'FinancialEvent',
default: null
sequence: {
type: Number,
required: true
},
metadata: {
deviceId: String,
ipAddress: String,
userAgent: String,
correlationId: String, // To group related events
timestamp: { type: Date, default: Date.now }
prevHash: {
type: String,
required: true
},
checksum: {
currentHash: {
type: String,
required: true // SHA-256 of payload+previousEventId for immutability validation
required: true
},
version: {
type: Number,
required: true // Incremental version per entity
signature: {
type: String,
required: true
},
performedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
timestamp: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
timestamps: false // We use our own timestamp
});

// Comprehensive indexes for forensic analysis
financialEventSchema.index({ entityId: 1, version: 1 }, { unique: true });
financialEventSchema.index({ 'metadata.timestamp': 1 });
financialEventSchema.index({ userId: 1, eventType: 1 });
// Compound index for unique sequence per entity
financialEventSchema.index({ entityId: 1, sequence: 1 }, { unique: true });

module.exports = mongoose.model('FinancialEvent', financialEventSchema);
20 changes: 20 additions & 0 deletions models/Transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ const transactionSchema = new mongoose.Schema({
type: Date,
default: Date.now
},
// Issue #738: Event Sourcing & Ledger Tracking
ledgerSequence: {
type: Number,
default: 0
},
ledgerStatus: {
type: String,
enum: ['synced', 'diverged', 'rebuilding'],
default: 'synced'
},
lastLedgerEventId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'FinancialEvent'
},
appliedRules: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Rule'
Expand Down Expand Up @@ -250,4 +264,10 @@ transactionSchema.index({ location: '2dsphere' });
transactionSchema.index({ receiptId: 1 });
transactionSchema.index({ source: 1, user: 1 });

transactionSchema.virtual('history', {
ref: 'FinancialEvent',
localField: '_id',
foreignField: 'entityId'
});

module.exports = mongoose.model('Transaction', transactionSchema);
43 changes: 43 additions & 0 deletions repositories/expenseRepository.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const BaseRepository = require('./baseRepository');
const Expense = require('../models/Expense');
const ledgerService = require('../services/ledgerService');

/**
* ExpenseRepository - Data Access Layer for Expense operations
Expand All @@ -10,6 +11,48 @@ class ExpenseRepository extends BaseRepository {
super(Expense);
}

/**
* Event-Sourced Update
*/
async updateOne(filters, data, options = { new: true, runValidators: true }) {
const doc = await this.model.findOne(filters);
if (!doc) return null;

const updatedDoc = await super.updateOne(filters, data, options);

// Record UPDATE event
const event = await ledgerService.recordEvent(
doc._id,
'UPDATED',
data,
doc.user // Assumes user exists on doc
);

updatedDoc.ledgerSequence = event.sequence;
updatedDoc.lastLedgerEventId = event._id;
await updatedDoc.save();

return updatedDoc;
}

/**
* Event-Sourced Delete
*/
async deleteOne(filters) {
const doc = await this.model.findOne(filters);
if (!doc) return null;

// Record DELETE event BEFORE actual deletion or use a tombstone
await ledgerService.recordEvent(
doc._id,
'DELETED',
{ deletedAt: new Date() },
doc.user
);

return await super.deleteOne(filters);
}

/**
* Find expenses by user with filters
*/
Expand Down
5 changes: 3 additions & 2 deletions routes/expenses.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const AppError = require('../utils/AppError');
const { ExpenseSchemas, validateRequest, validateQuery } = require('../middleware/inputValidator');
const { expenseLimiter, exportLimiter } = require('../middleware/rateLimiter');
const { requireAuth, getUserId } = require('../middleware/clerkAuth');
const integrityGuard = require('../middleware/integrityGuard');



Expand Down Expand Up @@ -98,7 +99,7 @@ router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
* @desc Update an expense
* @access Private
*/
router.put('/:id', requireAuth, validateRequest(ExpenseSchemas.create), asyncHandler(async (req, res) => {
router.put('/:id', requireAuth, integrityGuard, validateRequest(ExpenseSchemas.create), asyncHandler(async (req, res) => {
const expense = await expenseRepository.updateOne(
{ _id: req.params.id, user: req.user._id },
req.body
Expand Down Expand Up @@ -178,7 +179,7 @@ router.post('/bulk-delete', requireAuth, validateRequest(ExpenseSchemas.bulkDele
* @desc Delete an expense
* @access Private
*/
router.delete('/:id', requireAuth, asyncHandler(async (req, res) => {
router.delete('/:id', requireAuth, integrityGuard, asyncHandler(async (req, res) => {
const expense = await expenseRepository.deleteOne({ _id: req.params.id, user: req.user._id });

if (!expense) throw new NotFoundError('Expense not found');
Expand Down
49 changes: 49 additions & 0 deletions routes/ledger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const ledgerService = require('../services/ledgerService');
const FinancialEvent = require('../models/FinancialEvent');
const ResponseFactory = require('../utils/ResponseFactory');

/**
* Immutable Ledger API
* Issue #738: Endpoints to view transaction history and audit event chains.
*/

/**
* @route GET /api/ledger/:id
* @desc Get complete event history for an entity
*/
router.get('/:id', auth, async (req, res) => {
try {
const events = await FinancialEvent.find({ entityId: req.params.id })
.sort({ sequence: 1 })
.populate('performedBy', 'name email');

const audit = await ledgerService.auditChain(req.params.id);

return ResponseFactory.success(res, {
entityId: req.params.id,
integrity: audit,
eventCount: events.length,
events
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});

/**
* @route POST /api/ledger/:id/reconstruct
* @desc Reconstruct current state by replaying all events
*/
router.post('/:id/reconstruct', auth, async (req, res) => {
try {
const state = await ledgerService.reconstructState(req.params.id);
return ResponseFactory.success(res, state);
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});

module.exports = router;
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ app.use('/api/governance', require('./routes/governance'));
app.use('/api/taxonomy', require('./routes/taxonomy'));
app.use('/api/sync', require('./routes/sync'));
app.use('/api/admin', require('./routes/admin'));
app.use('/api/ledger', require('./routes/ledger'));

app.use('/api/telemetry', require('./routes/telemetry'));
app.use('/api/jobs', require('./routes/jobs'));
Expand Down
15 changes: 15 additions & 0 deletions services/expenseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const approvalService = require('./approvalService');
const intelligenceService = require('./intelligenceService');
const categorizationEngine = require('./categorizationEngine');
const merchantLearningService = require('./merchantLearningService');
const ledgerService = require('./ledgerService');

// Wrapper for backward compatibility
class ExpenseService {
Expand Down Expand Up @@ -110,6 +111,20 @@ class ExpenseService {
// 5. Save Expense
const expense = await expenseRepository.create(finalData);

// Issue #738: Immutable Ledger Event
const event = await ledgerService.recordEvent(
expense._id,
'CREATED',
finalData,
userId
);

// Update sequence in main document
await expenseRepository.updateById(expense._id, {
ledgerSequence: event.sequence,
lastLedgerEventId: event._id
});

// 6. Handle Approvals (fallback for non-policy workspace expenses)
if (finalData.workspace && !finalData.requiresApproval) {
const requiresApproval = await approvalService.requiresApproval(finalData, finalData.workspace);
Expand Down
Loading