const mongoose = require("mongoose"); const Order = require("../models/Order"); const Course = require("../models/Course"); const AuditLog = require("../models/AuditLog"); const { generateOrderNumber } = require("../utils/orderNumber"); const { env } = require("../config/env"); const { sendOrderPlacedEmail, sendAdminNewOrderAlert } = require("../utils/email"); const payoneerCheckout = require("../utils/payoneerCheckout"); const logger = require("../utils/logger"); /** * Public checkout configuration — payment instructions shown on the * frontend before the customer submits their reference number. No * secrets here, just receiving-account details that are meant to be * shared with paying customers. */ function getPaymentOptions(req, res) { res.json({ bankWire: { currency: "USD", bankName: env.bank.name, accountName: env.bank.accountName, accountNumber: env.bank.accountNumber, routingNumber: env.bank.routingNumber, swiftBic: env.bank.swiftBic, iban: env.bank.iban || undefined, address: env.bank.address, }, payoneer: { accountEmail: env.payoneer.accountEmail, paymentUrl: env.payoneer.paymentUrl, }, }); } async function createOrder(req, res, next) { try { const { courseId, paymentMethod } = req.body; if (!mongoose.isValidObjectId(courseId)) { return res.status(400).json({ error: "Invalid course selected." }); } const course = await Course.findById(courseId); if (!course || !course.isPublished) { return res.status(404).json({ error: "Course not found or unavailable." }); } if (req.user.hasAccessToCourse(course._id)) { return res.status(409).json({ error: "You already have access to this course." }); } // Prevent duplicate open orders for the same course by the same user. const existingOpenOrder = await Order.findOne({ user: req.user._id, course: course._id, status: { $in: ["pending_payment", "awaiting_confirmation"] }, }); if (existingOpenOrder) { return res.status(200).json({ order: existingOpenOrder.toPublicJSON(), reused: true }); } const order = new Order({ orderNumber: generateOrderNumber(), user: req.user._id, course: course._id, courseTitleSnapshot: course.title, amount: course.price, currency: course.currency || "USD", paymentMethod, status: "awaiting_confirmation", }); await order.save(); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "order.created", targetType: "Order", targetId: order._id, ip: req.ip, userAgent: req.headers["user-agent"], metadata: { courseId: course._id.toString(), paymentMethod }, }); // Fire-and-forget emails; failures are logged but never block checkout. sendOrderPlacedEmail({ to: req.user.email, name: req.user.name, order, courseTitle: course.title, bankDetails: env.bank, payoneerDetails: env.payoneer, }) .then(() => { order.confirmationEmailSentAt = new Date(); return order.save(); }) .catch((err) => logger.error("Order confirmation email failed", { error: err.message })); sendAdminNewOrderAlert({ order, courseTitle: course.title, customerEmail: req.user.email }).catch((err) => logger.error("Admin new-order alert failed", { error: err.message }) ); res.status(201).json({ order: order.toPublicJSON() }); } catch (err) { next(err); } } /** * Creates an Order plus a hosted Payoneer Checkout session for it, and * returns the redirect URL along with a clear amount/tax/total * breakdown — the frontend shows that breakdown to the buyer before * sending them to Payoneer, per the transparency requirement. * * Unlike createOrder (used for the manual bank_wire/payoneer-reference * flow), this never puts the order in "awaiting_confirmation" for a * human to review — it stays "pending_payment" until Payoneer's * webhook confirms the payment (see webhook.controller.js), at which * point it's auto-confirmed and access is granted automatically. */ async function createPayoneerCheckoutSession(req, res, next) { try { const { courseId } = req.body; if (!mongoose.isValidObjectId(courseId)) { return res.status(400).json({ error: "Invalid course selected." }); } const course = await Course.findById(courseId); if (!course || !course.isPublished) { return res.status(404).json({ error: "Course not found or unavailable." }); } if (req.user.hasAccessToCourse(course._id)) { return res.status(409).json({ error: "You already have access to this course." }); } const existingOpenOrder = await Order.findOne({ user: req.user._id, course: course._id, paymentMethod: "payoneer_checkout", status: "pending_payment", }); if (existingOpenOrder && existingOpenOrder.providerCheckoutUrl) { return res.status(200).json({ order: existingOpenOrder.toPublicJSON(), reused: true }); } const order = new Order({ orderNumber: generateOrderNumber(), user: req.user._id, course: course._id, courseTitleSnapshot: course.title, amount: course.price, currency: course.currency || "USD", paymentMethod: "payoneer_checkout", status: "pending_payment", }); const session = await payoneerCheckout.createCheckoutSession({ order, course, customerEmail: req.user.email, customerName: req.user.name, successUrl: `${env.frontendUrl}/academy/checkout/${order._id}?result=success`, cancelUrl: `${env.frontendUrl}/academy/checkout/${order._id}?result=cancelled`, }); order.providerSessionId = session.sessionId; order.providerCheckoutUrl = session.checkoutUrl; if (session.tax) { order.taxAmount = session.tax.amount; order.taxCurrency = session.tax.currency; } await order.save(); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "order.checkout_session_created", targetType: "Order", targetId: order._id, ip: req.ip, userAgent: req.headers["user-agent"], metadata: { courseId: course._id.toString() }, }); res.status(201).json({ order: order.toPublicJSON() }); } catch (err) { next(err); } } /** Customer submits their bank wire reference / Payoneer transaction ID after paying. */ async function submitPaymentProof(req, res, next) { try { const { reference, note } = req.body; const order = await Order.findOne({ _id: req.params.id, user: req.user._id }); if (!order) return res.status(404).json({ error: "Order not found." }); if (!["pending_payment", "awaiting_confirmation"].includes(order.status)) { return res.status(409).json({ error: "This order can no longer be updated." }); } order.setPaymentReference(reference); if (note) order.setPayerNote(note); order.status = "awaiting_confirmation"; await order.save(); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "order.payment_proof_submitted", targetType: "Order", targetId: order._id, ip: req.ip, userAgent: req.headers["user-agent"], }); res.json({ order: order.toPublicJSON() }); } catch (err) { next(err); } } async function listMyOrders(req, res, next) { try { const orders = await Order.find({ user: req.user._id }).sort({ createdAt: -1 }); res.json({ orders: orders.map((o) => o.toPublicJSON()) }); } catch (err) { next(err); } } async function getMyOrder(req, res, next) { try { const order = await Order.findOne({ _id: req.params.id, user: req.user._id }); if (!order) return res.status(404).json({ error: "Order not found." }); res.json({ order: order.toPublicJSON({ includeSensitive: true }) }); } catch (err) { next(err); } } module.exports = { getPaymentOptions, createOrder, createPayoneerCheckoutSession, submitPaymentProof, listMyOrders, getMyOrder, };