auth-app-api / src /controllers /order.controller.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
8.42 kB
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,
};