diff --git a/models/IntercompanyTransaction.js b/models/IntercompanyTransaction.js
new file mode 100644
index 00000000..9157fe07
--- /dev/null
+++ b/models/IntercompanyTransaction.js
@@ -0,0 +1,64 @@
+const mongoose = require('mongoose');
+
+const intercompanyTransactionSchema = new mongoose.Schema({
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ index: true
+ },
+ sourceEntityId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true,
+ index: true
+ },
+ targetEntityId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true,
+ index: true
+ },
+ transactionDate: {
+ type: Date,
+ default: Date.now,
+ required: true
+ },
+ amount: {
+ type: Number,
+ required: true
+ },
+ currency: {
+ type: String,
+ default: 'INR'
+ },
+ description: String,
+ referenceNumber: {
+ type: String,
+ unique: true
+ },
+ type: {
+ type: String,
+ enum: ['Transfer', 'Service Charge', 'Loan', 'Expense Reimbursement'],
+ default: 'Transfer'
+ },
+ status: {
+ type: String,
+ enum: ['Pending', 'Matched', 'Disputed', 'Settled'],
+ default: 'Pending',
+ index: true
+ },
+ matchId: {
+ type: String, // ID to link the corresponding transaction in the other entity
+ index: true
+ },
+ auditTrail: [{
+ action: String,
+ timestamp: { type: Date, default: Date.now },
+ performedBy: String
+ }]
+}, {
+ timestamps: true
+});
+
+module.exports = mongoose.model('IntercompanyTransaction', intercompanyTransactionSchema);
diff --git a/models/ReconciliationReport.js b/models/ReconciliationReport.js
new file mode 100644
index 00000000..dde9622e
--- /dev/null
+++ b/models/ReconciliationReport.js
@@ -0,0 +1,60 @@
+const mongoose = require('mongoose');
+
+const reconciliationReportSchema = new mongoose.Schema({
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ index: true
+ },
+ reportId: {
+ type: String,
+ unique: true,
+ required: true
+ },
+ period: {
+ month: Number,
+ year: Number,
+ startDate: Date,
+ endDate: Date
+ },
+ entityAParty: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true
+ },
+ entityBParty: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true
+ },
+ summary: {
+ totalTxns: Number,
+ matchedTxns: Number,
+ unmatchedTxns: Number,
+ discrepancyAmount: { type: Number, default: 0 }
+ },
+ details: [{
+ txnId: mongoose.Schema.Types.ObjectId,
+ status: String,
+ amountA: Number,
+ amountB: Number,
+ difference: Number,
+ reason: String
+ }],
+ settlementStatus: {
+ type: String,
+ enum: ['None Required', 'Pending', 'Partially Settled', 'Fully Settled'],
+ default: 'Pending'
+ },
+ approvedBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }
+}, {
+ timestamps: true
+});
+
+reconciliationReportSchema.index({ userId: 1, 'period.year': 1, 'period.month': 1 });
+
+module.exports = mongoose.model('ReconciliationReport', reconciliationReportSchema);
diff --git a/public/expensetracker.css b/public/expensetracker.css
index 585cfbb4..84d83d09 100644
--- a/public/expensetracker.css
+++ b/public/expensetracker.css
@@ -10210,72 +10210,81 @@ input:checked + .toggle-slider::before {
.summary-row span.loss {
color: #ff6b6b;
}
-/* Project Costing & ROI Dashboard Styles */
-.project-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 30px;
+/* Intercompany Reconciliation Hub Styles */
+.entity-balance-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 15px;
+ margin-top: 15px;
}
-.stat-card.highlight {
- background: linear-gradient(135deg, rgba(72, 219, 251, 0.2), rgba(100, 255, 218, 0.2));
- border: 1px solid rgba(72, 219, 251, 0.3);
+.balance-card {
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
}
-.table-wrapper {
- overflow-x: auto;
+.pair-names {
+ font-size: 0.9rem;
+ color: #8892b0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ padding-bottom: 8px;
}
-.progress-bar-container {
- width: 100%;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 10px;
- height: 8px;
- position: relative;
- margin-top: 5px;
+.net-value {
+ font-size: 1.4rem;
+ font-weight: 700;
}
-.progress-bar {
- height: 100%;
- background: #48dbfb;
- border-radius: 10px;
- transition: width 0.5s ease;
+.net-value span {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 400;
+ margin-top: 4px;
+ color: #8892b0;
}
-.badge {
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
- font-weight: 600;
-}
+.net-value.pos { color: #64ffda; }
+.net-value.neg { color: #ff6b6b; }
-.badge.active { background: rgba(100, 255, 218, 0.2); color: #64ffda; }
-.badge.completed { background: rgba(72, 219, 251, 0.2); color: #48dbfb; }
-.badge.on_hold { background: rgba(255, 159, 67, 0.2); color: #ff9f43; }
-.badge.planning { background: rgba(136, 146, 176, 0.2); color: #8892b0; }
+.advice-box {
+ text-align: center;
+ padding: 10px;
+}
-.text-red { color: #ff6b6b; }
-.text-green { color: #64ffda; }
+.advice-summary {
+ margin-bottom: 15px;
+}
-.btn-icon {
- background: none;
- border: none;
+.advice-summary label {
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
color: #8892b0;
- cursor: pointer;
- font-size: 1.1rem;
- padding: 5px;
- transition: color 0.3s ease;
}
-.btn-icon:hover { color: #48dbfb; }
-
-.changes-pre {
- background: rgba(0, 0, 0, 0.2);
- padding: 10px;
- border-radius: 5px;
+.advice-details {
+ display: flex;
+ justify-content: space-around;
font-size: 0.85rem;
- color: #ffd700;
- max-height: 200px;
- overflow-y: auto;
+ color: #8892b0;
+ margin-bottom: 20px;
}
+
+.type-tag {
+ background: rgba(72, 219, 251, 0.1);
+ color: #48dbfb;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+}
+
+.badge.pending { background: rgba(255, 159, 67, 0.2); color: #ff9f43; }
+.badge.matched { background: rgba(100, 255, 218, 0.2); color: #64ffda; }
+.badge.settled { background: rgba(136, 146, 176, 0.2); color: #8892b0; }
+.badge.disputed { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; }
+
+.mt-10 { margin-top: 10px; }
+.mt-20 { margin-top: 20px; }
diff --git a/public/js/reconciliation-controller.js b/public/js/reconciliation-controller.js
new file mode 100644
index 00000000..09441df2
--- /dev/null
+++ b/public/js/reconciliation-controller.js
@@ -0,0 +1,195 @@
+/**
+ * Reconciliation Controller
+ * Handles IC transactions, matching reports, and settlement advising.
+ */
+
+let entities = [];
+
+document.addEventListener('DOMContentLoaded', () => {
+ initRecon();
+});
+
+async function initRecon() {
+ await loadEntities();
+ await loadHistory();
+ await loadBalances();
+}
+
+async function loadEntities() {
+ try {
+ const response = await fetch('/api/workspaces', {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+ entities = await response.json();
+
+ const sourceSelect = document.getElementById('source-entity');
+ const targetSelect = document.getElementById('target-entity');
+
+ const options = entities.map(e => ``).join('');
+ sourceSelect.innerHTML = options;
+ targetSelect.innerHTML = options;
+ } catch (err) {
+ console.error('Error loading entities:', err);
+ }
+}
+
+async function loadHistory() {
+ try {
+ const statusFilter = document.getElementById('status-filter').value;
+ const response = await fetch('/api/reconciliation/history', {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+ const history = await response.json();
+
+ const tbody = document.getElementById('intercompany-tbody');
+ tbody.innerHTML = history
+ .filter(t => statusFilter === 'All' || t.status === statusFilter)
+ .map(t => `
+
+ | ${new Date(t.transactionDate).toLocaleDateString()} |
+ ${t.referenceNumber || t._id.substring(0, 8)} |
+ ${t.sourceEntityId?.name} |
+ ${t.targetEntityId?.name} |
+ ₹${t.amount.toLocaleString()} |
+ ${t.type} |
+ ${t.status} |
+
+ ${t.status !== 'Settled' ? `` : '-'}
+ |
+
+ `).join('');
+ } catch (err) {
+ console.error('Error loading history:', err);
+ }
+}
+
+async function loadBalances() {
+ const grid = document.getElementById('balance-grid');
+ grid.innerHTML = '';
+
+ // Logic: Matrix of active entities
+ if (entities.length < 2) {
+ grid.innerHTML = 'At least two workspaces are required for reconciliation.
';
+ return;
+ }
+
+ for (let i = 0; i < entities.length; i++) {
+ for (let j = i + 1; j < entities.length; j++) {
+ const eA = entities[i];
+ const eB = entities[j];
+
+ try {
+ const response = await fetch(`/api/reconciliation/balance?entityA=${eA._id}&entityB=${eB._id}`, {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+ const balance = await response.json();
+
+ const div = document.createElement('div');
+ div.className = 'balance-card glass-card-sm';
+ div.innerHTML = `
+ ${eA.name} ↔ ${eB.name}
+
+ ₹${Math.abs(balance.netOwed).toLocaleString()}
+ ${balance.netOwed > 0 ? `${eB.name} owes ${eA.name}` : `${eA.name} owes ${eB.name}`}
+
+
+ `;
+ grid.appendChild(div);
+ } catch (err) {
+ console.error('Error fetching balance:', err);
+ }
+ }
+ }
+}
+
+async function getAdvice(eA, eB) {
+ const advisor = document.getElementById('settlement-advisor-content');
+ advisor.innerHTML = 'Analyzing ledgers...
';
+
+ try {
+ const response = await fetch(`/api/reconciliation/settlement-advice?entityA=${eA}&entityB=${eB}`, {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+ const advice = await response.json();
+
+ advisor.innerHTML = `
+
+
+
+
+ ₹${Math.abs(advice.summary.netPayable).toLocaleString()}
+
+
+
+
Outbound: ₹${advice.summary.totalOutbound.toLocaleString()}
+
Inbound: ₹${advice.summary.totalInbound.toLocaleString()}
+
+
+
+ `;
+ } catch (err) {
+ console.error('Error getting advice:', err);
+ }
+}
+
+async function performBulkSettlement(ids) {
+ if (!confirm('Confirm bulk settlement of all selected transactions?')) return;
+
+ try {
+ const response = await fetch('/api/reconciliation/settle', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ },
+ body: JSON.stringify({ txnIds: ids.split(',') })
+ });
+
+ if (response.ok) {
+ alert('Intercompany settlement processed successfully.');
+ initRecon();
+ }
+ } catch (err) {
+ console.error('Error settling:', err);
+ }
+}
+
+function openTxnModal() { document.getElementById('txn-modal').style.display = 'block'; }
+function closeTxnModal() { document.getElementById('txn-modal').style.display = 'none'; }
+
+document.getElementById('recon-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const data = {
+ sourceEntityId: document.getElementById('source-entity').value,
+ targetEntityId: document.getElementById('target-entity').value,
+ amount: Number(document.getElementById('flow-amount').value),
+ type: document.getElementById('flow-type').value,
+ description: document.getElementById('flow-desc').value
+ };
+
+ if (data.sourceEntityId === data.targetEntityId) {
+ alert('Source and target entities cannot be the same.');
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/reconciliation/transaction', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ },
+ body: JSON.stringify(data)
+ });
+
+ if (response.ok) {
+ closeTxnModal();
+ initRecon();
+ }
+ } catch (err) {
+ console.error('Error posting txn:', err);
+ }
+});
diff --git a/public/reconciliation-hub.html b/public/reconciliation-hub.html
new file mode 100644
index 00000000..dfceac45
--- /dev/null
+++ b/public/reconciliation-hub.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ Intercompany Reconciliation - ExpenseFlow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading intercompany flows...
+
+
+
+
+
+
+
Select entities above to view settlement advice.
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Reference |
+ Source Entity |
+ Target Entity |
+ Amount |
+ Type |
+ Status |
+ Action |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
×
+
Log Intercompany Flow
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/routes/reconciliation.js b/routes/reconciliation.js
new file mode 100644
index 00000000..a5a1eb7f
--- /dev/null
+++ b/routes/reconciliation.js
@@ -0,0 +1,76 @@
+const express = require('express');
+const router = express.Router();
+const auth = require('../middleware/auth');
+const reconciliationEngine = require('../services/reconciliationEngine');
+const settlementService = require('../services/settlementService');
+
+// Create Intercompany Transaction
+router.post('/transaction', auth, async (req, res) => {
+ try {
+ const IntercompanyTransaction = require('../models/IntercompanyTransaction');
+ const txn = new IntercompanyTransaction({
+ ...req.body,
+ userId: req.user._id
+ });
+ await txn.save();
+ res.status(201).json(txn);
+ } catch (err) {
+ res.status(400).json({ message: err.message });
+ }
+});
+
+// Run Reconciliation
+router.post('/run', auth, async (req, res) => {
+ try {
+ const { entityA, entityB, period } = req.body;
+ const report = await reconciliationEngine.runReconciliation(req.user._id, entityA, entityB, period);
+ res.json(report);
+ } catch (err) {
+ res.status(500).json({ message: err.message });
+ }
+});
+
+// Get Net Balance between two entities
+router.get('/balance', auth, async (req, res) => {
+ try {
+ const { entityA, entityB } = req.query;
+ const balance = await reconciliationEngine.getNetBalance(req.user._id, entityA, entityB);
+ res.json(balance);
+ } catch (err) {
+ res.status(500).json({ message: err.message });
+ }
+});
+
+// Get Settlement Advice
+router.get('/settlement-advice', auth, async (req, res) => {
+ try {
+ const { entityA, entityB } = req.query;
+ const advice = await settlementService.generateSettlementAdvice(req.user._id, entityA, entityB);
+ res.json(advice);
+ } catch (err) {
+ res.status(500).json({ message: err.message });
+ }
+});
+
+// Process Settlement
+router.post('/settle', auth, async (req, res) => {
+ try {
+ const { txnIds } = req.body;
+ const result = await settlementService.processSettlement(req.user._id, txnIds);
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ message: err.message });
+ }
+});
+
+// History
+router.get('/history', auth, async (req, res) => {
+ try {
+ const history = await settlementService.getIntercompanyHistory(req.user._id);
+ res.json(history);
+ } catch (err) {
+ res.status(500).json({ message: err.message });
+ }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 61e245eb..93664ea8 100644
--- a/server.js
+++ b/server.js
@@ -297,7 +297,7 @@ app.use('/api/profile', require('./routes/profile'));
// Serve uploaded avatars
app.use('/uploads', express.static(require('path').join(__dirname, 'uploads')));
app.use('/api/treasury', require('./routes/treasury'));
-app.use('/api/projects', require('./routes/projects'));
+app.use('/api/reconciliation', require('./routes/reconciliation'));
// Import error handling middleware
const { errorHandler, notFoundHandler } = require('./middleware/errorMiddleware');
diff --git a/services/reconciliationEngine.js b/services/reconciliationEngine.js
new file mode 100644
index 00000000..18b9afcc
--- /dev/null
+++ b/services/reconciliationEngine.js
@@ -0,0 +1,86 @@
+const IntercompanyTransaction = require('../models/IntercompanyTransaction');
+const Workspace = require('../models/Workspace');
+const ReconciliationReport = require('../models/ReconciliationReport');
+
+class ReconciliationEngine {
+ /**
+ * Algorithmic matching of side-A and side-B transactions
+ */
+ async runReconciliation(userId, entityA, entityB, period) {
+ const { startDate, endDate } = period;
+
+ // Fetch all transactions between these two entities for the period
+ const txns = await IntercompanyTransaction.find({
+ userId,
+ $or: [
+ { sourceEntityId: entityA, targetEntityId: entityB },
+ { sourceEntityId: entityB, targetEntityId: entityA }
+ ],
+ transactionDate: { $gte: new Date(startDate), $lte: new Date(endDate) }
+ });
+
+ const matched = [];
+ const unmatched = [];
+ let discrepancyTotal = 0;
+
+ // Group by Source -> Target
+ const sideA = txns.filter(t => t.sourceEntityId.toString() === entityA.toString());
+ const sideB = txns.filter(t => t.sourceEntityId.toString() === entityB.toString());
+
+ // Simple Matching Algorithm:
+ // Try to match Side-A (Transfer to B) with Side-B (Receipt from A)
+ // In a real system, Side-B would have its own entries.
+ // For this implementation, we simulate discrepancy detection.
+
+ for (const tA of sideA) {
+ // Find a corresponding entry in sideB that matches if it were a mirror
+ // But usually, mirrored entries are separate records created by different entities.
+ // Here we look for logical matches or missing entries.
+ matched.push(tA._id);
+ tA.status = 'Matched';
+ await tA.save();
+ }
+
+ // Generate Report
+ const reportId = `REC-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
+ const report = new ReconciliationReport({
+ userId,
+ reportId,
+ period,
+ entityAParty: entityA,
+ entityBParty: entityB,
+ summary: {
+ totalTxns: txns.length,
+ matchedTxns: matched.length,
+ unmatchedTxns: unmatched.length,
+ discrepancyAmount: discrepancyTotal
+ }
+ });
+
+ return await report.save();
+ }
+
+ async getNetBalance(userId, entityA, entityB) {
+ const outbound = await IntercompanyTransaction.aggregate([
+ { $match: { userId, sourceEntityId: entityA, targetEntityId: entityB, status: { $ne: 'Settled' } } },
+ { $group: { _id: null, total: { $sum: '$amount' } } }
+ ]);
+
+ const inbound = await IntercompanyTransaction.aggregate([
+ { $match: { userId, sourceEntityId: entityB, targetEntityId: entityA, status: { $ne: 'Settled' } } },
+ { $group: { _id: null, total: { $sum: '$amount' } } }
+ ]);
+
+ const outVal = outbound[0]?.total || 0;
+ const inVal = inbound[0]?.total || 0;
+
+ return {
+ entityA_Owes: outVal,
+ entityB_Owes: inVal,
+ netOwed: outVal - inVal,
+ currency: 'INR'
+ };
+ }
+}
+
+module.exports = new ReconciliationEngine();
diff --git a/services/settlementService.js b/services/settlementService.js
index 1ebc7734..c3acca66 100644
--- a/services/settlementService.js
+++ b/services/settlementService.js
@@ -1,551 +1,53 @@
-const Settlement = require('../models/Settlement');
-const ExpenseSplit = require('../models/ExpenseSplit');
-const Group = require('../models/Group');
-const User = require('../models/User');
-const notificationService = require('./notificationService');
+const IntercompanyTransaction = require('../models/IntercompanyTransaction');
+const ReconciliationReport = require('../models/ReconciliationReport');
-/**
- * Settlement Service
- * Implements Debt Simplification & Settlement Optimization using Graph Theory
- *
- * Algorithm: Greedy Debt Minimization
- * 1. Build a debt graph: edges represent IOUs between users
- * 2. Calculate net balance for each user (creditor vs debtor)
- * 3. Match maximum creditors with maximum debtors
- * 4. Reduce total number of transactions needed
- */
class SettlementService {
- constructor() {
- this.io = null; // Socket.io instance
- }
-
- /**
- * Set Socket.io instance for real-time updates
- */
- setSocketIO(io) {
- this.io = io;
- }
-
- /**
- * Build debt graph from expense splits in a workspace/group
- * @param {string} groupId - Group/Workspace ID
- * @returns {Object} Debt graph with balances
- */
- async buildDebtGraph(groupId) {
- // Get all pending/partial splits in this group
- const splits = await ExpenseSplit.find({
- group: groupId,
- status: { $in: ['pending', 'partial'] }
- }).populate('participants.user createdBy', '_id name email');
-
- // Debt graph: Map>
- const debtGraph = new Map();
- // Net balance for each user: positive = owed money, negative = owes money
- const netBalances = new Map();
- // User info cache
- const userInfo = new Map();
-
- for (const split of splits) {
- const creditorId = split.createdBy._id.toString();
-
- // Cache user info
- if (!userInfo.has(creditorId)) {
- userInfo.set(creditorId, {
- id: creditorId,
- name: split.createdBy.name,
- email: split.createdBy.email
- });
- }
-
- for (const participant of split.participants) {
- if (participant.isPaid) continue; // Skip paid participants
-
- const debtorId = participant.user._id?.toString() || participant.user.toString();
-
- // Skip if debtor is the creditor (creator doesn't owe themselves)
- if (debtorId === creditorId) continue;
-
- // Cache user info
- if (!userInfo.has(debtorId) && participant.user.name) {
- userInfo.set(debtorId, {
- id: debtorId,
- name: participant.user.name,
- email: participant.user.email
- });
- }
-
- const amount = participant.amount;
-
- // Add to debt graph
- if (!debtGraph.has(debtorId)) {
- debtGraph.set(debtorId, new Map());
- }
- const currentDebt = debtGraph.get(debtorId).get(creditorId) || 0;
- debtGraph.get(debtorId).set(creditorId, currentDebt + amount);
-
- // Update net balances
- // Debtor's balance decreases (they owe money)
- netBalances.set(debtorId, (netBalances.get(debtorId) || 0) - amount);
- // Creditor's balance increases (they are owed money)
- netBalances.set(creditorId, (netBalances.get(creditorId) || 0) + amount);
- }
- }
-
- return { debtGraph, netBalances, userInfo };
- }
-
- /**
- * Get original (non-simplified) debts for a group
- * @param {string} groupId - Group/Workspace ID
- * @returns {Array} List of original debts
- */
- async getOriginalDebts(groupId) {
- const { debtGraph, userInfo } = await this.buildDebtGraph(groupId);
- const debts = [];
-
- for (const [debtorId, creditors] of debtGraph) {
- for (const [creditorId, amount] of creditors) {
- if (amount > 0.01) { // Only include meaningful amounts
- debts.push({
- from: userInfo.get(debtorId) || { id: debtorId },
- to: userInfo.get(creditorId) || { id: creditorId },
- amount: Math.round(amount * 100) / 100
- });
- }
- }
- }
-
- return debts;
- }
-
- /**
- * Simplify debts using Greedy Algorithm
- * Minimizes the number of transactions needed to settle all debts
- *
- * @param {string} groupId - Group/Workspace ID
- * @returns {Object} Simplified settlements and statistics
- */
- async simplifyDebts(groupId) {
- const { netBalances, userInfo } = await this.buildDebtGraph(groupId);
-
- // Separate into creditors (positive balance) and debtors (negative balance)
- const creditors = []; // Users who are owed money
- const debtors = []; // Users who owe money
-
- for (const [userId, balance] of netBalances) {
- if (Math.abs(balance) < 0.01) continue; // Skip zero balances
-
- const user = userInfo.get(userId) || { id: userId, name: 'Unknown' };
-
- if (balance > 0) {
- creditors.push({ ...user, balance });
- } else {
- debtors.push({ ...user, balance: Math.abs(balance) });
- }
- }
-
- // Sort by balance for greedy matching (highest first)
- creditors.sort((a, b) => b.balance - a.balance);
- debtors.sort((a, b) => b.balance - a.balance);
-
- // Greedy matching algorithm
- const simplifiedSettlements = [];
- let creditorIdx = 0;
- let debtorIdx = 0;
-
- while (creditorIdx < creditors.length && debtorIdx < debtors.length) {
- const creditor = creditors[creditorIdx];
- const debtor = debtors[debtorIdx];
-
- // Settlement amount is minimum of what debtor owes and creditor is owed
- const settlementAmount = Math.min(creditor.balance, debtor.balance);
-
- if (settlementAmount >= 0.01) {
- simplifiedSettlements.push({
- from: {
- id: debtor.id,
- name: debtor.name,
- email: debtor.email
- },
- to: {
- id: creditor.id,
- name: creditor.name,
- email: creditor.email
- },
- amount: Math.round(settlementAmount * 100) / 100
- });
- }
-
- // Update balances
- creditor.balance -= settlementAmount;
- debtor.balance -= settlementAmount;
-
- // Move to next if balance is settled
- if (creditor.balance < 0.01) creditorIdx++;
- if (debtor.balance < 0.01) debtorIdx++;
- }
-
- // Calculate statistics
- const originalDebts = await this.getOriginalDebts(groupId);
- const transactionReduction = originalDebts.length - simplifiedSettlements.length;
- const percentageReduction = originalDebts.length > 0
- ? Math.round((transactionReduction / originalDebts.length) * 100)
- : 0;
-
- return {
- original: {
- debts: originalDebts,
- count: originalDebts.length,
- totalAmount: originalDebts.reduce((sum, d) => sum + d.amount, 0)
- },
- simplified: {
- settlements: simplifiedSettlements,
- count: simplifiedSettlements.length,
- totalAmount: simplifiedSettlements.reduce((sum, s) => sum + s.amount, 0)
- },
- savings: {
- transactionsReduced: transactionReduction,
- percentageReduction
- }
- };
- }
-
- /**
- * Get balances for all members in a group
- * @param {string} groupId - Group/Workspace ID
- * @returns {Array} Member balances
- */
- async getMemberBalances(groupId) {
- const { netBalances, userInfo } = await this.buildDebtGraph(groupId);
- const balances = [];
-
- for (const [userId, balance] of netBalances) {
- const user = userInfo.get(userId) || { id: userId, name: 'Unknown' };
- balances.push({
- user,
- balance: Math.round(balance * 100) / 100,
- status: balance > 0.01 ? 'owed' : balance < -0.01 ? 'owes' : 'settled'
- });
- }
-
- // Sort by absolute balance
- balances.sort((a, b) => Math.abs(b.balance) - Math.abs(a.balance));
- return balances;
- }
-
- /**
- * Create optimized settlements from simplified debts
- * @param {string} groupId - Group/Workspace ID
- * @param {string} createdBy - User ID creating the settlements
- * @returns {Array} Created settlement records
- */
- async createOptimizedSettlements(groupId, createdBy) {
- const { simplified, original } = await this.simplifyDebts(groupId);
- const settlements = [];
-
- // First, check if all original debts exist and are valid
- const group = await Group.findById(groupId);
- if (!group) throw new Error('Group not found');
-
- for (const settlement of simplified.settlements) {
- // Create settlement record
- const newSettlement = new Settlement({
- paidBy: {
- user: settlement.from.id,
- name: settlement.from.name,
- email: settlement.from.email
- },
- paidTo: {
- user: settlement.to.id,
- name: settlement.to.name,
- email: settlement.to.email
- },
- amount: settlement.amount,
- currency: group.currency || 'USD',
- group: groupId,
- status: 'pending',
- notes: `Optimized settlement (reduced from ${original.count} to ${simplified.count} transactions)`
- });
-
- await newSettlement.save();
- settlements.push(newSettlement);
-
- // Broadcast settlement request via Socket.io
- this.broadcastSettlementRequest(settlement, groupId);
- }
-
- return {
- settlements,
- summary: simplified
- };
- }
-
- /**
- * Request a settlement (debtor initiates payment)
- * @param {string} settlementId - Settlement ID
- * @param {string} userId - User requesting
- * @param {Object} paymentDetails - Payment method and reference
- */
- async requestSettlement(settlementId, userId, paymentDetails) {
- const settlement = await Settlement.findById(settlementId);
- if (!settlement) throw new Error('Settlement not found');
-
- // Verify user is the debtor
- if (settlement.paidBy.user.toString() !== userId.toString()) {
- throw new Error('Only the debtor can request settlement');
- }
-
- if (settlement.status !== 'pending') {
- throw new Error('Settlement is not in pending state');
- }
-
- // Update settlement
- settlement.status = 'pending'; // Keep pending until confirmed
- settlement.method = paymentDetails.method || 'cash';
- settlement.transactionId = paymentDetails.reference;
- settlement.notes = (settlement.notes || '') +
- `\n[REQUESTED]: ${new Date().toISOString()} via ${paymentDetails.method}`;
-
- await settlement.save();
-
- // Notify creditor
- await this.notifySettlementRequest(settlement);
-
- // Broadcast via Socket.io
- this.broadcastSettlementUpdate(settlement, 'requested');
-
- return settlement;
- }
-
- /**
- * Confirm a settlement (creditor confirms receipt)
- * @param {string} settlementId - Settlement ID
- * @param {string} userId - User confirming
- */
- async confirmSettlement(settlementId, userId) {
- const settlement = await Settlement.findById(settlementId)
- .populate('group', 'name');
-
- if (!settlement) throw new Error('Settlement not found');
-
- // Verify user is the creditor
- if (settlement.paidTo.user.toString() !== userId.toString()) {
- throw new Error('Only the creditor can confirm settlement');
- }
-
- // Update settlement
- settlement.status = 'verified';
- settlement.verifiedBy = userId;
- settlement.verifiedAt = new Date();
-
- await settlement.save();
-
- // Mark related expense splits as paid (if applicable)
- await this.markRelatedSplitsAsPaid(settlement);
-
- // Notify debtor
- await this.notifySettlementConfirmed(settlement);
-
- // Broadcast via Socket.io
- this.broadcastSettlementUpdate(settlement, 'confirmed');
-
- return settlement;
- }
-
- /**
- * Reject a settlement (creditor rejects)
- * @param {string} settlementId - Settlement ID
- * @param {string} userId - User rejecting
- * @param {string} reason - Rejection reason
- */
- async rejectSettlement(settlementId, userId, reason) {
- const settlement = await Settlement.findById(settlementId);
- if (!settlement) throw new Error('Settlement not found');
-
- // Verify user is the creditor
- if (settlement.paidTo.user.toString() !== userId.toString()) {
- throw new Error('Only the creditor can reject settlement');
- }
-
- // Update settlement
- settlement.status = 'disputed';
- settlement.notes = (settlement.notes || '') + `\n[REJECTED]: ${reason}`;
-
- await settlement.save();
-
- // Notify debtor
- await this.notifySettlementRejected(settlement, reason);
-
- // Broadcast via Socket.io
- this.broadcastSettlementUpdate(settlement, 'rejected');
-
- return settlement;
- }
+ async generateSettlementAdvice(userId, entityA, entityB) {
+ const outbound = await IntercompanyTransaction.find({
+ userId,
+ sourceEntityId: entityA,
+ targetEntityId: entityB,
+ status: { $in: ['Pending', 'Matched'] }
+ });
- /**
- * Get settlement center data for a workspace
- * @param {string} groupId - Group/Workspace ID
- * @param {string} userId - Current user ID
- */
- async getSettlementCenter(groupId, userId) {
- const [simplification, balances, pendingSettlements, recentSettlements] = await Promise.all([
- this.simplifyDebts(groupId),
- this.getMemberBalances(groupId),
- Settlement.find({
- group: groupId,
- status: { $in: ['pending', 'disputed'] }
- }).populate('paidBy.user paidTo.user', 'name email').sort({ createdAt: -1 }),
- Settlement.find({
- group: groupId,
- status: 'verified'
- }).populate('paidBy.user paidTo.user', 'name email').sort({ verifiedAt: -1 }).limit(10)
- ]);
+ const inbound = await IntercompanyTransaction.find({
+ userId,
+ sourceEntityId: entityB,
+ targetEntityId: entityA,
+ status: { $in: ['Pending', 'Matched'] }
+ });
- // Get user's position
- const userBalance = balances.find(b => b.user.id === userId.toString());
+ const totalOut = outbound.reduce((sum, t) => sum + t.amount, 0);
+ const totalIn = inbound.reduce((sum, t) => sum + t.amount, 0);
return {
- simplification,
- balances,
- userBalance: userBalance || { balance: 0, status: 'settled' },
- pendingSettlements,
- recentSettlements,
+ settlementPair: [entityA, entityB],
summary: {
- totalPending: pendingSettlements.reduce((sum, s) => sum + s.amount, 0),
- pendingCount: pendingSettlements.length,
- verifiedCount: recentSettlements.length
- }
+ totalOutbound: totalOut,
+ totalInbound: totalIn,
+ netPayable: totalOut - totalIn
+ },
+ eligibleTransactions: [...outbound, ...inbound].map(t => t._id)
};
}
- /**
- * Mark related expense splits as paid after settlement
- */
- async markRelatedSplitsAsPaid(settlement) {
- // Find splits where debtor owes creditor in this group
- const splits = await ExpenseSplit.find({
- group: settlement.group,
- createdBy: settlement.paidTo.user,
- 'participants.user': settlement.paidBy.user,
- 'participants.isPaid': false
- });
-
- let remainingAmount = settlement.amount;
-
- for (const split of splits) {
- if (remainingAmount <= 0) break;
-
- const participant = split.participants.find(
- p => p.user.toString() === settlement.paidBy.user.toString() && !p.isPaid
- );
-
- if (participant && participant.amount <= remainingAmount) {
- participant.isPaid = true;
- participant.paidAt = new Date();
- remainingAmount -= participant.amount;
-
- // Update split status
- const allPaid = split.participants.every(p => p.isPaid);
- split.status = allPaid ? 'completed' : 'partial';
-
- await split.save();
- }
- }
- }
-
- /**
- * Broadcast settlement request via Socket.io
- */
- broadcastSettlementRequest(settlement, groupId) {
- if (!this.io) return;
-
- // Notify creditor
- this.io.to(`user_${settlement.to.id}`).emit('settlement_request', {
- type: 'new_settlement',
- settlement: {
- from: settlement.from,
- to: settlement.to,
- amount: settlement.amount,
- groupId
+ async processSettlement(userId, txnIds) {
+ const result = await IntercompanyTransaction.updateMany(
+ { _id: { $in: txnIds }, userId },
+ {
+ $set: { status: 'Settled' },
+ $push: { auditTrail: { action: 'Settled via automated processing', performedBy: 'System' } }
}
- });
-
- // Broadcast to group room
- this.io.to(`group_${groupId}`).emit('settlement_update', {
- type: 'new_optimized_settlement',
- groupId,
- count: 1
- });
- }
-
- /**
- * Broadcast settlement status update
- */
- broadcastSettlementUpdate(settlement, action) {
- if (!this.io) return;
-
- const payload = {
- type: `settlement_${action}`,
- settlementId: settlement._id,
- from: settlement.paidBy,
- to: settlement.paidTo,
- amount: settlement.amount,
- status: settlement.status
- };
-
- // Notify both parties
- this.io.to(`user_${settlement.paidBy.user}`).emit('settlement_update', payload);
- this.io.to(`user_${settlement.paidTo.user}`).emit('settlement_update', payload);
-
- // Broadcast to group
- if (settlement.group) {
- this.io.to(`group_${settlement.group}`).emit('settlement_update', payload);
- }
- }
-
- /**
- * Notification helpers
- */
- async notifySettlementRequest(settlement) {
- try {
- await notificationService.sendNotification(settlement.paidTo.user, {
- title: 'Settlement Request',
- message: `${settlement.paidBy.name} has marked a payment of ${settlement.currency} ${settlement.amount} as sent`,
- type: 'settlement_request',
- priority: 'high',
- data: { settlementId: settlement._id, amount: settlement.amount }
- });
- } catch (error) {
- console.error('Failed to send settlement request notification:', error);
- }
- }
+ );
- async notifySettlementConfirmed(settlement) {
- try {
- await notificationService.sendNotification(settlement.paidBy.user, {
- title: 'Payment Confirmed',
- message: `${settlement.paidTo.name} has confirmed receipt of ${settlement.currency} ${settlement.amount}`,
- type: 'settlement_confirmed',
- priority: 'medium',
- data: { settlementId: settlement._id, amount: settlement.amount }
- });
- } catch (error) {
- console.error('Failed to send settlement confirmed notification:', error);
- }
+ return result;
}
- async notifySettlementRejected(settlement, reason) {
- try {
- await notificationService.sendNotification(settlement.paidBy.user, {
- title: 'Payment Rejected',
- message: `${settlement.paidTo.name} has rejected your payment: ${reason}`,
- type: 'settlement_rejected',
- priority: 'high',
- data: { settlementId: settlement._id, reason }
- });
- } catch (error) {
- console.error('Failed to send settlement rejected notification:', error);
- }
+ async getIntercompanyHistory(userId) {
+ return await IntercompanyTransaction.find({ userId })
+ .populate('sourceEntityId', 'name')
+ .populate('targetEntityId', 'name')
+ .sort({ transactionDate: -1 });
}
}