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;