samoulla-backend / controllers /financeController.js
Samoulla Sync Bot
Auto-deploy Samoulla Backend: b68e45770de26ed39feb4b1c0925e5345eb3a61d
634b9bb
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 });
}
};