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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions controllers/analytics.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,16 @@ exports.getDailyReport = async (req, res) => {
{ $group: { _id: null, total: { $sum: '$commissionEarned' } } }
]);

const completedCountD = rides.filter(r => r.status === 'completed').length;
const avgFareD = completedCountD > 0 ? totalRevenue / completedCountD : 0;
report = await DailyReport.create({
date: targetDate,
totalRides: rides.length,
totalRevenue,
totalCommission: totalCommission[0]?.total || 0,
completedRides: rides.filter(r => r.status === 'completed').length,
completedRides: completedCountD,
canceledRides: rides.filter(r => r.status === 'canceled').length,
averageFare: rides.length > 0 ? totalRevenue / rides.filter(r => r.status === 'completed').length : 0,
averageFare: Number.isFinite(avgFareD) ? avgFareD : 0,
rideDetails: rides.map(r => ({
bookingId: r._id,
driverId: r.driverId,
Expand Down Expand Up @@ -164,15 +166,17 @@ exports.getWeeklyReport = async (req, res) => {
{ $group: { _id: null, total: { $sum: '$commissionEarned' } } }
]);

const completedCount = rides.filter(r => r.status === 'completed').length;
const avgFare = completedCount > 0 ? totalRevenue / completedCount : 0;
report = await WeeklyReport.create({
weekStart: startDate,
weekEnd: endDate,
totalRides: rides.length,
totalRevenue,
totalCommission: totalCommission[0]?.total || 0,
completedRides: rides.filter(r => r.status === 'completed').length,
completedRides: completedCount,
canceledRides: rides.filter(r => r.status === 'canceled').length,
averageFare: rides.length > 0 ? totalRevenue / rides.filter(r => r.status === 'completed').length : 0
averageFare: Number.isFinite(avgFare) ? avgFare : 0
});
}

Expand Down Expand Up @@ -207,15 +211,17 @@ exports.getMonthlyReport = async (req, res) => {
{ $group: { _id: null, total: { $sum: '$commissionEarned' } } }
]);

const completedCountM = rides.filter(r => r.status === 'completed').length;
const avgFareM = completedCountM > 0 ? totalRevenue / completedCountM : 0;
report = await MonthlyReport.create({
month: targetMonth,
year: targetYear,
totalRides: rides.length,
totalRevenue,
totalCommission: totalCommission[0]?.total || 0,
completedRides: rides.filter(r => r.status === 'completed').length,
completedRides: completedCountM,
canceledRides: rides.filter(r => r.status === 'canceled').length,
averageFare: rides.length > 0 ? totalRevenue / rides.filter(r => r.status === 'completed').length : 0
averageFare: Number.isFinite(avgFareM) ? avgFareM : 0
});
}

Expand Down Expand Up @@ -297,18 +303,20 @@ exports.getDriverEarnings = async (req, res) => {
// Commission Management
exports.setCommission = async (req, res) => {
try {
const { percentage, description } = req.body;
const { driverId, percentage, description } = req.body;
const adminId = req.user.id;

if (percentage < 0 || percentage > 100) {
return res.status(400).json({ message: 'Commission percentage must be between 0 and 100' });
}

// Deactivate current commission
await Commission.updateMany({ isActive: true }, { isActive: false });
if (!driverId) {
return res.status(400).json({ message: 'driverId is required to set commission' });
}

// Create new commission
// Create driver-specific commission entry (latest wins)
const commission = await Commission.create({
driverId: String(driverId),
percentage,
description,
createdBy: adminId
Expand All @@ -322,8 +330,12 @@ exports.setCommission = async (req, res) => {

exports.getCommission = async (req, res) => {
try {
const commission = await Commission.findOne({ isActive: true }).sort({ createdAt: -1 });
res.json(commission || { percentage: 15, isActive: true }); // Default 15%
const driverId = req.query.driverId || req.params.driverId || req.user?.id;
if (!driverId) {
return res.json({ percentage: Number(process.env.COMMISSION_RATE || 15) });
}
const commission = await Commission.findOne({ driverId: String(driverId) }).sort({ createdAt: -1 });
res.json(commission || { percentage: Number(process.env.COMMISSION_RATE || 15) });
} catch (e) {
res.status(500).json({ message: `Failed to get commission: ${e.message}` });
}
Expand Down
21 changes: 17 additions & 4 deletions controllers/driver.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,29 @@ module.exports = {
listPaymentOptions: async (req, res) => {
try {
const rows = await paymentService.getPaymentOptions();
const data = (rows || []).map(o => ({ id: String(o._id), name: o.name, logo: o.logo }));
let selectedId = null;
try {
if (req.user && req.user.type === 'driver') {
const me = await Driver.findById(String(req.user.id)).select({ paymentPreference: 1 }).lean();
selectedId = me && me.paymentPreference ? String(me.paymentPreference) : null;
}
} catch (_) {}
const data = (rows || []).map(o => ({ id: String(o._id || o.id), name: o.name, logo: o.logo, selected: selectedId ? String(o._id || o.id) === String(selectedId) : false }));
return res.json(data);
} catch (e) { errorHandler(res, e); }
},
setPaymentPreference: async (req, res) => {
try {
if (!req.user || req.user.type !== 'driver') return res.status(403).json({ message: 'Driver authentication required' });
const { paymentOptionId } = req.body || {};
let { paymentOptionId, driverId, id } = req.body || {};
// Accept `id` as an alias for `paymentOptionId` for convenience
if (!paymentOptionId && id) paymentOptionId = id;
const actingIsDriver = req.user && req.user.type === 'driver';
const actingIsAdmin = req.user && (req.user.type === 'admin' || (Array.isArray(req.user.roles) && req.user.roles.includes('superadmin')));
const targetDriverId = actingIsDriver ? String(req.user.id) : String(driverId || '');
if (!actingIsDriver && !actingIsAdmin) return res.status(403).json({ message: 'Forbidden: driver or admin required' });
if (!paymentOptionId) return res.status(400).json({ message: 'paymentOptionId is required' });
const updated = await paymentService.setDriverPaymentPreference(String(req.user.id), paymentOptionId);
if (!targetDriverId) return res.status(400).json({ message: 'driverId is required for admin to set preference' });
const updated = await paymentService.setDriverPaymentPreference(targetDriverId, paymentOptionId);
return res.json(updated);
} catch (e) { errorHandler(res, e); }
}
Expand Down
23 changes: 23 additions & 0 deletions controllers/paymentOption.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const PaymentOption = require('../models/paymentOption');

exports.list = async (req, res) => {
try {
const rows = await PaymentOption.find({}).select({ name: 1, logo: 1 }).sort({ name: 1 }).lean();
return res.json(rows.map(r => ({ id: String(r._id), name: r.name, logo: r.logo })));
} catch (e) {
return res.status(500).json({ message: e.message });
}
};

exports.create = async (req, res) => {
try {
const { name, logo } = req.body || {};
if (!name || String(name).trim() === '') return res.status(400).json({ message: 'name is required' });
const exists = await PaymentOption.findOne({ name: String(name).trim() }).lean();
if (exists) return res.status(409).json({ message: 'Payment option already exists' });
const row = await PaymentOption.create({ name: String(name).trim(), logo });
return res.status(201).json({ id: String(row._id), name: row.name, logo: row.logo });
} catch (e) {
return res.status(500).json({ message: e.message });
}
};
61 changes: 52 additions & 9 deletions controllers/wallet.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,31 @@ exports.topup = async (req, res) => {
metadata: { reason },
});

// Normalize payment method for SantimPay API
// Resolve payment method from explicit param or driver's selected PaymentOption
async function resolvePaymentMethod() {
const pick = (v) => (typeof v === 'string' && v.trim().length) ? v.trim() : null;
const explicit = pick(paymentMethod);
if (explicit) return explicit;
try {
const { Driver } = require("../models/userModels");
const me = await Driver.findById(String(userId)).select({ paymentPreference: 1 }).populate({ path: 'paymentPreference', select: { name: 1 } });
const name = me && me.paymentPreference && me.paymentPreference.name ? String(me.paymentPreference.name).trim() : null;
if (name) return name;
} catch (_) {}
const err = new Error('paymentMethod is required and no driver payment preference is set');
err.status = 400;
throw err;
}
// Normalize for SantimPay API accepted values
const normalizePaymentMethod = (method) => {
const m = String(method || "")
.trim()
.toLowerCase();
const m = String(method || "").trim().toLowerCase();
if (m === "telebirr" || m === "tele") return "Telebirr";
if (m === "cbe" || m === "cbe-birr" || m === "cbebirr") return "CBE";
if (m === "hellocash" || m === "hello-cash") return "HelloCash";
return "Telebirr";
return method; // pass-through for other configured options
};

const methodForGateway = normalizePaymentMethod(paymentMethod);
const methodForGateway = normalizePaymentMethod(await resolvePaymentMethod());

const notifyUrl =
process.env.SANTIMPAY_NOTIFY_URL ||
Expand Down Expand Up @@ -221,8 +234,15 @@ exports.webhook = async (req, res) => {
try {
const { Commission } = require("../models/commission");
const financeService = require("../services/financeService");
const commissionDoc = await Commission.findOne({ isActive: true }).sort({ createdAt: -1 });
const commissionRate = commissionDoc ? commissionDoc.percentage : Number(process.env.COMMISSION_RATE || 15);
let commissionRate = Number(process.env.COMMISSION_RATE || 15);
try {
if (tx && tx.role === 'driver' && tx.userId) {
const commissionDoc = await Commission.findOne({ driverId: String(tx.userId) }).sort({ createdAt: -1 });
if (commissionDoc && Number.isFinite(commissionDoc.percentage)) {
commissionRate = commissionDoc.percentage;
}
}
} catch (_) {}
if (tx.role === 'driver') {
delta = financeService.calculatePackage(providerAmount, commissionRate);
}
Expand Down Expand Up @@ -345,12 +365,35 @@ exports.withdraw = async (req, res) => {
process.env.SANTIMPAY_WITHDRAW_NOTIFY_URL ||
`${process.env.PUBLIC_BASE_URL || ""}/v1/wallet/webhook`;
try {
// Resolve payment method from explicit param or driver's selected PaymentOption
async function resolvePaymentMethodWithdraw() {
const pick = (v) => (typeof v === 'string' && v.trim().length) ? v.trim() : null;
const explicit = pick(paymentMethod);
if (explicit) return explicit;
try {
const { Driver } = require("../models/userModels");
const me = await Driver.findById(String(userId)).select({ paymentPreference: 1 }).populate({ path: 'paymentPreference', select: { name: 1 } });
const name = me && me.paymentPreference && me.paymentPreference.name ? String(me.paymentPreference.name).trim() : null;
if (name) return name;
} catch (_) {}
const err = new Error('paymentMethod is required and no driver payment preference is set');
err.status = 400;
throw err;
}
const normalizePaymentMethod2 = (method) => {
const m = String(method || "").trim().toLowerCase();
if (m === "telebirr" || m === "tele") return "Telebirr";
if (m === "cbe" || m === "cbe-birr" || m === "cbebirr") return "CBE";
if (m === "hellocash" || m === "hello-cash") return "HelloCash";
return method;
};
const pm = normalizePaymentMethod2(await resolvePaymentMethodWithdraw());
const gw = await santim.payoutTransfer({
id: tx._id.toString(),
amount,
paymentReason: reason,
phoneNumber: msisdn,
paymentMethod: paymentMethod || "Telebirr",
paymentMethod: pm,
notifyUrl,
});
const gwTxnId =
Expand Down
42 changes: 30 additions & 12 deletions docs/socket-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ This document lists all Socket.IO events in the system, who emits them, required

- Event: `trip_ongoing`
- Target: Room `booking:{bookingId}`
- Payload: `{ bookingId, location }`
- Payload: `{ bookingId, location: { latitude, longitude, bearing?, timestamp? } }`

- Event: `trip_completed`
- Target: Room `booking:{bookingId}`
- Payload: `{ bookingId, amount, distance, waitingTime, completedAt, driverEarnings, commission }`

- Event: `booking_accept`
- Target: Room `booking:{bookingId}`
Expand All @@ -102,22 +104,23 @@ This document lists all Socket.IO events in the system, who emits them, required

### Driver Domain (Server Emits)

- Event: `driver:init_bookings`
- Event: `booking:nearby`
- Target: Driver socket immediately after connection
- Payload:
{
init: true,
driverId: "<ObjectId>",
bookings: [
{
bookingId: "<ObjectId>",
status: "pending",
pickup: "Bole Airport",
dropoff: "CMC",
fare: 350,
status: "requested|accepted|ongoing",
pickup: any,
dropoff: any,
fare: number,
passenger: {
id: "<ObjectId>",
name: "Jane Doe",
phone: "+251911111111"
name: string,
phone: string
}
}
]
Expand Down Expand Up @@ -176,15 +179,30 @@ This document lists all Socket.IO events in the system, who emits them, required

- Event: `pricing:update`
- Emitter: Server (HTTP pricing controller)
- Payload: Updated pricing model document
- Payload: Updated pricing model document payload(bookingId)

---

### Booking Domain (Broadcasts)

- Event: `booking:new:broadcast`
- Emitter: Server (events/bookingEvents)
- Payload: New booking summary for passenger broadcast channels
- Event: `booking:new`
- Emitter: Server (targeted to drivers) via `sendMessageToSocketId('driver:{driverId}')`
- Payload:
{
bookingId: "<ObjectId>",
patch: {
status: "requested",
passengerId: "<ObjectId>",
vehicleType: string,
pickup: any,
dropoff: any,
passenger: { id, name, phone }
}
}

- Event: `booking:removed`
- Emitter: Server (targeted to nearby drivers) via `sendMessageToSocketId('driver:{driverId}')`
- Payload: `{ bookingId }`

- Event: `booking:update`
- Emitter: Server (events/bookingEvents)
Expand Down
3 changes: 1 addition & 2 deletions models/commission.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
const mongoose = require('mongoose');

const CommissionSchema = new mongoose.Schema({
driverId: { type: String, required: true, index: true },
percentage: { type: Number, required: true, min: 0, max: 100 },
isActive: { type: Boolean, default: true },
effectiveFrom: { type: Date, default: Date.now },
effectiveTo: { type: Date },
createdBy: { type: String, required: true }, // Admin ID who set this commission
description: { type: String }
Expand Down
Binary file added modules.zip
Binary file not shown.
2 changes: 1 addition & 1 deletion routes/v1/driver.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ router.post('/discover-and-estimate', authenticate, ctrl.discoverAndEstimate);

// Payment options via driver route namespace (alternative path)
router.get('/payment-options', authenticate, ctrl.listPaymentOptions);
router.post('/payment-preference', authenticate, authorize('driver'), ctrl.setPaymentPreference);
router.post('/payment-preference', authenticate, ctrl.setPaymentPreference);

module.exports = router;
13 changes: 10 additions & 3 deletions routes/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ router.use("/drivers", require("./driver.routes"));
router.get('/payment-options', async (req, res) => {
try {
const ctrl = require('../../controllers/driver.controller');
const rows = await require('../../services/paymentService').getPaymentOptions();
return res.json(rows);
return await ctrl.listPaymentOptions(req, res);
} catch (e) {
return res.status(500).json({ message: e.message });
}
});
router.post('/driver/payment-preference', authorize('driver'), async (req, res) => {
router.post('/payment-options', authorize('admin','superadmin'), async (req, res) => {
try {
const { create } = require('../../controllers/paymentOption.controller');
return await create(req, res);
} catch (e) {
return res.status(500).json({ message: e.message });
}
});
router.post('/driver/payment-preference', async (req, res) => {
try {
const ctrl = require('../../controllers/driver.controller');
return await ctrl.setPaymentPreference(req, res);
Expand Down
11 changes: 8 additions & 3 deletions services/bookingLifecycleService.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,14 @@ async function completeTrip(bookingId, endLocation, options = {}) {
const waitingTimeMinutes = Math.max(0, Math.round(((completedAt - new Date(startedAt)) / 60000)));

const fare = await pricingService.calculateFare(distanceKm, waitingTimeMinutes, booking.vehicleType, surgeMultiplier, discount);
// Get dynamic commission rate from model (admin configurable)
const commissionDoc = await Commission.findOne({ isActive: true }).sort({ createdAt: -1 });
const commissionRate = commissionDoc ? commissionDoc.percentage : Number(process.env.COMMISSION_RATE || 15);
// Get per-driver commission rate set by admin; fallback to env default
let commissionRate = Number(process.env.COMMISSION_RATE || 15);
if (booking.driverId) {
const commissionDoc = await Commission.findOne({ driverId: String(booking.driverId) }).sort({ createdAt: -1 });
if (commissionDoc && Number.isFinite(commissionDoc.percentage)) {
commissionRate = commissionDoc.percentage;
}
}
const commission = financeService.calculateCommission(fare, commissionRate);
const driverEarnings = fare - commission;

Expand Down
Loading