Spaces:
Running
Running
| 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; | |