samoulla-backend / controllers /paymentController.js
Samoulla Sync Bot
Auto-deploy Samoulla Backend: b68e45770de26ed39feb4b1c0925e5345eb3a61d
634b9bb
const Order = require('../models/orderModel');
const {
initiatePayment: initiatePaymobPayment,
verifyPayment: verifyPaymobPayment,
refundTransaction: refundPaymobTransaction,
} = require('../utils/paymobService');
const { sendOrderConfirmationEmail } = require('../utils/emailService');
const {
notifyOrderCreated,
notifyAdminsNewOrder,
notifyVendorNewOrder,
emitOrderUpdate,
} = require('../utils/notificationService');
/**
* Initiate Paymob payment for an order
* This should be called after creating an order with payment method 'visa'
*/
exports.initiatePayment = async (req, res) => {
try {
const { orderId, frontendUrl } = req.body;
if (!orderId) {
return res.status(400).json({
status: 'fail',
message: 'Order ID is required',
});
}
const order = await Order.findById(orderId).populate('items.product');
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
// Verify that the order belongs to the user (if not admin)
if (req.user && order.user && String(order.user) !== String(req.user._id)) {
return res.status(403).json({
status: 'fail',
message: 'Not authorized to access this order',
});
}
// Check if payment method is visa
if (order.payment.method !== 'visa') {
return res.status(400).json({
status: 'fail',
message: 'This order does not require online payment',
});
}
// Check if already paid
if (order.payment.status === 'paid') {
return res.status(400).json({
status: 'fail',
message: 'This order has already been paid',
});
}
// Prepare billing data
const nameParts = order.name.split(' ');
const billingData = {
firstName: nameParts[0] || order.name,
lastName: nameParts.slice(1).join(' ') || nameParts[0],
email: req.user ? req.user.email : 'guest@samoulla.com',
phone: order.mobile,
street: order.address.street,
city: order.address.city,
state: order.address.governorate,
country: 'EG',
apartment: 'NA',
floor: 'NA',
building: 'NA',
postalCode: 'NA',
};
// Prepare order data for Paymob
const orderData = {
amount: order.totalPrice,
items: order.items.map((item) => ({
name: item.name,
quantity: item.quantity,
unitPrice: item.unitPrice,
description: item.name,
})),
billingData,
};
// Initiate payment with Paymob
const paymentResult = await initiatePaymobPayment(orderData);
if (!paymentResult.success) {
return res.status(500).json({
status: 'error',
message: 'Failed to initiate payment',
error: paymentResult.error,
});
}
// Update order with Paymob data
order.payment.paymobOrderId = paymentResult.paymobOrderId;
order.payment.paymentKey = paymentResult.paymentKey;
if (frontendUrl) {
order.payment.frontendUrl = frontendUrl;
}
await order.save();
res.status(200).json({
status: 'success',
data: {
paymentKey: paymentResult.paymentKey,
iframeUrl: paymentResult.iframeUrl,
paymobOrderId: paymentResult.paymobOrderId,
},
});
} catch (error) {
console.error('Payment initiation error:', error);
res.status(500).json({
status: 'error',
message: error.message,
});
}
};
const { restoreOrderStock } = require('../utils/orderUtils');
const Promo = require('../models/promoCodeModel');
/**
* Paymob callback handler
* This endpoint receives payment status updates from Paymob
*/
exports.paymobCallback = async (req, res) => {
let order;
try {
// Combine body and query for consistent data access
const transactionData = { ...req.query, ...req.body };
console.log(
'Paymob callback received:',
JSON.stringify(transactionData, null, 2),
);
// Verify payment using HMAC
const verificationResult = await verifyPaymobPayment(transactionData);
// TRY TO FIND ORDER EARLY (to get frontendUrl for redirects)
if (verificationResult.orderId) {
order = await Order.findOne({
'payment.paymobOrderId': String(verificationResult.orderId),
}).populate('user', 'name email');
}
const fallbackUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const frontendUrl =
(order && order.payment && order.payment.frontendUrl) || fallbackUrl;
// 1. VERIFICATION CHECK
if (!verificationResult.hmacVerified) {
console.error('Payment verification failed: Invalid HMAC signature');
if (req.method === 'GET') {
return res.redirect(
`${frontendUrl}/checkout?error=verification_failed`,
);
}
return res
.status(200)
.json({ message: 'Callback received but verification failed' });
}
// 2. FIND AND UPDATE ORDER (If not already found)
if (!order) {
console.error(
'Order not found for Paymob order ID:',
verificationResult.orderId,
);
if (req.method === 'GET') {
// If it's a redirect and order is gone (maybe deleted by POST already),
// We still want to go back to checkout with a status if possible
const status = verificationResult.success ? 'success' : 'failed';
if (status === 'failed') {
return res.redirect(`${frontendUrl}/checkout?status=failed`);
}
return res.redirect(`${frontendUrl}/checkout?error=order_not_found`);
}
return res.status(200).json({ message: 'Order not found' });
}
// IDEMPOTENCY CHECK: Skip if already processed (paid or explicitly cancelled)
const isAlreadyPaid = order.payment.status === 'paid';
const isAlreadyCancelled = order.orderStatus === 'cancelled';
if (!isAlreadyPaid && !isAlreadyCancelled) {
if (verificationResult.success) {
// SUCCESSFUL TRANSACTION
order.payment.status = 'paid';
order.payment.paidAt = new Date();
order.payment.paymobTransactionId = String(
verificationResult.transactionId,
);
// Ensure orderStatus is 'created' or 'processing' if paid
if (order.orderStatus === 'cancelled') order.orderStatus = 'created';
await order.save();
// Send confirmation email and notifications
try {
// Notify the user
if (order.user) {
await notifyOrderCreated(order, order.user._id || order.user);
}
// Notify admins
await notifyAdminsNewOrder(order);
// Notify vendors
const uniqueProviderIds = new Set();
// We need items available here. Let's populate them if needed or use the existing ones.
// The order already has items array but products might not be populated with provider.
const orderWithProducts = await Order.findById(order._id).populate(
'items.product',
);
orderWithProducts.items.forEach((item) => {
if (item.product && item.product.provider) {
uniqueProviderIds.add(item.product.provider.toString());
}
});
for (const pId of uniqueProviderIds) {
await notifyVendorNewOrder(order, pId);
}
// Send Email
if (order.user) {
// Populate for email (fully)
const populatedOrderForEmail = await Order.findById(
order._id,
).populate({
path: 'items.product',
select: 'nameAr price imageCover',
populate: { path: 'provider', select: 'storeName' },
});
const emailOrderData = {
orderNumber: order._id.toString().slice(-8).toUpperCase(),
items: populatedOrderForEmail.items.map((item) => ({
product: {
nameAr: (item.product && item.product.nameAr) || item.name,
imageCover: (item.product && item.product.imageCover) || null,
provider: (item.product && item.product.provider) || null,
},
quantity: item.quantity,
price: item.unitPrice,
})),
subtotal:
order.totalPrice -
(order.shippingPrice || 0) +
(order.discountAmount || 0),
discount: order.discountAmount || 0,
shippingCost: order.shippingPrice || 0,
total: order.totalPrice,
paymentMethod: order.payment.method,
shippingAddress: {
street: order.address.street,
city: order.address.city,
governorate: order.address.governorate,
phone: order.mobile,
},
};
await sendOrderConfirmationEmail(emailOrderData, order.user);
}
} catch (notifErr) {
console.error('Notification/Email error:', notifErr);
}
emitOrderUpdate(order, 'payment_completed');
console.log(`✅ Payment successful for order ${order._id}`);
} else {
// FAILED TRANSACTION
// Per user request: Don't place (keep) the order if payment failed.
// We delete it instead of marking as cancelled.
// 1. RESTORE STOCK
try {
await restoreOrderStock(order.items);
console.log(
`📦 Stock restored for deleted (previously failed) order ${order._id}`,
);
} catch (stockErr) {
console.error('Failed to restore stock:', stockErr);
}
// 2. RESTORE PROMO (if any)
if (order.promo) {
try {
await Promo.findByIdAndUpdate(order.promo, {
$inc: { usedCount: -1 },
});
console.log(`🏷️ Promo usage rolled back for order ${order._id}`);
} catch (promoErr) {
console.error('Failed to restore promo usage:', promoErr);
}
}
// 3. EMIT FAILURE EVENT (before delete)
emitOrderUpdate(order, 'payment_failed');
// 4. DELETE THE ORDER
const orderId = order._id;
await Order.findByIdAndDelete(orderId);
console.log(`🗑️ Failed order ${orderId} deleted from database`);
}
} else {
console.log(
`ℹ️ Order ${order._id} already processed. Status: ${order.payment.status}, OrderStatus: ${order.orderStatus}`,
);
}
// 3. FINAL RESPONSE
if (req.method === 'GET') {
const status = verificationResult.success ? 'success' : 'failed';
console.log(
`Redirecting user for order ${order._id} with status ${status} to ${frontendUrl}`,
);
if (status === 'success') {
return res.redirect(
`${frontendUrl}/order-confirmation/${order._id}?status=success`,
);
}
return res.redirect(
`${frontendUrl}/checkout?status=failed&orderId=${order._id}`,
);
}
res.status(200).json({
status: 'success',
message: 'Callback processed successfully',
});
} catch (error) {
console.error('Callback processing error:', error);
if (req.method === 'GET') {
const fallbackUrlCatch =
process.env.FRONTEND_URL || 'http://localhost:5173';
const frontendUrlCatch =
(order && order.payment && order.payment.frontendUrl) ||
fallbackUrlCatch;
return res.redirect(
`${frontendUrlCatch}/checkout?error=internal_server_error`,
);
}
res
.status(200)
.json({ message: 'Callback received but processing failed' });
}
};
/**
* Check payment status for an order
*/
exports.checkPaymentStatus = async (req, res) => {
try {
const { orderId } = req.params;
const order = await Order.findById(orderId);
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
// Verify that the order belongs to the user (if not admin)
if (req.user && order.user && String(order.user) !== String(req.user._id)) {
return res.status(403).json({
status: 'fail',
message: 'Not authorized to access this order',
});
}
res.status(200).json({
status: 'success',
data: {
paymentStatus: order.payment.status,
paymentMethod: order.payment.method,
paidAt: order.payment.paidAt,
transactionId: order.payment.paymobTransactionId,
},
});
} catch (error) {
console.error('Payment status check error:', error);
res.status(500).json({
status: 'error',
message: error.message,
});
}
};
/**
* Refund a payment (Admin only)
*/
exports.refundPayment = async (req, res) => {
try {
const { orderId } = req.params;
const { amount } = req.body;
const order = await Order.findById(orderId);
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
// Check if order was paid
if (order.payment.status !== 'paid') {
return res.status(400).json({
status: 'fail',
message: 'Cannot refund an unpaid order',
});
}
// Check if transaction ID exists
if (!order.payment.paymobTransactionId) {
return res.status(400).json({
status: 'fail',
message: 'No Paymob transaction found for this order',
});
}
// Determine refund amount
const refundAmount = amount || order.totalPrice;
if (refundAmount > order.totalPrice) {
return res.status(400).json({
status: 'fail',
message: 'Refund amount cannot exceed order total',
});
}
// Process refund with Paymob
const refundResult = await refundPaymobTransaction(
order.payment.paymobTransactionId,
refundAmount,
);
if (!refundResult.success) {
return res.status(500).json({
status: 'error',
message: 'Failed to process refund',
error: refundResult.error,
});
}
// Update order
order.payment.refundedAt = new Date();
order.payment.refundAmount = refundAmount;
order.payment.status = 'failed'; // Mark as failed after refund
await order.save();
// Broadcast order update
emitOrderUpdate(order, 'payment_refunded');
res.status(200).json({
status: 'success',
data: {
message: 'Refund processed successfully',
refundAmount,
order,
},
});
} catch (error) {
console.error('Refund processing error:', error);
res.status(500).json({
status: 'error',
message: error.message,
});
}
};
/**
* Paymob transaction processed callback (Alternative callback endpoint)
*/
exports.paymobTransactionCallback = async (req, res) => {
try {
// Extract data from query parameters (Paymob sends some data via GET)
const transactionData = {
...req.query,
...req.body,
};
console.log(
'Transaction callback received:',
JSON.stringify(transactionData, null, 2),
);
// Forward to main callback handler
req.body = transactionData;
return exports.paymobCallback(req, res);
} catch (error) {
console.error('Transaction callback error:', error);
res.status(200).json({ message: 'Callback received' });
}
};
module.exports = exports;