Spaces:
Running
Running
| const Order = require('../models/orderModel'); | |
| const Transaction = require('../models/transactionModel'); | |
| const Provider = require('../models/providerModel'); | |
| const Product = require('../models/productModel'); | |
| const Expense = require('../models/expenseModel'); | |
| const Capital = require('../models/capitalModel'); | |
| const { | |
| CapitalAccount, | |
| CapitalTransfer, | |
| } = require('../models/capitalAccountModel'); | |
| const User = require('../models/userModel'); | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // HELPER: Get only external (non-platform) providers | |
| // Excludes the platform owner (Yadawy) by checking: | |
| // 1. Provider's linked user has role admin/superadmin | |
| // 2. Provider has no linked user but storeName matches platform name | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| const PLATFORM_STORE_NAMES = ['samoulla', 'Ψ³Ψ§Ω ΩΩΩΨ§']; | |
| const getExternalProviders = async () => { | |
| // Find user IDs that are admin or superadmin | |
| const adminUsers = await User.find( | |
| { role: { $in: ['admin', 'superadmin'] } }, | |
| '_id', | |
| ); | |
| const adminUserIds = adminUsers.map((u) => u._id.toString()); | |
| // Fetch all providers with their user populated | |
| const allProviders = await Provider.find({}).populate('user', 'role'); | |
| // Filter out platform-owned providers | |
| return allProviders.filter((provider) => { | |
| // Exclude if linked user is admin/superadmin | |
| if (provider.user && adminUserIds.includes(provider.user._id.toString())) { | |
| return false; | |
| } | |
| // Exclude if storeName matches platform names (case-insensitive) | |
| if ( | |
| PLATFORM_STORE_NAMES.includes( | |
| (provider.storeName || '').toLowerCase().trim(), | |
| ) | |
| ) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // HELPER: Calculate financials with optional date range | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| const calculateFinancials = async (startDate, endDate) => { | |
| const query = { orderStatus: 'completed' }; | |
| if (startDate || endDate) { | |
| query.createdAt = {}; | |
| if (startDate) query.createdAt.$gte = new Date(startDate); | |
| if (endDate) query.createdAt.$lte = new Date(endDate); | |
| } | |
| const orders = await Order.find(query).populate({ | |
| path: 'items.product', | |
| populate: { | |
| path: 'provider', | |
| populate: { path: 'user' }, | |
| }, | |
| }); | |
| let totalWebsiteProfit = 0; | |
| let websiteOwnedProfit = 0; | |
| let vendorOwnedProfit = 0; | |
| let totalRevenue = 0; | |
| const vendorMap = {}; | |
| for (const order of orders) { | |
| for (const item of order.items) { | |
| const product = item.product; | |
| if (!product || !product.provider) continue; | |
| const quantity = item.quantity; | |
| const sellingPrice = item.unitPrice; | |
| const costPrice = product.costPrice || 0; | |
| const purchasePrice = product.purchasePrice || 0; | |
| const itemRevenue = sellingPrice * quantity; | |
| const itemWebsiteProfit = (sellingPrice - costPrice) * quantity; | |
| totalRevenue += itemRevenue; | |
| const providerUser = product.provider.user; | |
| const providerStoreName = (product.provider.storeName || '') | |
| .toLowerCase() | |
| .trim(); | |
| let isWebsiteOwned = false; | |
| if ( | |
| (providerUser && | |
| (providerUser.role === 'admin' || | |
| providerUser.role === 'superadmin')) || | |
| // If provider is Samoulla or provider ID matches platform IDs | |
| PLATFORM_STORE_NAMES.includes(providerStoreName) | |
| ) { | |
| isWebsiteOwned = true; | |
| } | |
| totalWebsiteProfit += itemWebsiteProfit; | |
| if (isWebsiteOwned) { | |
| websiteOwnedProfit += itemWebsiteProfit; | |
| } else { | |
| vendorOwnedProfit += itemWebsiteProfit; | |
| const pid = product.provider._id.toString(); | |
| if (!vendorMap[pid]) { | |
| vendorMap[pid] = { | |
| totalDeliveredSales: 0, | |
| totalRevenueOwed: 0, | |
| totalPurchaseCost: 0, | |
| }; | |
| } | |
| vendorMap[pid].totalDeliveredSales += sellingPrice * quantity; | |
| vendorMap[pid].totalRevenueOwed += costPrice * quantity; | |
| vendorMap[pid].totalPurchaseCost += purchasePrice * quantity; | |
| } | |
| } | |
| } | |
| return { | |
| totalWebsiteProfit, | |
| websiteOwnedProfit, | |
| vendorOwnedProfit, | |
| totalRevenue, | |
| vendorMap, | |
| }; | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // ADMIN ENDPOINTS | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/admin/stats | |
| // Admin Financial Overview + Vendors Table | |
| // Supports ?startDate=...&endDate=... | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.getAdminFinancialStats = async (req, res) => { | |
| try { | |
| const { startDate, endDate } = req.query; | |
| const { | |
| totalWebsiteProfit, | |
| websiteOwnedProfit, | |
| vendorOwnedProfit, | |
| totalRevenue, | |
| vendorMap, | |
| } = await calculateFinancials(startDate, endDate); | |
| const allProviders = await getExternalProviders(); | |
| // Get payouts (optionally filtered by date) | |
| const payoutQuery = { type: 'payout' }; | |
| if (startDate || endDate) { | |
| payoutQuery.date = {}; | |
| if (startDate) payoutQuery.date.$gte = new Date(startDate); | |
| if (endDate) payoutQuery.date.$lte = new Date(endDate); | |
| } | |
| const payouts = await Transaction.find(payoutQuery); | |
| const payoutMap = {}; | |
| payouts.forEach((t) => { | |
| const pid = t.provider.toString(); | |
| payoutMap[pid] = (payoutMap[pid] || 0) + t.amount; | |
| }); | |
| const vendorSummary = []; | |
| for (const provider of allProviders) { | |
| const pid = provider._id.toString(); | |
| const stats = vendorMap[pid] || { | |
| totalDeliveredSales: 0, | |
| totalRevenueOwed: 0, | |
| totalPurchaseCost: 0, | |
| }; | |
| const paidAmount = payoutMap[pid] || 0; | |
| const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost; | |
| const platformCommission = | |
| stats.totalDeliveredSales - stats.totalRevenueOwed; | |
| vendorSummary.push({ | |
| provider: { | |
| _id: provider._id, | |
| name: provider.name, | |
| storeName: provider.storeName, | |
| }, | |
| totalSales: stats.totalDeliveredSales, | |
| vendorProfit, | |
| platformCommission, | |
| totalOwed: stats.totalRevenueOwed, | |
| paidAmount, | |
| remainingBalance: stats.totalRevenueOwed - paidAmount, | |
| }); | |
| } | |
| // Get expenses total for the period | |
| const expenseQuery = {}; | |
| if (startDate || endDate) { | |
| expenseQuery.date = {}; | |
| if (startDate) expenseQuery.date.$gte = new Date(startDate); | |
| if (endDate) expenseQuery.date.$lte = new Date(endDate); | |
| } | |
| const expenses = await Expense.find(expenseQuery); | |
| const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0); | |
| // Get capital info | |
| const capital = await Capital.getCapital(); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| totalRevenue, | |
| totalWebsiteProfit, | |
| profitSplit: { | |
| websiteOwned: websiteOwnedProfit, | |
| vendorOwned: vendorOwnedProfit, | |
| }, | |
| totalExpenses, | |
| netProfit: totalWebsiteProfit - totalExpenses, | |
| capital: { | |
| initial: capital.initialCapital, | |
| current: capital.currentCapital, | |
| }, | |
| vendorSummary, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // POST /finance/admin/payout | |
| // Create a payout and log to capital | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.createPayout = async (req, res) => { | |
| try { | |
| const { providerId, amount, note, paymentMethod, referenceId } = req.body; | |
| if (!amount || amount <= 0) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'Amount must be positive' }); | |
| } | |
| const provider = await Provider.findById(providerId); | |
| if (!provider) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Provider not found' }); | |
| } | |
| const transaction = await Transaction.create({ | |
| provider: providerId, | |
| admin: req.user._id, | |
| amount, | |
| type: 'payout', | |
| note, | |
| paymentMethod, | |
| referenceId, | |
| }); | |
| // Decrease capital | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital -= amount; | |
| capital.logs.push({ | |
| type: 'vendor_payout', | |
| amount: -amount, | |
| balanceAfter: capital.currentCapital, | |
| reference: transaction._id, | |
| referenceModel: 'Transaction', | |
| description: | |
| `Payout to ${provider.storeName || provider.name}: ${note || ''}`.trim(), | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| res.status(201).json({ | |
| status: 'success', | |
| data: { | |
| transaction, | |
| capitalAfter: capital.currentCapital, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // CAPITAL MANAGEMENT | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/admin/capital | |
| exports.getCapital = async (req, res) => { | |
| try { | |
| const capital = await Capital.getCapital(); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| initialCapital: capital.initialCapital, | |
| currentCapital: capital.currentCapital, | |
| logsCount: capital.logs.length, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // PUT /finance/admin/capital | |
| // Set/update initial capital | |
| exports.setCapital = async (req, res) => { | |
| try { | |
| const { initialCapital } = req.body; | |
| if (initialCapital === undefined || initialCapital < 0) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: 'initialCapital must be a non-negative number', | |
| }); | |
| } | |
| const capital = await Capital.getCapital(); | |
| const diff = initialCapital - capital.initialCapital; | |
| capital.initialCapital = initialCapital; | |
| capital.currentCapital += diff; | |
| capital.logs.push({ | |
| type: 'initial', | |
| amount: diff, | |
| balanceAfter: capital.currentCapital, | |
| description: `Initial capital set to ${initialCapital}`, | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| initialCapital: capital.initialCapital, | |
| currentCapital: capital.currentCapital, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // POST /finance/admin/capital/adjust | |
| // Manual adjustment (add/subtract) to capital | |
| exports.adjustCapital = async (req, res) => { | |
| try { | |
| const { amount, description } = req.body; | |
| if (amount === undefined || amount === 0) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'Amount must be a non-zero number' }); | |
| } | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital += amount; | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount, | |
| balanceAfter: capital.currentCapital, | |
| description: | |
| description || `Manual adjustment: ${amount > 0 ? '+' : ''}${amount}`, | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| currentCapital: capital.currentCapital, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/capital/timeline | |
| // Returns capital log entries for chart display | |
| exports.getCapitalTimeline = async (req, res) => { | |
| try { | |
| const { startDate, endDate, limit } = req.query; | |
| const capital = await Capital.getCapital(); | |
| let logs = capital.logs || []; | |
| // Filter by date | |
| if (startDate) { | |
| const sd = new Date(startDate); | |
| logs = logs.filter((l) => l.date >= sd); | |
| } | |
| if (endDate) { | |
| const ed = new Date(endDate); | |
| logs = logs.filter((l) => l.date <= ed); | |
| } | |
| // Sort by date ascending for timeline | |
| logs.sort((a, b) => a.date - b.date); | |
| // Limit | |
| if (limit) { | |
| logs = logs.slice(-parseInt(limit, 10)); | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| results: logs.length, | |
| data: { logs }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // EXPENSE MANAGEMENT | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/admin/expenses | |
| exports.getExpenses = async (req, res) => { | |
| try { | |
| const { startDate, endDate, category, page = 1, limit = 50 } = req.query; | |
| const query = {}; | |
| if (startDate || endDate) { | |
| query.date = {}; | |
| if (startDate) query.date.$gte = new Date(startDate); | |
| if (endDate) query.date.$lte = new Date(endDate); | |
| } | |
| if (category) query.category = category; | |
| const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | |
| const [expenses, total] = await Promise.all([ | |
| Expense.find(query) | |
| .sort('-date') | |
| .skip(skip) | |
| .limit(parseInt(limit, 10)) | |
| .populate('createdBy', 'name email'), | |
| Expense.countDocuments(query), | |
| ]); | |
| const totalAmount = await Expense.aggregate([ | |
| { $match: query }, | |
| { $group: { _id: null, total: { $sum: '$amount' } } }, | |
| ]); | |
| // Category breakdown | |
| const categoryBreakdown = await Expense.aggregate([ | |
| { $match: query }, | |
| { | |
| $group: { | |
| _id: '$category', | |
| total: { $sum: '$amount' }, | |
| count: { $sum: 1 }, | |
| }, | |
| }, | |
| { $sort: { total: -1 } }, | |
| ]); | |
| res.status(200).json({ | |
| status: 'success', | |
| results: expenses.length, | |
| totalRecords: total, | |
| data: { | |
| expenses, | |
| totalExpenses: (totalAmount[0] && totalAmount[0].total) || 0, | |
| categoryBreakdown, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // POST /finance/admin/expenses | |
| exports.createExpense = async (req, res) => { | |
| try { | |
| const { category, amount, date, notes } = req.body; | |
| if (!category || !amount) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'category and amount are required' }); | |
| } | |
| const expense = await Expense.create({ | |
| category, | |
| amount, | |
| date: date || new Date(), | |
| notes: notes || '', | |
| createdBy: req.user._id, | |
| }); | |
| // Decrease capital | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital -= amount; | |
| capital.logs.push({ | |
| type: 'expense', | |
| amount: -amount, | |
| balanceAfter: capital.currentCapital, | |
| reference: expense._id, | |
| referenceModel: 'Expense', | |
| description: `Expense (${category}): ${notes || ''}`.trim(), | |
| date: expense.date, | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| res.status(201).json({ | |
| status: 'success', | |
| data: { | |
| expense, | |
| capitalAfter: capital.currentCapital, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // PUT /finance/admin/expenses/:id | |
| exports.updateExpense = async (req, res) => { | |
| try { | |
| const expense = await Expense.findById(req.params.id); | |
| if (!expense) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Expense not found' }); | |
| } | |
| const oldAmount = expense.amount; | |
| // Update fields | |
| if (req.body.category) expense.category = req.body.category; | |
| if (req.body.amount !== undefined) expense.amount = req.body.amount; | |
| if (req.body.date) expense.date = req.body.date; | |
| if (req.body.notes !== undefined) expense.notes = req.body.notes; | |
| await expense.save(); | |
| // Adjust capital for difference | |
| const diff = oldAmount - expense.amount; // positive if expense decreased | |
| if (diff !== 0) { | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital += diff; | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount: diff, | |
| balanceAfter: capital.currentCapital, | |
| reference: expense._id, | |
| referenceModel: 'Expense', | |
| description: `Expense updated (${expense.category}): ${oldAmount} β ${expense.amount}`, | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { expense }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // DELETE /finance/admin/expenses/:id | |
| exports.deleteExpense = async (req, res) => { | |
| try { | |
| const expense = await Expense.findById(req.params.id); | |
| if (!expense) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Expense not found' }); | |
| } | |
| // Restore capital | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital += expense.amount; | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount: expense.amount, | |
| balanceAfter: capital.currentCapital, | |
| description: `Expense deleted (${expense.category}): +${expense.amount} restored`, | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| await Expense.findByIdAndDelete(req.params.id); | |
| res.status(200).json({ | |
| status: 'success', | |
| message: 'Expense deleted and capital restored', | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // ANALYTICS (Admin) | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/admin/analytics/monthly-profit | |
| // Returns monthly profit data for charts | |
| exports.getMonthlyProfit = async (req, res) => { | |
| try { | |
| const { year } = req.query; | |
| const targetYear = parseInt(year, 10) || new Date().getFullYear(); | |
| const startOfYear = new Date(targetYear, 0, 1); | |
| const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59); | |
| // Aggregate completed orders by month | |
| const monthlyData = await Order.aggregate([ | |
| { | |
| $match: { | |
| orderStatus: 'completed', | |
| createdAt: { $gte: startOfYear, $lte: endOfYear }, | |
| }, | |
| }, | |
| { $unwind: '$items' }, | |
| { | |
| $lookup: { | |
| from: 'products', | |
| localField: 'items.product', | |
| foreignField: '_id', | |
| as: 'productInfo', | |
| }, | |
| }, | |
| { $unwind: { path: '$productInfo', preserveNullAndEmptyArrays: true } }, | |
| { | |
| $group: { | |
| _id: { $month: '$createdAt' }, | |
| totalRevenue: { | |
| $sum: { $multiply: ['$items.unitPrice', '$items.quantity'] }, | |
| }, | |
| totalCost: { | |
| $sum: { | |
| $multiply: [ | |
| { $ifNull: ['$productInfo.costPrice', 0] }, | |
| '$items.quantity', | |
| ], | |
| }, | |
| }, | |
| orderCount: { $sum: 1 }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]); | |
| // Get monthly expenses | |
| const monthlyExpenses = await Expense.aggregate([ | |
| { | |
| $match: { | |
| date: { $gte: startOfYear, $lte: endOfYear }, | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { $month: '$date' }, | |
| totalExpenses: { $sum: '$amount' }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]); | |
| const expenseMap = {}; | |
| monthlyExpenses.forEach((e) => { | |
| expenseMap[e._id] = e.totalExpenses; | |
| }); | |
| // Build full 12-month response | |
| const months = []; | |
| for (let m = 1; m <= 12; m++) { | |
| const data = monthlyData.find((d) => d._id === m); | |
| const revenue = data ? data.totalRevenue : 0; | |
| const cost = data ? data.totalCost : 0; | |
| const profit = revenue - cost; | |
| const expenses = expenseMap[m] || 0; | |
| months.push({ | |
| month: m, | |
| revenue, | |
| profit, | |
| expenses, | |
| netProfit: profit - expenses, | |
| orderCount: data ? data.orderCount : 0, | |
| }); | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { year: targetYear, months }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/analytics/vendor-comparison | |
| exports.getVendorComparison = async (req, res) => { | |
| try { | |
| const { startDate, endDate } = req.query; | |
| const { vendorMap } = await calculateFinancials(startDate, endDate); | |
| const allProviders = await getExternalProviders(); | |
| const comparison = []; | |
| for (const provider of allProviders) { | |
| const pid = provider._id.toString(); | |
| const stats = vendorMap[pid] || { | |
| totalDeliveredSales: 0, | |
| totalRevenueOwed: 0, | |
| totalPurchaseCost: 0, | |
| }; | |
| const platformCommission = | |
| stats.totalDeliveredSales - stats.totalRevenueOwed; | |
| const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost; | |
| comparison.push({ | |
| provider: { | |
| _id: provider._id, | |
| name: provider.name, | |
| storeName: provider.storeName, | |
| }, | |
| totalSales: stats.totalDeliveredSales, | |
| vendorProfit, | |
| platformCommission, | |
| }); | |
| } | |
| // Sort by total sales descending | |
| comparison.sort((a, b) => b.totalSales - a.totalSales); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { comparison }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/analytics/revenue-vs-expenses | |
| exports.getRevenueVsExpenses = async (req, res) => { | |
| try { | |
| const { year } = req.query; | |
| const targetYear = parseInt(year, 10) || new Date().getFullYear(); | |
| const startOfYear = new Date(targetYear, 0, 1); | |
| const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59); | |
| // Monthly revenue from completed orders | |
| const monthlyRevenue = await Order.aggregate([ | |
| { | |
| $match: { | |
| orderStatus: 'completed', | |
| createdAt: { $gte: startOfYear, $lte: endOfYear }, | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { $month: '$createdAt' }, | |
| revenue: { $sum: '$totalPrice' }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]); | |
| // Monthly expenses | |
| const monthlyExpenses = await Expense.aggregate([ | |
| { | |
| $match: { | |
| date: { $gte: startOfYear, $lte: endOfYear }, | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { $month: '$date' }, | |
| expenses: { $sum: '$amount' }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]); | |
| // Monthly vendor payouts | |
| const monthlyPayouts = await Transaction.aggregate([ | |
| { | |
| $match: { | |
| type: 'payout', | |
| date: { $gte: startOfYear, $lte: endOfYear }, | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { $month: '$date' }, | |
| payouts: { $sum: '$amount' }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]); | |
| const revenueMap = {}; | |
| monthlyRevenue.forEach((r) => { | |
| revenueMap[r._id] = r.revenue; | |
| }); | |
| const expenseMap = {}; | |
| monthlyExpenses.forEach((e) => { | |
| expenseMap[e._id] = e.expenses; | |
| }); | |
| const payoutMap = {}; | |
| monthlyPayouts.forEach((p) => { | |
| payoutMap[p._id] = p.payouts; | |
| }); | |
| const months = []; | |
| for (let m = 1; m <= 12; m++) { | |
| months.push({ | |
| month: m, | |
| revenue: revenueMap[m] || 0, | |
| expenses: expenseMap[m] || 0, | |
| vendorPayouts: payoutMap[m] || 0, | |
| totalOutflow: (expenseMap[m] || 0) + (payoutMap[m] || 0), | |
| }); | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { year: targetYear, months }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/summary | |
| // Daily / Weekly / Monthly summary | |
| exports.getFinancialSummary = async (req, res) => { | |
| try { | |
| const { period } = req.query; // 'daily', 'weekly', 'monthly' | |
| const now = new Date(); | |
| let startDate; | |
| switch (period) { | |
| case 'daily': | |
| startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| break; | |
| case 'weekly': | |
| startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| break; | |
| case 'monthly': | |
| startDate = new Date(now.getFullYear(), now.getMonth(), 1); | |
| break; | |
| default: | |
| startDate = new Date(now.getFullYear(), now.getMonth(), 1); // default monthly | |
| } | |
| const { totalWebsiteProfit, totalRevenue, vendorMap } = | |
| await calculateFinancials(startDate, now); | |
| // Expenses in period | |
| const expenses = await Expense.aggregate([ | |
| { $match: { date: { $gte: startDate, $lte: now } } }, | |
| { $group: { _id: null, total: { $sum: '$amount' } } }, | |
| ]); | |
| const totalExpenses = (expenses[0] && expenses[0].total) || 0; | |
| // Payouts in period | |
| const payouts = await Transaction.aggregate([ | |
| { $match: { type: 'payout', date: { $gte: startDate, $lte: now } } }, | |
| { $group: { _id: null, total: { $sum: '$amount' } } }, | |
| ]); | |
| const totalPayouts = (payouts[0] && payouts[0].total) || 0; | |
| // Order count | |
| const orderCount = await Order.countDocuments({ | |
| orderStatus: 'completed', | |
| createdAt: { $gte: startDate, $lte: now }, | |
| }); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| period: period || 'monthly', | |
| startDate, | |
| endDate: now, | |
| totalRevenue, | |
| totalProfit: totalWebsiteProfit, | |
| totalExpenses, | |
| totalPayouts, | |
| netProfit: totalWebsiteProfit - totalExpenses, | |
| orderCount, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/export | |
| // Export financial report as Excel | |
| exports.exportFinancialReport = async (req, res) => { | |
| try { | |
| const XLSX = require('xlsx'); | |
| const { startDate, endDate } = req.query; | |
| const { | |
| totalWebsiteProfit, | |
| websiteOwnedProfit, | |
| vendorOwnedProfit, | |
| totalRevenue, | |
| vendorMap, | |
| } = await calculateFinancials(startDate, endDate); | |
| const allProviders = await getExternalProviders(); | |
| // Build vendor summary data | |
| const payoutQuery = { type: 'payout' }; | |
| if (startDate || endDate) { | |
| payoutQuery.date = {}; | |
| if (startDate) payoutQuery.date.$gte = new Date(startDate); | |
| if (endDate) payoutQuery.date.$lte = new Date(endDate); | |
| } | |
| const payouts = await Transaction.find(payoutQuery); | |
| const payoutMap = {}; | |
| payouts.forEach((t) => { | |
| const pid = t.provider.toString(); | |
| payoutMap[pid] = (payoutMap[pid] || 0) + t.amount; | |
| }); | |
| // Sheet 1: Overview | |
| const overviewData = [ | |
| ['Financial Report'], | |
| ['Period', startDate || 'All Time', endDate || 'Present'], | |
| [], | |
| ['Total Revenue', totalRevenue], | |
| ['Total Platform Profit', totalWebsiteProfit], | |
| ['Website-Owned Profit', websiteOwnedProfit], | |
| ['Vendor-Owned Profit (Commission)', vendorOwnedProfit], | |
| ]; | |
| // Sheet 2: Vendor Details | |
| const vendorHeaders = [ | |
| 'Vendor Name', | |
| 'Store Name', | |
| 'Total Sales', | |
| 'Vendor Profit', | |
| 'Platform Commission', | |
| 'Total Owed', | |
| 'Paid Amount', | |
| 'Remaining Balance', | |
| ]; | |
| const vendorRows = [vendorHeaders]; | |
| for (const provider of allProviders) { | |
| const pid = provider._id.toString(); | |
| const stats = vendorMap[pid] || { | |
| totalDeliveredSales: 0, | |
| totalRevenueOwed: 0, | |
| totalPurchaseCost: 0, | |
| }; | |
| const paidAmount = payoutMap[pid] || 0; | |
| const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost; | |
| const platformCommission = | |
| stats.totalDeliveredSales - stats.totalRevenueOwed; | |
| vendorRows.push([ | |
| provider.name, | |
| provider.storeName, | |
| stats.totalDeliveredSales, | |
| vendorProfit, | |
| platformCommission, | |
| stats.totalRevenueOwed, | |
| paidAmount, | |
| stats.totalRevenueOwed - paidAmount, | |
| ]); | |
| } | |
| // Sheet 3: Expenses | |
| const expenseQuery = {}; | |
| if (startDate || endDate) { | |
| expenseQuery.date = {}; | |
| if (startDate) expenseQuery.date.$gte = new Date(startDate); | |
| if (endDate) expenseQuery.date.$lte = new Date(endDate); | |
| } | |
| const expensesList = await Expense.find(expenseQuery) | |
| .sort('-date') | |
| .populate('createdBy', 'name'); | |
| const expenseHeaders = [ | |
| 'Category', | |
| 'Amount', | |
| 'Date', | |
| 'Notes', | |
| 'Created By', | |
| ]; | |
| const expenseRows = [expenseHeaders]; | |
| for (const exp of expensesList) { | |
| expenseRows.push([ | |
| exp.category, | |
| exp.amount, | |
| exp.date ? exp.date.toISOString().split('T')[0] : '', | |
| exp.notes, | |
| exp.createdBy ? exp.createdBy.name : '', | |
| ]); | |
| } | |
| // Build workbook | |
| const wb = XLSX.utils.book_new(); | |
| const ws1 = XLSX.utils.aoa_to_sheet(overviewData); | |
| const ws2 = XLSX.utils.aoa_to_sheet(vendorRows); | |
| const ws3 = XLSX.utils.aoa_to_sheet(expenseRows); | |
| XLSX.utils.book_append_sheet(wb, ws1, 'Overview'); | |
| XLSX.utils.book_append_sheet(wb, ws2, 'Vendors'); | |
| XLSX.utils.book_append_sheet(wb, ws3, 'Expenses'); | |
| const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); | |
| res.setHeader( | |
| 'Content-Type', | |
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| ); | |
| res.setHeader( | |
| 'Content-Disposition', | |
| 'attachment; filename=financial_report.xlsx', | |
| ); | |
| res.send(buffer); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // VENDOR ENDPOINTS | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/vendor/stats | |
| // Vendor Profit Overview | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.getVendorMyStats = async (req, res) => { | |
| try { | |
| if (!req.user.provider) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'User is not linked to a provider' }); | |
| } | |
| const myProviderId = req.user.provider.toString(); | |
| const { startDate, endDate } = req.query; | |
| const { vendorMap } = await calculateFinancials(startDate, endDate); | |
| const myStats = vendorMap[myProviderId] || { | |
| totalDeliveredSales: 0, | |
| totalRevenueOwed: 0, | |
| totalPurchaseCost: 0, | |
| }; | |
| const totalProfit = myStats.totalRevenueOwed - myStats.totalPurchaseCost; | |
| const platformCommission = | |
| myStats.totalDeliveredSales - myStats.totalRevenueOwed; | |
| // Get payouts | |
| const payoutQuery = { provider: myProviderId, type: 'payout' }; | |
| if (startDate || endDate) { | |
| payoutQuery.date = {}; | |
| if (startDate) payoutQuery.date.$gte = new Date(startDate); | |
| if (endDate) payoutQuery.date.$lte = new Date(endDate); | |
| } | |
| const payouts = await Transaction.find(payoutQuery).sort('-date'); | |
| const paidAmount = payouts.reduce((sum, t) => sum + t.amount, 0); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| totalSales: myStats.totalDeliveredSales, | |
| totalRevenueOwed: myStats.totalRevenueOwed, | |
| totalProfit, | |
| platformCommission, | |
| paidAmount, | |
| pendingAmount: myStats.totalRevenueOwed - paidAmount, | |
| remainingBalance: myStats.totalRevenueOwed - paidAmount, | |
| transactions: payouts, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/vendor/orders | |
| // Vendor Orders Financial Breakdown | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.getVendorOrderBreakdown = async (req, res) => { | |
| try { | |
| if (!req.user.provider) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'User is not linked to a provider' }); | |
| } | |
| const myProviderId = req.user.provider.toString(); | |
| const { startDate, endDate, page = 1, limit = 20 } = req.query; | |
| // Find completed orders that contain products from this vendor's provider | |
| const matchStage = { orderStatus: 'completed' }; | |
| if (startDate || endDate) { | |
| matchStage.createdAt = {}; | |
| if (startDate) matchStage.createdAt.$gte = new Date(startDate); | |
| if (endDate) matchStage.createdAt.$lte = new Date(endDate); | |
| } | |
| const orders = await Order.find(matchStage) | |
| .populate({ | |
| path: 'items.product', | |
| populate: { path: 'provider' }, | |
| }) | |
| .sort('-createdAt'); | |
| // Filter and compute per-order financials for THIS vendor | |
| const orderBreakdown = []; | |
| for (const order of orders) { | |
| let orderVendorSales = 0; | |
| let orderVendorCost = 0; | |
| let orderVendorProfit = 0; | |
| let orderCommission = 0; | |
| let hasVendorItems = false; | |
| const vendorItems = []; | |
| for (const item of order.items) { | |
| if (!item.product || !item.product.provider) continue; | |
| if (item.product.provider._id.toString() !== myProviderId) continue; | |
| hasVendorItems = true; | |
| const qty = item.quantity; | |
| const sellingPrice = item.unitPrice; | |
| const costPrice = item.product.costPrice || 0; | |
| const purchasePrice = item.product.purchasePrice || 0; | |
| const itemSales = sellingPrice * qty; | |
| const itemOwed = costPrice * qty; | |
| const itemProfit = (costPrice - purchasePrice) * qty; | |
| const itemCommission = (sellingPrice - costPrice) * qty; | |
| orderVendorSales += itemSales; | |
| orderVendorCost += itemOwed; | |
| orderVendorProfit += itemProfit; | |
| orderCommission += itemCommission; | |
| vendorItems.push({ | |
| productId: item.product._id, | |
| productName: item.product.nameAr || item.product.nameEn, | |
| quantity: qty, | |
| sellingPrice, | |
| costPrice, | |
| profitPerUnit: costPrice - purchasePrice, | |
| totalProfit: itemProfit, | |
| commission: itemCommission, | |
| }); | |
| } | |
| if (hasVendorItems) { | |
| orderBreakdown.push({ | |
| orderId: order._id, | |
| orderDate: order.createdAt, | |
| totalSales: orderVendorSales, | |
| vendorRevenue: orderVendorCost, | |
| vendorProfit: orderVendorProfit, | |
| platformCommission: orderCommission, | |
| items: vendorItems, | |
| }); | |
| } | |
| } | |
| // Paginate | |
| const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | |
| const paginatedOrders = orderBreakdown.slice( | |
| skip, | |
| skip + parseInt(limit, 10), | |
| ); | |
| res.status(200).json({ | |
| status: 'success', | |
| results: paginatedOrders.length, | |
| totalRecords: orderBreakdown.length, | |
| data: { orders: paginatedOrders }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/vendor/payments | |
| // Vendor Payment History | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.getVendorPaymentHistory = async (req, res) => { | |
| try { | |
| if (!req.user.provider) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'User is not linked to a provider' }); | |
| } | |
| const myProviderId = req.user.provider.toString(); | |
| const { startDate, endDate, page = 1, limit = 20 } = req.query; | |
| const query = { provider: myProviderId, type: 'payout' }; | |
| if (startDate || endDate) { | |
| query.date = {}; | |
| if (startDate) query.date.$gte = new Date(startDate); | |
| if (endDate) query.date.$lte = new Date(endDate); | |
| } | |
| const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | |
| const [payments, total] = await Promise.all([ | |
| Transaction.find(query) | |
| .sort('-date') | |
| .skip(skip) | |
| .limit(parseInt(limit, 10)) | |
| .populate('admin', 'name email'), | |
| Transaction.countDocuments(query), | |
| ]); | |
| const totalPaid = await Transaction.aggregate([ | |
| { $match: query }, | |
| { $group: { _id: null, total: { $sum: '$amount' } } }, | |
| ]); | |
| res.status(200).json({ | |
| status: 'success', | |
| results: payments.length, | |
| totalRecords: total, | |
| data: { | |
| payments, | |
| totalPaid: (totalPaid[0] && totalPaid[0].total) || 0, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/vendor/analytics | |
| // Vendor profit analytics (daily/monthly) | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| exports.getVendorAnalytics = async (req, res) => { | |
| try { | |
| if (!req.user.provider) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'User is not linked to a provider' }); | |
| } | |
| const myProviderId = req.user.provider.toString(); | |
| const { year, granularity } = req.query; // granularity: 'daily' or 'monthly' | |
| const targetYear = parseInt(year, 10) || new Date().getFullYear(); | |
| const startOfYear = new Date(targetYear, 0, 1); | |
| const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59); | |
| // Get completed orders within the year | |
| const orders = await Order.find({ | |
| orderStatus: 'completed', | |
| createdAt: { $gte: startOfYear, $lte: endOfYear }, | |
| }).populate({ | |
| path: 'items.product', | |
| populate: { path: 'provider' }, | |
| }); | |
| // Group by time period | |
| const dataMap = {}; | |
| for (const order of orders) { | |
| for (const item of order.items) { | |
| if (!item.product || !item.product.provider) continue; | |
| if (item.product.provider._id.toString() !== myProviderId) continue; | |
| const date = new Date(order.createdAt); | |
| let key; | |
| if (granularity === 'daily') { | |
| key = date.toISOString().split('T')[0]; // YYYY-MM-DD | |
| } else { | |
| key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM | |
| } | |
| if (!dataMap[key]) { | |
| dataMap[key] = { sales: 0, revenue: 0, profit: 0, orders: new Set() }; | |
| } | |
| const qty = item.quantity; | |
| const sellingPrice = item.unitPrice; | |
| const costPrice = item.product.costPrice || 0; | |
| const purchasePrice = item.product.purchasePrice || 0; | |
| dataMap[key].sales += sellingPrice * qty; | |
| dataMap[key].revenue += costPrice * qty; | |
| dataMap[key].profit += (costPrice - purchasePrice) * qty; | |
| dataMap[key].orders.add(order._id.toString()); | |
| } | |
| } | |
| // Convert to array | |
| const analytics = Object.entries(dataMap) | |
| .map(([period, data]) => ({ | |
| period, | |
| sales: data.sales, | |
| revenue: data.revenue, | |
| profit: data.profit, | |
| orderCount: data.orders.size, | |
| })) | |
| .sort((a, b) => a.period.localeCompare(b.period)); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| year: targetYear, | |
| granularity: granularity || 'monthly', | |
| analytics, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // CAPITAL ACCOUNTS (Where is the money?) | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /finance/admin/accounts | |
| // List all capital accounts with balances | |
| exports.getCapitalAccounts = async (req, res) => { | |
| try { | |
| const { includeInactive } = req.query; | |
| const query = includeInactive === 'true' ? {} : { isActive: true }; | |
| const accounts = await CapitalAccount.find(query).sort('type name'); | |
| // Totals by type | |
| const totals = { | |
| cash: 0, | |
| bank: 0, | |
| e_wallet: 0, | |
| other: 0, | |
| grand: 0, | |
| }; | |
| for (const acc of accounts) { | |
| if (!acc.isActive) continue; | |
| totals[acc.type] = (totals[acc.type] || 0) + acc.balance; | |
| totals.grand += acc.balance; | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| results: accounts.length, | |
| data: { | |
| accounts, | |
| totals, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // POST /finance/admin/accounts | |
| // Create a new capital account | |
| exports.createCapitalAccount = async (req, res) => { | |
| try { | |
| const { name, type, bankName, accountNumber, balance, notes } = req.body; | |
| if (!name || !type) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'name and type are required' }); | |
| } | |
| const account = await CapitalAccount.create({ | |
| name, | |
| type, | |
| bankName: bankName || '', | |
| accountNumber: accountNumber || '', | |
| balance: balance || 0, | |
| notes: notes || '', | |
| }); | |
| // If initial balance > 0, log it in capital | |
| if (balance && balance > 0) { | |
| const capital = await Capital.getCapital(); | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount: balance, | |
| balanceAfter: capital.currentCapital, | |
| description: `Initial balance for new account "${name}" (${type})`, | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| } | |
| res.status(201).json({ | |
| status: 'success', | |
| data: { account }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // PUT /finance/admin/accounts/:id | |
| // Update a capital account (name, type, bankName, notes, isActive) | |
| exports.updateCapitalAccount = async (req, res) => { | |
| try { | |
| const account = await CapitalAccount.findById(req.params.id); | |
| if (!account) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Account not found' }); | |
| } | |
| const allowedFields = [ | |
| 'name', | |
| 'type', | |
| 'bankName', | |
| 'accountNumber', | |
| 'notes', | |
| 'isActive', | |
| ]; | |
| for (const field of allowedFields) { | |
| if (req.body[field] !== undefined) { | |
| account[field] = req.body[field]; | |
| } | |
| } | |
| await account.save(); | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { account }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // DELETE /finance/admin/accounts/:id | |
| // Soft-delete (deactivate) an account β only if balance is 0 | |
| exports.deleteCapitalAccount = async (req, res) => { | |
| try { | |
| const account = await CapitalAccount.findById(req.params.id); | |
| if (!account) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Account not found' }); | |
| } | |
| if (account.balance !== 0) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: `Cannot delete account with non-zero balance (${account.balance}). Transfer the balance first.`, | |
| }); | |
| } | |
| account.isActive = false; | |
| await account.save(); | |
| res.status(200).json({ | |
| status: 'success', | |
| message: 'Account deactivated successfully', | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // PUT /finance/admin/accounts/:id/balance | |
| // Manually set/correct the balance of an account (e.g. after physical count) | |
| exports.setAccountBalance = async (req, res) => { | |
| try { | |
| const { balance, note } = req.body; | |
| if (balance === undefined || balance < 0) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: 'balance must be a non-negative number', | |
| }); | |
| } | |
| const account = await CapitalAccount.findById(req.params.id); | |
| if (!account) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Account not found' }); | |
| } | |
| const oldBalance = account.balance; | |
| const diff = balance - oldBalance; | |
| account.balance = balance; | |
| await account.save(); | |
| // Log the adjustment in capital | |
| if (diff !== 0) { | |
| const capital = await Capital.getCapital(); | |
| capital.currentCapital += diff; | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount: diff, | |
| balanceAfter: capital.currentCapital, | |
| description: | |
| `Account "${account.name}" balance corrected: ${oldBalance} β ${balance}. ${note || ''}`.trim(), | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| } | |
| res.status(200).json({ | |
| status: 'success', | |
| data: { | |
| account, | |
| adjustment: diff, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // CAPITAL TRANSFERS (Move money between accounts) | |
| // βββββββββββββββββββββββββββββββββββββββββββββ | |
| // POST /finance/admin/transfers | |
| // Transfer money from one account to another | |
| exports.createTransfer = async (req, res) => { | |
| try { | |
| const { fromAccountId, toAccountId, amount, note } = req.body; | |
| if (!fromAccountId || !toAccountId) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: 'fromAccountId and toAccountId are required', | |
| }); | |
| } | |
| if (fromAccountId === toAccountId) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: 'Cannot transfer to the same account', | |
| }); | |
| } | |
| if (!amount || amount <= 0) { | |
| return res | |
| .status(400) | |
| .json({ status: 'fail', message: 'Amount must be positive' }); | |
| } | |
| const fromAccount = await CapitalAccount.findById(fromAccountId); | |
| const toAccount = await CapitalAccount.findById(toAccountId); | |
| if (!fromAccount) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Source account not found' }); | |
| } | |
| if (!toAccount) { | |
| return res | |
| .status(404) | |
| .json({ status: 'fail', message: 'Destination account not found' }); | |
| } | |
| if (!fromAccount.isActive || !toAccount.isActive) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: 'Cannot transfer to/from a deactivated account', | |
| }); | |
| } | |
| if (fromAccount.balance < amount) { | |
| return res.status(400).json({ | |
| status: 'fail', | |
| message: `Insufficient balance in "${fromAccount.name}". Available: ${fromAccount.balance}, Requested: ${amount}`, | |
| }); | |
| } | |
| // Perform the transfer | |
| fromAccount.balance -= amount; | |
| toAccount.balance += amount; | |
| await fromAccount.save(); | |
| await toAccount.save(); | |
| // Record the transfer | |
| const transfer = await CapitalTransfer.create({ | |
| fromAccount: fromAccountId, | |
| toAccount: toAccountId, | |
| amount, | |
| note: note || '', | |
| createdBy: req.user._id, | |
| }); | |
| // Log in capital timeline | |
| const capital = await Capital.getCapital(); | |
| capital.logs.push({ | |
| type: 'adjustment', | |
| amount: 0, // net effect on total capital is zero | |
| balanceAfter: capital.currentCapital, | |
| description: | |
| `Transfer: ${amount} from "${fromAccount.name}" β "${toAccount.name}". ${note || ''}`.trim(), | |
| date: new Date(), | |
| createdBy: req.user._id, | |
| }); | |
| await capital.save(); | |
| res.status(201).json({ | |
| status: 'success', | |
| data: { | |
| transfer, | |
| fromAccount: { | |
| _id: fromAccount._id, | |
| name: fromAccount.name, | |
| balance: fromAccount.balance, | |
| }, | |
| toAccount: { | |
| _id: toAccount._id, | |
| name: toAccount.name, | |
| balance: toAccount.balance, | |
| }, | |
| }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |
| // GET /finance/admin/transfers | |
| // Get transfer history | |
| exports.getTransfers = async (req, res) => { | |
| try { | |
| const { startDate, endDate, accountId, page = 1, limit = 30 } = req.query; | |
| const query = {}; | |
| if (startDate || endDate) { | |
| query.date = {}; | |
| if (startDate) query.date.$gte = new Date(startDate); | |
| if (endDate) query.date.$lte = new Date(endDate); | |
| } | |
| // Filter by specific account (either source or destination) | |
| if (accountId) { | |
| query.$or = [{ fromAccount: accountId }, { toAccount: accountId }]; | |
| } | |
| const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | |
| const [transfers, total] = await Promise.all([ | |
| CapitalTransfer.find(query) | |
| .sort('-date') | |
| .skip(skip) | |
| .limit(parseInt(limit, 10)) | |
| .populate('fromAccount', 'name type bankName') | |
| .populate('toAccount', 'name type bankName') | |
| .populate('createdBy', 'name email'), | |
| CapitalTransfer.countDocuments(query), | |
| ]); | |
| res.status(200).json({ | |
| status: 'success', | |
| results: transfers.length, | |
| totalRecords: total, | |
| data: { transfers }, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ status: 'error', message: err.message }); | |
| } | |
| }; | |