Spaces:
Sleeping
Sleeping
Upload 46 files
Browse files- src/app.js +11 -0
- src/config/env.js +17 -1
- src/controllers/admin.controller.js +9 -51
- src/controllers/content.controller.js +56 -1
- src/controllers/course.controller.js +35 -2
- src/controllers/order.controller.js +94 -3
- src/controllers/voucher.controller.js +279 -0
- src/controllers/webhook.controller.js +71 -0
- src/middleware/rateLimiters.js +13 -1
- src/models/Course.js +25 -2
- src/models/Order.js +21 -2
- src/models/Voucher.js +100 -0
- src/routes/admin.routes.js +23 -0
- src/routes/course.routes.js +3 -0
- src/routes/order.routes.js +8 -0
- src/routes/voucher.routes.js +20 -0
- src/routes/webhook.routes.js +8 -0
- src/seed/seed.js +134 -128
- src/utils/cloudinaryUpload.js +41 -1
- src/utils/email.js +18 -0
- src/utils/orderConfirmation.js +97 -0
- src/utils/payoneerCheckout.js +171 -0
- src/utils/voucherCode.js +21 -0
src/app.js
CHANGED
|
@@ -15,6 +15,8 @@ const authRoutes = require("./routes/auth.routes");
|
|
| 15 |
const courseRoutes = require("./routes/course.routes");
|
| 16 |
const orderRoutes = require("./routes/order.routes");
|
| 17 |
const adminRoutes = require("./routes/admin.routes");
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const app = express();
|
| 20 |
|
|
@@ -68,6 +70,14 @@ app.use(
|
|
| 68 |
);
|
| 69 |
|
| 70 |
app.use(compression());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
|
| 72 |
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
| 73 |
|
|
@@ -92,6 +102,7 @@ app.use("/api/auth", authRoutes);
|
|
| 92 |
app.use("/api/courses", courseRoutes);
|
| 93 |
app.use("/api/orders", orderRoutes);
|
| 94 |
app.use("/api/admin", adminRoutes);
|
|
|
|
| 95 |
|
| 96 |
app.use(notFound);
|
| 97 |
app.use(errorHandler);
|
|
|
|
| 15 |
const courseRoutes = require("./routes/course.routes");
|
| 16 |
const orderRoutes = require("./routes/order.routes");
|
| 17 |
const adminRoutes = require("./routes/admin.routes");
|
| 18 |
+
const voucherRoutes = require("./routes/voucher.routes");
|
| 19 |
+
const webhookRoutes = require("./routes/webhook.routes");
|
| 20 |
|
| 21 |
const app = express();
|
| 22 |
|
|
|
|
| 70 |
);
|
| 71 |
|
| 72 |
app.use(compression());
|
| 73 |
+
|
| 74 |
+
// Payoneer's webhook signature is computed over the exact raw request
|
| 75 |
+
// body, so this route MUST be registered with a raw-body parser
|
| 76 |
+
// BEFORE the global express.json() below β once express.json() has
|
| 77 |
+
// run, the original bytes are gone and any signature check would
|
| 78 |
+
// fail. See utils/payoneerCheckout.js -> verifyAndParseWebhook.
|
| 79 |
+
app.use("/api/webhooks", express.raw({ type: "*/*", limit: "200kb" }), webhookRoutes);
|
| 80 |
+
|
| 81 |
app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
|
| 82 |
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
| 83 |
|
|
|
|
| 102 |
app.use("/api/courses", courseRoutes);
|
| 103 |
app.use("/api/orders", orderRoutes);
|
| 104 |
app.use("/api/admin", adminRoutes);
|
| 105 |
+
app.use("/api/vouchers", voucherRoutes);
|
| 106 |
|
| 107 |
app.use(notFound);
|
| 108 |
app.use(errorHandler);
|
src/config/env.js
CHANGED
|
@@ -23,7 +23,10 @@ const env = {
|
|
| 23 |
.split(",")
|
| 24 |
.map((s) => s.trim())
|
| 25 |
.filter(Boolean),
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
mongoUri: process.env.MONGODB_URI,
|
| 28 |
|
| 29 |
jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
|
|
@@ -57,6 +60,19 @@ const env = {
|
|
| 57 |
accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
|
| 58 |
},
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
seedAdmin: {
|
| 61 |
name: process.env.SEED_ADMIN_NAME || "Site Admin",
|
| 62 |
email: process.env.SEED_ADMIN_EMAIL,
|
|
|
|
| 23 |
.split(",")
|
| 24 |
.map((s) => s.trim())
|
| 25 |
.filter(Boolean),
|
| 26 |
+
// Base URL used to build absolute redirect links (e.g. Payoneer
|
| 27 |
+
// Checkout success/cancel URLs) sent to third-party APIs β falls
|
| 28 |
+
// back to the first allowed CORS origin if not set explicitly.
|
| 29 |
+
frontendUrl: process.env.FRONTEND_URL || (process.env.CLIENT_ORIGINS || "http://localhost:5173").split(",")[0].trim(),
|
| 30 |
mongoUri: process.env.MONGODB_URI,
|
| 31 |
|
| 32 |
jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
|
|
|
|
| 60 |
accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
|
| 61 |
},
|
| 62 |
|
| 63 |
+
// Payoneer Checkout (automated card/wallet checkout) β separate from
|
| 64 |
+
// the manual "payoneer" bank-style transfer above. These credentials
|
| 65 |
+
// are issued directly by Payoneer only after your business is
|
| 66 |
+
// approved as a Payoneer Checkout merchant; see
|
| 67 |
+
// utils/payoneerCheckout.js for what's still a placeholder pending
|
| 68 |
+
// that approval and your integration-specific API docs from them.
|
| 69 |
+
payoneerCheckout: {
|
| 70 |
+
apiBase: process.env.PAYONEER_CHECKOUT_API_BASE || "",
|
| 71 |
+
apiKey: process.env.PAYONEER_CHECKOUT_API_KEY || "",
|
| 72 |
+
apiSecret: process.env.PAYONEER_CHECKOUT_API_SECRET || "",
|
| 73 |
+
webhookSecret: process.env.PAYONEER_CHECKOUT_WEBHOOK_SECRET || "",
|
| 74 |
+
},
|
| 75 |
+
|
| 76 |
seedAdmin: {
|
| 77 |
name: process.env.SEED_ADMIN_NAME || "Site Admin",
|
| 78 |
email: process.env.SEED_ADMIN_EMAIL,
|
src/controllers/admin.controller.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
-
const mongoose = require("mongoose");
|
| 2 |
const Order = require("../models/Order");
|
| 3 |
const User = require("../models/User");
|
| 4 |
-
const Course = require("../models/Course");
|
| 5 |
const AuditLog = require("../models/AuditLog");
|
| 6 |
-
const {
|
|
|
|
| 7 |
const logger = require("../utils/logger");
|
| 8 |
|
| 9 |
async function listOrders(req, res, next) {
|
|
@@ -65,62 +64,21 @@ async function getOrderDetail(req, res, next) {
|
|
| 65 |
* granted (or vice versa).
|
| 66 |
*/
|
| 67 |
async function confirmOrder(req, res, next) {
|
| 68 |
-
const session = await mongoose.startSession();
|
| 69 |
try {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
await session.withTransaction(async () => {
|
| 75 |
-
const order = await Order.findById(req.params.id).session(session);
|
| 76 |
-
if (!order) throw Object.assign(new Error("Order not found."), { status: 404 });
|
| 77 |
-
if (order.status === "confirmed") throw Object.assign(new Error("Order already confirmed."), { status: 409 });
|
| 78 |
-
if (!["awaiting_confirmation", "pending_payment"].includes(order.status)) {
|
| 79 |
-
throw Object.assign(new Error("Order cannot be confirmed from its current status."), { status: 409 });
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
order.status = "confirmed";
|
| 83 |
-
order.confirmedBy = req.user._id;
|
| 84 |
-
order.confirmedAt = new Date();
|
| 85 |
-
await order.save({ session });
|
| 86 |
-
|
| 87 |
-
const user = await User.findById(order.user).session(session);
|
| 88 |
-
if (!user) throw Object.assign(new Error("Associated user not found."), { status: 404 });
|
| 89 |
-
|
| 90 |
-
const alreadyEnrolled = user.enrolledCourses.some((ec) => ec.course.toString() === order.course.toString());
|
| 91 |
-
if (!alreadyEnrolled) {
|
| 92 |
-
user.enrolledCourses.push({ course: order.course, order: order._id, accessGrantedAt: new Date() });
|
| 93 |
-
await user.save({ session });
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
course = await Course.findById(order.course).session(session);
|
| 97 |
-
resultOrder = order;
|
| 98 |
-
resultUser = user;
|
| 99 |
-
});
|
| 100 |
|
| 101 |
-
await
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
action: "admin.order_confirmed",
|
| 105 |
-
targetType: "Order",
|
| 106 |
-
targetId: resultOrder._id,
|
| 107 |
ip: req.ip,
|
| 108 |
userAgent: req.headers["user-agent"],
|
| 109 |
-
metadata: { userId: resultUser._id.toString() },
|
| 110 |
});
|
| 111 |
|
| 112 |
-
|
| 113 |
-
to: resultUser.email,
|
| 114 |
-
name: resultUser.name,
|
| 115 |
-
order: resultOrder,
|
| 116 |
-
courseTitle: course ? course.title : resultOrder.courseTitleSnapshot,
|
| 117 |
-
}).catch((err) => logger.error("Payment-confirmed email failed", { error: err.message }));
|
| 118 |
-
|
| 119 |
-
res.json({ order: resultOrder.toPublicJSON() });
|
| 120 |
} catch (err) {
|
| 121 |
next(err);
|
| 122 |
-
} finally {
|
| 123 |
-
session.endSession();
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
|
|
|
|
|
|
| 1 |
const Order = require("../models/Order");
|
| 2 |
const User = require("../models/User");
|
|
|
|
| 3 |
const AuditLog = require("../models/AuditLog");
|
| 4 |
+
const { sendPaymentRejectedEmail } = require("../utils/email");
|
| 5 |
+
const { confirmOrderAndGrantAccess } = require("../utils/orderConfirmation");
|
| 6 |
const logger = require("../utils/logger");
|
| 7 |
|
| 8 |
async function listOrders(req, res, next) {
|
|
|
|
| 64 |
* granted (or vice versa).
|
| 65 |
*/
|
| 66 |
async function confirmOrder(req, res, next) {
|
|
|
|
| 67 |
try {
|
| 68 |
+
const existing = await Order.findById(req.params.id);
|
| 69 |
+
if (!existing) return res.status(404).json({ error: "Order not found." });
|
| 70 |
+
if (existing.status === "confirmed") return res.status(409).json({ error: "Order already confirmed." });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
const { order } = await confirmOrderAndGrantAccess({
|
| 73 |
+
orderId: req.params.id,
|
| 74 |
+
confirmedBy: req.user._id,
|
|
|
|
|
|
|
|
|
|
| 75 |
ip: req.ip,
|
| 76 |
userAgent: req.headers["user-agent"],
|
|
|
|
| 77 |
});
|
| 78 |
|
| 79 |
+
res.json({ order: order.toPublicJSON() });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
} catch (err) {
|
| 81 |
next(err);
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
src/controllers/content.controller.js
CHANGED
|
@@ -2,7 +2,14 @@ const fs = require("fs/promises");
|
|
| 2 |
const mongoose = require("mongoose");
|
| 3 |
const Course = require("../models/Course");
|
| 4 |
const AuditLog = require("../models/AuditLog");
|
| 5 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
const logger = require("../utils/logger");
|
| 7 |
|
| 8 |
function userHasAccess(user, courseId) {
|
|
@@ -282,6 +289,53 @@ async function getCourseCurriculumForAdmin(req, res, next) {
|
|
| 282 |
}
|
| 283 |
}
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
module.exports = {
|
| 286 |
getCourseCurriculum,
|
| 287 |
getLessonAccess,
|
|
@@ -292,4 +346,5 @@ module.exports = {
|
|
| 292 |
uploadLesson,
|
| 293 |
updateLesson,
|
| 294 |
deleteLesson,
|
|
|
|
| 295 |
};
|
|
|
|
| 2 |
const mongoose = require("mongoose");
|
| 3 |
const Course = require("../models/Course");
|
| 4 |
const AuditLog = require("../models/AuditLog");
|
| 5 |
+
const {
|
| 6 |
+
uploadFromPath,
|
| 7 |
+
deleteAsset,
|
| 8 |
+
getSignedPlaybackUrl,
|
| 9 |
+
resourceTypeForMimetype,
|
| 10 |
+
uploadThumbnail: uploadThumbnailToCloudinary,
|
| 11 |
+
deleteThumbnail,
|
| 12 |
+
} = require("../utils/cloudinaryUpload");
|
| 13 |
const logger = require("../utils/logger");
|
| 14 |
|
| 15 |
function userHasAccess(user, courseId) {
|
|
|
|
| 289 |
}
|
| 290 |
}
|
| 291 |
|
| 292 |
+
/**
|
| 293 |
+
* Admin uploads/replaces a course's marketing thumbnail (shown on the
|
| 294 |
+
* homepage "Our Academy" preview and the Academy catalogue page). If
|
| 295 |
+
* the course already had one, the old Cloudinary asset is deleted so
|
| 296 |
+
* replacing a thumbnail doesn't leave the previous image as an
|
| 297 |
+
* orphaned file in storage.
|
| 298 |
+
*/
|
| 299 |
+
async function uploadThumbnail(req, res, next) {
|
| 300 |
+
try {
|
| 301 |
+
const course = await Course.findById(req.params.id);
|
| 302 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 303 |
+
if (!req.file) return res.status(400).json({ error: "No image file was uploaded." });
|
| 304 |
+
|
| 305 |
+
const previousPublicId = course.thumbnailPublicId;
|
| 306 |
+
|
| 307 |
+
const result = await uploadThumbnailToCloudinary(req.file.path, { filenameHint: course.slug });
|
| 308 |
+
|
| 309 |
+
course.image = result.secure_url;
|
| 310 |
+
course.thumbnailPublicId = result.public_id;
|
| 311 |
+
await course.save();
|
| 312 |
+
|
| 313 |
+
if (previousPublicId) {
|
| 314 |
+
deleteThumbnail(previousPublicId).catch((err) =>
|
| 315 |
+
logger.error("Failed to clean up replaced course thumbnail", { error: err.message, publicId: previousPublicId })
|
| 316 |
+
);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
await AuditLog.create({
|
| 320 |
+
actor: req.user._id,
|
| 321 |
+
actorEmail: req.user.email,
|
| 322 |
+
action: "admin.course_thumbnail_uploaded",
|
| 323 |
+
targetType: "Course",
|
| 324 |
+
targetId: course._id,
|
| 325 |
+
ip: req.ip,
|
| 326 |
+
userAgent: req.headers["user-agent"],
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
res.json({ course });
|
| 330 |
+
} catch (err) {
|
| 331 |
+
next(err);
|
| 332 |
+
} finally {
|
| 333 |
+
if (req.file) {
|
| 334 |
+
fs.unlink(req.file.path).catch(() => {});
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
module.exports = {
|
| 340 |
getCourseCurriculum,
|
| 341 |
getLessonAccess,
|
|
|
|
| 346 |
uploadLesson,
|
| 347 |
updateLesson,
|
| 348 |
deleteLesson,
|
| 349 |
+
uploadThumbnail,
|
| 350 |
};
|
src/controllers/course.controller.js
CHANGED
|
@@ -1,10 +1,43 @@
|
|
| 1 |
const Course = require("../models/Course");
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
async function listCourses(req, res, next) {
|
| 4 |
try {
|
| 5 |
const filter = req.user?.role === "admin" ? {} : { isPublished: true };
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
} catch (err) {
|
| 9 |
next(err);
|
| 10 |
}
|
|
|
|
| 1 |
const Course = require("../models/Course");
|
| 2 |
|
| 3 |
+
/**
|
| 4 |
+
* Public course catalogue listing β powers both the homepage "Our
|
| 5 |
+
* Academy" preview and the full Academy page. Supports the category
|
| 6 |
+
* filter, free-text search, and pagination the Academy page needs,
|
| 7 |
+
* since the number of courses is expected to keep growing.
|
| 8 |
+
*
|
| 9 |
+
* Query params (all optional):
|
| 10 |
+
* category - one of Course.CATEGORIES, exact match
|
| 11 |
+
* search - matched against title/description (case-insensitive)
|
| 12 |
+
* page - 1-indexed, default 1
|
| 13 |
+
* limit - default 12, capped at 50 to avoid huge unbounded responses
|
| 14 |
+
*/
|
| 15 |
async function listCourses(req, res, next) {
|
| 16 |
try {
|
| 17 |
const filter = req.user?.role === "admin" ? {} : { isPublished: true };
|
| 18 |
+
|
| 19 |
+
if (req.query.category) {
|
| 20 |
+
filter.category = req.query.category;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (req.query.search) {
|
| 24 |
+
const escaped = req.query.search.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 25 |
+
const pattern = new RegExp(escaped, "i");
|
| 26 |
+
filter.$or = [{ title: pattern }, { description: pattern }];
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
| 30 |
+
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 12));
|
| 31 |
+
|
| 32 |
+
const [courses, total] = await Promise.all([
|
| 33 |
+
Course.find(filter)
|
| 34 |
+
.sort({ createdAt: 1 })
|
| 35 |
+
.skip((page - 1) * limit)
|
| 36 |
+
.limit(limit),
|
| 37 |
+
Course.countDocuments(filter),
|
| 38 |
+
]);
|
| 39 |
+
|
| 40 |
+
res.json({ courses, total, page, pages: Math.max(1, Math.ceil(total / limit)) });
|
| 41 |
} catch (err) {
|
| 42 |
next(err);
|
| 43 |
}
|
src/controllers/order.controller.js
CHANGED
|
@@ -5,6 +5,7 @@ const AuditLog = require("../models/AuditLog");
|
|
| 5 |
const { generateOrderNumber } = require("../utils/orderNumber");
|
| 6 |
const { env } = require("../config/env");
|
| 7 |
const { sendOrderPlacedEmail, sendAdminNewOrderAlert } = require("../utils/email");
|
|
|
|
| 8 |
const logger = require("../utils/logger");
|
| 9 |
|
| 10 |
/**
|
|
@@ -107,9 +108,92 @@ async function createOrder(req, res, next) {
|
|
| 107 |
}
|
| 108 |
}
|
| 109 |
|
| 110 |
-
/**
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
const { reference, note } = req.body;
|
| 114 |
const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
|
| 115 |
if (!order) return res.status(404).json({ error: "Order not found." });
|
|
@@ -158,4 +242,11 @@ async function getMyOrder(req, res, next) {
|
|
| 158 |
}
|
| 159 |
}
|
| 160 |
|
| 161 |
-
module.exports = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
const { generateOrderNumber } = require("../utils/orderNumber");
|
| 6 |
const { env } = require("../config/env");
|
| 7 |
const { sendOrderPlacedEmail, sendAdminNewOrderAlert } = require("../utils/email");
|
| 8 |
+
const payoneerCheckout = require("../utils/payoneerCheckout");
|
| 9 |
const logger = require("../utils/logger");
|
| 10 |
|
| 11 |
/**
|
|
|
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
| 111 |
+
/**
|
| 112 |
+
* Creates an Order plus a hosted Payoneer Checkout session for it, and
|
| 113 |
+
* returns the redirect URL along with a clear amount/tax/total
|
| 114 |
+
* breakdown β the frontend shows that breakdown to the buyer before
|
| 115 |
+
* sending them to Payoneer, per the transparency requirement.
|
| 116 |
+
*
|
| 117 |
+
* Unlike createOrder (used for the manual bank_wire/payoneer-reference
|
| 118 |
+
* flow), this never puts the order in "awaiting_confirmation" for a
|
| 119 |
+
* human to review β it stays "pending_payment" until Payoneer's
|
| 120 |
+
* webhook confirms the payment (see webhook.controller.js), at which
|
| 121 |
+
* point it's auto-confirmed and access is granted automatically.
|
| 122 |
+
*/
|
| 123 |
+
async function createPayoneerCheckoutSession(req, res, next) {
|
| 124 |
try {
|
| 125 |
+
const { courseId } = req.body;
|
| 126 |
+
|
| 127 |
+
if (!mongoose.isValidObjectId(courseId)) {
|
| 128 |
+
return res.status(400).json({ error: "Invalid course selected." });
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const course = await Course.findById(courseId);
|
| 132 |
+
if (!course || !course.isPublished) {
|
| 133 |
+
return res.status(404).json({ error: "Course not found or unavailable." });
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (req.user.hasAccessToCourse(course._id)) {
|
| 137 |
+
return res.status(409).json({ error: "You already have access to this course." });
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const existingOpenOrder = await Order.findOne({
|
| 141 |
+
user: req.user._id,
|
| 142 |
+
course: course._id,
|
| 143 |
+
paymentMethod: "payoneer_checkout",
|
| 144 |
+
status: "pending_payment",
|
| 145 |
+
});
|
| 146 |
+
if (existingOpenOrder && existingOpenOrder.providerCheckoutUrl) {
|
| 147 |
+
return res.status(200).json({ order: existingOpenOrder.toPublicJSON(), reused: true });
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const order = new Order({
|
| 151 |
+
orderNumber: generateOrderNumber(),
|
| 152 |
+
user: req.user._id,
|
| 153 |
+
course: course._id,
|
| 154 |
+
courseTitleSnapshot: course.title,
|
| 155 |
+
amount: course.price,
|
| 156 |
+
currency: course.currency || "USD",
|
| 157 |
+
paymentMethod: "payoneer_checkout",
|
| 158 |
+
status: "pending_payment",
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
const session = await payoneerCheckout.createCheckoutSession({
|
| 162 |
+
order,
|
| 163 |
+
course,
|
| 164 |
+
customerEmail: req.user.email,
|
| 165 |
+
customerName: req.user.name,
|
| 166 |
+
successUrl: `${env.frontendUrl}/academy/checkout/${order._id}?result=success`,
|
| 167 |
+
cancelUrl: `${env.frontendUrl}/academy/checkout/${order._id}?result=cancelled`,
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
order.providerSessionId = session.sessionId;
|
| 171 |
+
order.providerCheckoutUrl = session.checkoutUrl;
|
| 172 |
+
if (session.tax) {
|
| 173 |
+
order.taxAmount = session.tax.amount;
|
| 174 |
+
order.taxCurrency = session.tax.currency;
|
| 175 |
+
}
|
| 176 |
+
await order.save();
|
| 177 |
+
|
| 178 |
+
await AuditLog.create({
|
| 179 |
+
actor: req.user._id,
|
| 180 |
+
actorEmail: req.user.email,
|
| 181 |
+
action: "order.checkout_session_created",
|
| 182 |
+
targetType: "Order",
|
| 183 |
+
targetId: order._id,
|
| 184 |
+
ip: req.ip,
|
| 185 |
+
userAgent: req.headers["user-agent"],
|
| 186 |
+
metadata: { courseId: course._id.toString() },
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
res.status(201).json({ order: order.toPublicJSON() });
|
| 190 |
+
} catch (err) {
|
| 191 |
+
next(err);
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/** Customer submits their bank wire reference / Payoneer transaction ID after paying. */
|
| 196 |
+
async function submitPaymentProof(req, res, next) { try {
|
| 197 |
const { reference, note } = req.body;
|
| 198 |
const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
|
| 199 |
if (!order) return res.status(404).json({ error: "Order not found." });
|
|
|
|
| 242 |
}
|
| 243 |
}
|
| 244 |
|
| 245 |
+
module.exports = {
|
| 246 |
+
getPaymentOptions,
|
| 247 |
+
createOrder,
|
| 248 |
+
createPayoneerCheckoutSession,
|
| 249 |
+
submitPaymentProof,
|
| 250 |
+
listMyOrders,
|
| 251 |
+
getMyOrder,
|
| 252 |
+
};
|
src/controllers/voucher.controller.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const Voucher = require("../models/Voucher");
|
| 3 |
+
const Order = require("../models/Order");
|
| 4 |
+
const User = require("../models/User");
|
| 5 |
+
const Course = require("../models/Course");
|
| 6 |
+
const AuditLog = require("../models/AuditLog");
|
| 7 |
+
const { generateOrderNumber } = require("../utils/orderNumber");
|
| 8 |
+
const { generateVoucherCode } = require("../utils/voucherCode");
|
| 9 |
+
const { sendVoucherRedeemedEmail } = require("../utils/email");
|
| 10 |
+
const logger = require("../utils/logger");
|
| 11 |
+
|
| 12 |
+
function normalizeEmails(emails) {
|
| 13 |
+
if (!Array.isArray(emails)) return [];
|
| 14 |
+
return [...new Set(emails.map((e) => String(e).toLowerCase().trim()).filter(Boolean))];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// ---------------------------------------------------------------------
|
| 18 |
+
// Admin: create / list / inspect / deactivate vouchers
|
| 19 |
+
// ---------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Creates a voucher for a specific course. If no code is supplied, one is
|
| 23 |
+
* generated server-side (retried on the astronomically unlikely chance of
|
| 24 |
+
* a collision) so admins can just click "Generate" without inventing a
|
| 25 |
+
* code themselves.
|
| 26 |
+
*/
|
| 27 |
+
async function createVoucher(req, res, next) {
|
| 28 |
+
try {
|
| 29 |
+
const { courseId, code, maxRedemptions, restrictedToEmails, expiresAt, note } = req.body;
|
| 30 |
+
|
| 31 |
+
if (!mongoose.isValidObjectId(courseId)) {
|
| 32 |
+
return res.status(400).json({ error: "Invalid course selected." });
|
| 33 |
+
}
|
| 34 |
+
const course = await Course.findById(courseId);
|
| 35 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 36 |
+
|
| 37 |
+
let finalCode = code ? String(code).toUpperCase().trim() : null;
|
| 38 |
+
if (finalCode) {
|
| 39 |
+
const existing = await Voucher.findOne({ code: finalCode });
|
| 40 |
+
if (existing) return res.status(409).json({ error: "That voucher code is already in use." });
|
| 41 |
+
} else {
|
| 42 |
+
for (let attempt = 0; attempt < 5 && !finalCode; attempt++) {
|
| 43 |
+
const candidate = generateVoucherCode();
|
| 44 |
+
// eslint-disable-next-line no-await-in-loop
|
| 45 |
+
const existing = await Voucher.findOne({ code: candidate });
|
| 46 |
+
if (!existing) finalCode = candidate;
|
| 47 |
+
}
|
| 48 |
+
if (!finalCode) {
|
| 49 |
+
return res.status(500).json({ error: "Could not generate a unique voucher code. Please try again." });
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const voucher = await Voucher.create({
|
| 54 |
+
code: finalCode,
|
| 55 |
+
course: course._id,
|
| 56 |
+
createdBy: req.user._id,
|
| 57 |
+
maxRedemptions: maxRedemptions || 1,
|
| 58 |
+
restrictedToEmails: normalizeEmails(restrictedToEmails),
|
| 59 |
+
expiresAt: expiresAt || null,
|
| 60 |
+
note: note || "",
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
await AuditLog.create({
|
| 64 |
+
actor: req.user._id,
|
| 65 |
+
actorEmail: req.user.email,
|
| 66 |
+
action: "admin.voucher_created",
|
| 67 |
+
targetType: "Voucher",
|
| 68 |
+
targetId: voucher._id,
|
| 69 |
+
ip: req.ip,
|
| 70 |
+
userAgent: req.headers["user-agent"],
|
| 71 |
+
metadata: {
|
| 72 |
+
courseId: course._id.toString(),
|
| 73 |
+
maxRedemptions: voucher.maxRedemptions,
|
| 74 |
+
restrictedToEmails: voucher.restrictedToEmails,
|
| 75 |
+
},
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
res.status(201).json({ voucher: voucher.toAdminJSON() });
|
| 79 |
+
} catch (err) {
|
| 80 |
+
if (err && err.code === 11000) {
|
| 81 |
+
return res.status(409).json({ error: "That voucher code is already in use." });
|
| 82 |
+
}
|
| 83 |
+
next(err);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async function listVouchers(req, res, next) {
|
| 88 |
+
try {
|
| 89 |
+
const { courseId, active } = req.query;
|
| 90 |
+
const filter = {};
|
| 91 |
+
if (courseId && mongoose.isValidObjectId(courseId)) filter.course = courseId;
|
| 92 |
+
if (active === "true") filter.isActive = true;
|
| 93 |
+
if (active === "false") filter.isActive = false;
|
| 94 |
+
|
| 95 |
+
const vouchers = await Voucher.find(filter)
|
| 96 |
+
.sort({ createdAt: -1 })
|
| 97 |
+
.populate("course", "title")
|
| 98 |
+
.populate("redemptions.user", "name email");
|
| 99 |
+
|
| 100 |
+
res.json({
|
| 101 |
+
vouchers: vouchers.map((v) => ({
|
| 102 |
+
...v.toAdminJSON(),
|
| 103 |
+
course: v.course ? { id: v.course._id, title: v.course.title } : null,
|
| 104 |
+
redemptions: v.redemptions.map((r) => ({
|
| 105 |
+
user: r.user ? { id: r.user._id, name: r.user.name, email: r.user.email } : null,
|
| 106 |
+
order: r.order,
|
| 107 |
+
redeemedAt: r.redeemedAt,
|
| 108 |
+
})),
|
| 109 |
+
})),
|
| 110 |
+
});
|
| 111 |
+
} catch (err) {
|
| 112 |
+
next(err);
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async function getVoucher(req, res, next) {
|
| 117 |
+
try {
|
| 118 |
+
const voucher = await Voucher.findById(req.params.id)
|
| 119 |
+
.populate("course", "title")
|
| 120 |
+
.populate("redemptions.user", "name email");
|
| 121 |
+
if (!voucher) return res.status(404).json({ error: "Voucher not found." });
|
| 122 |
+
|
| 123 |
+
res.json({
|
| 124 |
+
voucher: {
|
| 125 |
+
...voucher.toAdminJSON(),
|
| 126 |
+
course: voucher.course ? { id: voucher.course._id, title: voucher.course.title } : null,
|
| 127 |
+
redemptions: voucher.redemptions.map((r) => ({
|
| 128 |
+
user: r.user ? { id: r.user._id, name: r.user.name, email: r.user.email } : null,
|
| 129 |
+
order: r.order,
|
| 130 |
+
redeemedAt: r.redeemedAt,
|
| 131 |
+
})),
|
| 132 |
+
},
|
| 133 |
+
});
|
| 134 |
+
} catch (err) {
|
| 135 |
+
next(err);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function makeSetVoucherActiveHandler(isActive) {
|
| 140 |
+
return async function handler(req, res, next) {
|
| 141 |
+
try {
|
| 142 |
+
const voucher = await Voucher.findById(req.params.id);
|
| 143 |
+
if (!voucher) return res.status(404).json({ error: "Voucher not found." });
|
| 144 |
+
|
| 145 |
+
voucher.isActive = isActive;
|
| 146 |
+
await voucher.save();
|
| 147 |
+
|
| 148 |
+
await AuditLog.create({
|
| 149 |
+
actor: req.user._id,
|
| 150 |
+
actorEmail: req.user.email,
|
| 151 |
+
action: isActive ? "admin.voucher_reactivated" : "admin.voucher_deactivated",
|
| 152 |
+
targetType: "Voucher",
|
| 153 |
+
targetId: voucher._id,
|
| 154 |
+
ip: req.ip,
|
| 155 |
+
userAgent: req.headers["user-agent"],
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
res.json({ voucher: voucher.toAdminJSON() });
|
| 159 |
+
} catch (err) {
|
| 160 |
+
next(err);
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const deactivateVoucher = makeSetVoucherActiveHandler(false);
|
| 166 |
+
const reactivateVoucher = makeSetVoucherActiveHandler(true);
|
| 167 |
+
|
| 168 |
+
// ---------------------------------------------------------------------
|
| 169 |
+
// User: redeem a voucher code for free course access
|
| 170 |
+
// ---------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Redeeming a voucher mirrors utils/orderConfirmation.js's
|
| 174 |
+
* confirmOrderAndGrantAccess: it produces a real (already-confirmed,
|
| 175 |
+
* zero-amount) Order so the course shows up consistently in "my
|
| 176 |
+
* orders"/admin reporting, and grants access the same way a paid order
|
| 177 |
+
* would. Wrapped in a transaction so the order, the user's enrolment,
|
| 178 |
+
* and the voucher's redemption count can never drift out of sync.
|
| 179 |
+
*/
|
| 180 |
+
async function redeemVoucher(req, res, next) {
|
| 181 |
+
const { code } = req.body;
|
| 182 |
+
const normalizedCode = String(code || "")
|
| 183 |
+
.toUpperCase()
|
| 184 |
+
.trim();
|
| 185 |
+
|
| 186 |
+
const session = await mongoose.startSession();
|
| 187 |
+
try {
|
| 188 |
+
let resultOrder;
|
| 189 |
+
let resultCourse;
|
| 190 |
+
let blockReason;
|
| 191 |
+
|
| 192 |
+
await session.withTransaction(async () => {
|
| 193 |
+
const voucher = await Voucher.findOne({ code: normalizedCode }).session(session);
|
| 194 |
+
if (!voucher) {
|
| 195 |
+
blockReason = "That voucher code isn't valid.";
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const course = await Course.findById(voucher.course).session(session);
|
| 200 |
+
if (!course || !course.isPublished) {
|
| 201 |
+
blockReason = "That voucher's course is no longer available.";
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (req.user.hasAccessToCourse(course._id)) {
|
| 206 |
+
blockReason = "You already have access to this course.";
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const reason = voucher.redemptionBlockReason(req.user);
|
| 211 |
+
if (reason) {
|
| 212 |
+
blockReason = reason;
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const order = new Order({
|
| 217 |
+
orderNumber: generateOrderNumber(),
|
| 218 |
+
user: req.user._id,
|
| 219 |
+
course: course._id,
|
| 220 |
+
courseTitleSnapshot: course.title,
|
| 221 |
+
amount: 0,
|
| 222 |
+
currency: course.currency || "USD",
|
| 223 |
+
paymentMethod: "voucher",
|
| 224 |
+
voucher: voucher._id,
|
| 225 |
+
status: "confirmed",
|
| 226 |
+
confirmedAt: new Date(),
|
| 227 |
+
});
|
| 228 |
+
await order.save({ session });
|
| 229 |
+
|
| 230 |
+
const user = await User.findById(req.user._id).session(session);
|
| 231 |
+
user.enrolledCourses.push({ course: course._id, order: order._id, accessGrantedAt: new Date() });
|
| 232 |
+
await user.save({ session });
|
| 233 |
+
|
| 234 |
+
voucher.redemptions.push({ user: user._id, order: order._id, redeemedAt: new Date() });
|
| 235 |
+
await voucher.save({ session });
|
| 236 |
+
|
| 237 |
+
resultOrder = order;
|
| 238 |
+
resultCourse = course;
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
if (blockReason) {
|
| 242 |
+
return res.status(409).json({ error: blockReason });
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
await AuditLog.create({
|
| 246 |
+
actor: req.user._id,
|
| 247 |
+
actorEmail: req.user.email,
|
| 248 |
+
action: "voucher.redeemed",
|
| 249 |
+
targetType: "Order",
|
| 250 |
+
targetId: resultOrder._id,
|
| 251 |
+
ip: req.ip,
|
| 252 |
+
userAgent: req.headers["user-agent"],
|
| 253 |
+
metadata: { courseId: resultCourse._id.toString(), voucherCode: normalizedCode },
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
sendVoucherRedeemedEmail({
|
| 257 |
+
to: req.user.email,
|
| 258 |
+
name: req.user.name,
|
| 259 |
+
order: resultOrder,
|
| 260 |
+
courseTitle: resultCourse.title,
|
| 261 |
+
voucherCode: normalizedCode,
|
| 262 |
+
}).catch((err) => logger.error("Voucher-redeemed email failed", { error: err.message }));
|
| 263 |
+
|
| 264 |
+
res.status(201).json({ order: resultOrder.toPublicJSON() });
|
| 265 |
+
} catch (err) {
|
| 266 |
+
next(err);
|
| 267 |
+
} finally {
|
| 268 |
+
session.endSession();
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
module.exports = {
|
| 273 |
+
createVoucher,
|
| 274 |
+
listVouchers,
|
| 275 |
+
getVoucher,
|
| 276 |
+
deactivateVoucher,
|
| 277 |
+
reactivateVoucher,
|
| 278 |
+
redeemVoucher,
|
| 279 |
+
};
|
src/controllers/webhook.controller.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Order = require("../models/Order");
|
| 2 |
+
const payoneerCheckout = require("../utils/payoneerCheckout");
|
| 3 |
+
const { confirmOrderAndGrantAccess } = require("../utils/orderConfirmation");
|
| 4 |
+
const logger = require("../utils/logger");
|
| 5 |
+
|
| 6 |
+
// PAYONEER-DOCS-NEEDED: confirm the actual header name Payoneer sends
|
| 7 |
+
// the webhook signature in β this is a placeholder guess following
|
| 8 |
+
// common convention (e.g. Stripe uses "Stripe-Signature").
|
| 9 |
+
const SIGNATURE_HEADER = "x-payoneer-signature";
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Receives Payoneer Checkout's payment-status webhook and, on a
|
| 13 |
+
* successful payment, automatically confirms the matching Order and
|
| 14 |
+
* grants the buyer access β no admin involved, unlike the manual
|
| 15 |
+
* bank_wire/payoneer-reference flow.
|
| 16 |
+
*
|
| 17 |
+
* Registered in app.js with a raw body parser (see
|
| 18 |
+
* routes/webhook.routes.js) so `req.body` here is the exact raw
|
| 19 |
+
* bytes Payoneer signed, not the parsed JSON the rest of the app uses.
|
| 20 |
+
*
|
| 21 |
+
* Always responds 200 once the webhook is understood (even for a
|
| 22 |
+
* "not our order" or "already confirmed" case) so Payoneer doesn't
|
| 23 |
+
* keep retrying a webhook we've already handled. Only a genuinely bad
|
| 24 |
+
* signature or unreadable payload gets a 4xx.
|
| 25 |
+
*/
|
| 26 |
+
async function handlePayoneerWebhook(req, res) {
|
| 27 |
+
let parsed;
|
| 28 |
+
try {
|
| 29 |
+
parsed = payoneerCheckout.verifyAndParseWebhook({
|
| 30 |
+
rawBody: req.body,
|
| 31 |
+
signatureHeader: req.headers[SIGNATURE_HEADER],
|
| 32 |
+
});
|
| 33 |
+
} catch (err) {
|
| 34 |
+
logger.error("Payoneer webhook rejected", { error: err.message });
|
| 35 |
+
return res.status(err.status || 400).json({ error: "Invalid webhook." });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
if (parsed.status !== "paid") {
|
| 40 |
+
// Payoneer also sends non-success events (failed, cancelled,
|
| 41 |
+
// pending, etc.) β nothing to do for those except acknowledge.
|
| 42 |
+
logger.info("Payoneer webhook received (non-paid status, no action)", { status: parsed.status });
|
| 43 |
+
return res.status(200).json({ received: true });
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const order = await Order.findOne({ providerSessionId: parsed.sessionId, paymentMethod: "payoneer_checkout" });
|
| 47 |
+
if (!order) {
|
| 48 |
+
logger.error("Payoneer webhook: no matching order for session", { sessionId: parsed.sessionId });
|
| 49 |
+
// Still 200 β this is Payoneer's event, not a request we want retried forever.
|
| 50 |
+
return res.status(200).json({ received: true, matched: false });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const { alreadyConfirmed } = await confirmOrderAndGrantAccess({ orderId: order._id, confirmedBy: null });
|
| 54 |
+
|
| 55 |
+
logger.info("Payoneer webhook: order auto-confirmed", {
|
| 56 |
+
orderId: order._id.toString(),
|
| 57 |
+
alreadyConfirmed,
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
res.status(200).json({ received: true, matched: true });
|
| 61 |
+
} catch (err) {
|
| 62 |
+
logger.error("Payoneer webhook processing failed", { error: err.message });
|
| 63 |
+
// 200 anyway: a bug on our side confirming the order shouldn't
|
| 64 |
+
// cause Payoneer to hammer us with retries for an event we did
|
| 65 |
+
// successfully authenticate and understand. This case should
|
| 66 |
+
// instead be caught by monitoring your server logs.
|
| 67 |
+
res.status(200).json({ received: true, error: "processing_failed" });
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
module.exports = { handlePayoneerWebhook };
|
src/middleware/rateLimiters.js
CHANGED
|
@@ -28,4 +28,16 @@ const orderLimiter = rateLimit({
|
|
| 28 |
message: { error: "Too many order attempts. Please try again later or contact support." },
|
| 29 |
});
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
message: { error: "Too many order attempts. Please try again later or contact support." },
|
| 29 |
});
|
| 30 |
|
| 31 |
+
// Voucher codes are bearer credentials, so redemption attempts are rate
|
| 32 |
+
// limited per client the same way login attempts are, to blunt brute
|
| 33 |
+
// forcing/guessing of codes.
|
| 34 |
+
const voucherRedeemLimiter = rateLimit({
|
| 35 |
+
windowMs: 15 * 60 * 1000,
|
| 36 |
+
limit: 15,
|
| 37 |
+
standardHeaders: true,
|
| 38 |
+
legacyHeaders: false,
|
| 39 |
+
skipSuccessfulRequests: true,
|
| 40 |
+
message: { error: "Too many voucher attempts. Please wait a few minutes and try again." },
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
module.exports = { apiLimiter, authLimiter, orderLimiter, voucherRedeemLimiter };
|
src/models/Course.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
| 1 |
-
const mongoose = require("mongoose");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
const lessonSchema = new mongoose.Schema(
|
| 4 |
{
|
|
@@ -50,7 +61,18 @@ const courseSchema = new mongoose.Schema(
|
|
| 50 |
duration: { type: String, required: true, trim: true },
|
| 51 |
level: { type: String, required: true, trim: true },
|
| 52 |
description: { type: String, required: true },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
image: { type: String, default: "" },
|
|
|
|
|
|
|
| 54 |
highlights: { type: [String], default: [] },
|
| 55 |
isPublished: { type: Boolean, default: true },
|
| 56 |
|
|
@@ -100,5 +122,6 @@ courseSchema.methods.toLockedCurriculumJSON = function toLockedCurriculumJSON()
|
|
| 100 |
}));
|
| 101 |
};
|
| 102 |
|
| 103 |
-
module.exports = mongoose.model("Course", courseSchema);
|
|
|
|
| 104 |
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
|
| 3 |
+
const CATEGORIES = [
|
| 4 |
+
"Artificial Intelligence",
|
| 5 |
+
"Cloud Computing",
|
| 6 |
+
"Quantum Computing",
|
| 7 |
+
"Cyber Security",
|
| 8 |
+
"Networking",
|
| 9 |
+
"Systems Engineering",
|
| 10 |
+
"Professional Ethics",
|
| 11 |
+
"Robotics",
|
| 12 |
+
];
|
| 13 |
|
| 14 |
const lessonSchema = new mongoose.Schema(
|
| 15 |
{
|
|
|
|
| 61 |
duration: { type: String, required: true, trim: true },
|
| 62 |
level: { type: String, required: true, trim: true },
|
| 63 |
description: { type: String, required: true },
|
| 64 |
+
category: { type: String, enum: CATEGORIES, required: true, index: true },
|
| 65 |
+
|
| 66 |
+
// Marketing thumbnail shown on the homepage "Our Academy" preview and
|
| 67 |
+
// on the Academy catalogue page. Uploaded by an admin as an actual
|
| 68 |
+
// image file (see content.controller.js -> uploadThumbnail), stored
|
| 69 |
+
// in Cloudinary as a plain PUBLIC asset (type: "upload") β unlike
|
| 70 |
+
// lesson videos/documents, a thumbnail is meant to be publicly
|
| 71 |
+
// visible to any visitor browsing the storefront, so it deliberately
|
| 72 |
+
// does NOT use the authenticated/signed-URL scheme those use.
|
| 73 |
image: { type: String, default: "" },
|
| 74 |
+
thumbnailPublicId: { type: String, default: "" },
|
| 75 |
+
|
| 76 |
highlights: { type: [String], default: [] },
|
| 77 |
isPublished: { type: Boolean, default: true },
|
| 78 |
|
|
|
|
| 122 |
}));
|
| 123 |
};
|
| 124 |
|
| 125 |
+
module.exports = mongoose.model("Course", courseSchema);
|
| 126 |
+
module.exports.CATEGORIES = CATEGORIES;
|
| 127 |
|
src/models/Order.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
const mongoose = require("mongoose");
|
| 2 |
const { encryptField, decryptField, hmacIndex } = require("../utils/crypto");
|
| 3 |
|
| 4 |
-
const PAYMENT_METHODS = ["bank_wire", "payoneer"];
|
| 5 |
const ORDER_STATUSES = ["pending_payment", "awaiting_confirmation", "confirmed", "rejected", "cancelled"];
|
| 6 |
|
| 7 |
const orderSchema = new mongoose.Schema(
|
|
@@ -16,7 +16,21 @@ const orderSchema = new mongoose.Schema(
|
|
| 16 |
amount: { type: Number, required: true, min: 0 },
|
| 17 |
currency: { type: String, default: "USD" },
|
| 18 |
|
| 19 |
-
paymentMethod: { type: String, enum: PAYMENT_METHODS, required: true },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
// Sensitive: encrypted at rest with AES-256-GCM. Never returned by
|
| 22 |
// default queries (select: false) and never logged in plaintext.
|
|
@@ -63,7 +77,12 @@ orderSchema.methods.toPublicJSON = function toPublicJSON({ includeSensitive = fa
|
|
| 63 |
courseTitleSnapshot: this.courseTitleSnapshot,
|
| 64 |
amount: this.amount,
|
| 65 |
currency: this.currency,
|
|
|
|
|
|
|
|
|
|
| 66 |
paymentMethod: this.paymentMethod,
|
|
|
|
|
|
|
| 67 |
status: this.status,
|
| 68 |
confirmedAt: this.confirmedAt,
|
| 69 |
rejectionReason: this.rejectionReason,
|
|
|
|
| 1 |
const mongoose = require("mongoose");
|
| 2 |
const { encryptField, decryptField, hmacIndex } = require("../utils/crypto");
|
| 3 |
|
| 4 |
+
const PAYMENT_METHODS = ["bank_wire", "payoneer", "payoneer_checkout", "voucher"];
|
| 5 |
const ORDER_STATUSES = ["pending_payment", "awaiting_confirmation", "confirmed", "rejected", "cancelled"];
|
| 6 |
|
| 7 |
const orderSchema = new mongoose.Schema(
|
|
|
|
| 16 |
amount: { type: Number, required: true, min: 0 },
|
| 17 |
currency: { type: String, default: "USD" },
|
| 18 |
|
| 19 |
+
paymentMethod: { type: String, enum: PAYMENT_METHODS, required: true },
|
| 20 |
+
|
| 21 |
+
// Set only when paymentMethod === "voucher": the voucher whose
|
| 22 |
+
// redemption produced this (free, pre-confirmed) order.
|
| 23 |
+
voucher: { type: mongoose.Schema.Types.ObjectId, ref: "Voucher", default: null, index: true },
|
| 24 |
+
|
| 25 |
+
// --- Automated Payoneer Checkout fields (paymentMethod ===
|
| 26 |
+
// "payoneer_checkout" only). Populated when the checkout session is
|
| 27 |
+
// created and used to (a) match an incoming webhook back to this
|
| 28 |
+
// order, and (b) show the buyer a clear tax breakdown before they're
|
| 29 |
+
// sent to Payoneer to actually pay. ---
|
| 30 |
+
providerSessionId: { type: String, default: null, index: true },
|
| 31 |
+
providerCheckoutUrl: { type: String, default: null },
|
| 32 |
+
taxAmount: { type: Number, default: 0, min: 0 },
|
| 33 |
+
taxCurrency: { type: String, default: null },
|
| 34 |
|
| 35 |
// Sensitive: encrypted at rest with AES-256-GCM. Never returned by
|
| 36 |
// default queries (select: false) and never logged in plaintext.
|
|
|
|
| 77 |
courseTitleSnapshot: this.courseTitleSnapshot,
|
| 78 |
amount: this.amount,
|
| 79 |
currency: this.currency,
|
| 80 |
+
taxAmount: this.taxAmount,
|
| 81 |
+
taxCurrency: this.taxCurrency,
|
| 82 |
+
totalAmount: this.amount + (this.taxAmount || 0),
|
| 83 |
paymentMethod: this.paymentMethod,
|
| 84 |
+
voucher: this.voucher || undefined,
|
| 85 |
+
providerCheckoutUrl: this.providerCheckoutUrl,
|
| 86 |
status: this.status,
|
| 87 |
confirmedAt: this.confirmedAt,
|
| 88 |
rejectionReason: this.rejectionReason,
|
src/models/Voucher.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
|
| 3 |
+
// Records that a specific user redeemed a specific voucher, and which
|
| 4 |
+
// (free) order that redemption produced β kept as a subdocument so a
|
| 5 |
+
// voucher's full redemption history is visible in one place without a
|
| 6 |
+
// second query.
|
| 7 |
+
const redemptionSchema = new mongoose.Schema(
|
| 8 |
+
{
|
| 9 |
+
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
|
| 10 |
+
order: { type: mongoose.Schema.Types.ObjectId, ref: "Order", required: true },
|
| 11 |
+
redeemedAt: { type: Date, default: Date.now },
|
| 12 |
+
},
|
| 13 |
+
{ _id: false }
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
const voucherSchema = new mongoose.Schema(
|
| 17 |
+
{
|
| 18 |
+
// Always stored upper-cased so lookups are case-insensitive without
|
| 19 |
+
// needing a collation β the code itself is not secret in the sense a
|
| 20 |
+
// password is, but it IS a bearer credential, so it's still generated
|
| 21 |
+
// with real randomness (see utils/voucherCode.js) rather than
|
| 22 |
+
// something guessable.
|
| 23 |
+
code: { type: String, required: true, unique: true, uppercase: true, trim: true, index: true },
|
| 24 |
+
|
| 25 |
+
course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true, index: true },
|
| 26 |
+
|
| 27 |
+
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
|
| 28 |
+
|
| 29 |
+
// How many different users may redeem this single code. Defaults to
|
| 30 |
+
// 1 (one voucher == one seat), but an admin can raise this to hand
|
| 31 |
+
// the same code to a small group (e.g. a workshop cohort) instead of
|
| 32 |
+
// generating one per person.
|
| 33 |
+
maxRedemptions: { type: Number, default: 1, min: 1, max: 10000 },
|
| 34 |
+
|
| 35 |
+
redemptions: { type: [redemptionSchema], default: [] },
|
| 36 |
+
|
| 37 |
+
// Optional allow-list of emails (lower-cased) this voucher may be
|
| 38 |
+
// redeemed by. Empty array = anyone who has the code may redeem it
|
| 39 |
+
// (up to maxRedemptions). Non-empty = only these accounts may, which
|
| 40 |
+
// is how an admin "gives a voucher to specific users" rather than
|
| 41 |
+
// just publishing an open promo code.
|
| 42 |
+
restrictedToEmails: { type: [String], default: [] },
|
| 43 |
+
|
| 44 |
+
expiresAt: { type: Date, default: null },
|
| 45 |
+
|
| 46 |
+
isActive: { type: Boolean, default: true },
|
| 47 |
+
|
| 48 |
+
// Free-text admin memo, e.g. "Scholarship cohort β Fall 2026". Never
|
| 49 |
+
// shown to the redeeming user.
|
| 50 |
+
note: { type: String, default: "", maxlength: 500 },
|
| 51 |
+
},
|
| 52 |
+
{ timestamps: true }
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
voucherSchema.methods.isExpired = function isExpired() {
|
| 56 |
+
return !!(this.expiresAt && this.expiresAt.getTime() < Date.now());
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
voucherSchema.methods.hasRemainingRedemptions = function hasRemainingRedemptions() {
|
| 60 |
+
return this.redemptions.length < this.maxRedemptions;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
voucherSchema.methods.isRedeemableBy = function isRedeemableBy(email) {
|
| 64 |
+
if (this.restrictedToEmails.length === 0) return true;
|
| 65 |
+
return this.restrictedToEmails.includes(String(email).toLowerCase().trim());
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
voucherSchema.methods.hasBeenRedeemedBy = function hasBeenRedeemedBy(userId) {
|
| 69 |
+
return this.redemptions.some((r) => r.user.toString() === userId.toString());
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
/** Why (if at all) this voucher currently can't be redeemed β null means it's valid to try. */
|
| 73 |
+
voucherSchema.methods.redemptionBlockReason = function redemptionBlockReason(user) {
|
| 74 |
+
if (!this.isActive) return "This voucher has been deactivated.";
|
| 75 |
+
if (this.isExpired()) return "This voucher has expired.";
|
| 76 |
+
if (!this.hasRemainingRedemptions()) return "This voucher has already been fully redeemed.";
|
| 77 |
+
if (!this.isRedeemableBy(user.email)) return "This voucher isn't assigned to your account.";
|
| 78 |
+
if (this.hasBeenRedeemedBy(user._id)) return "You've already redeemed this voucher.";
|
| 79 |
+
return null;
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
voucherSchema.methods.toAdminJSON = function toAdminJSON() {
|
| 83 |
+
return {
|
| 84 |
+
id: this._id,
|
| 85 |
+
code: this.code,
|
| 86 |
+
course: this.course,
|
| 87 |
+
createdBy: this.createdBy,
|
| 88 |
+
maxRedemptions: this.maxRedemptions,
|
| 89 |
+
redemptionsCount: this.redemptions.length,
|
| 90 |
+
redemptions: this.redemptions,
|
| 91 |
+
restrictedToEmails: this.restrictedToEmails,
|
| 92 |
+
expiresAt: this.expiresAt,
|
| 93 |
+
isActive: this.isActive,
|
| 94 |
+
isExpired: this.isExpired(),
|
| 95 |
+
note: this.note,
|
| 96 |
+
createdAt: this.createdAt,
|
| 97 |
+
};
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
module.exports = mongoose.model("Voucher", voucherSchema);
|
src/routes/admin.routes.js
CHANGED
|
@@ -5,6 +5,7 @@ const { requireAuth, requireRole } = require("../middleware/auth");
|
|
| 5 |
const { upload } = require("../middleware/upload");
|
| 6 |
const ctrl = require("../controllers/admin.controller");
|
| 7 |
const contentCtrl = require("../controllers/content.controller");
|
|
|
|
| 8 |
|
| 9 |
const router = express.Router();
|
| 10 |
|
|
@@ -45,5 +46,27 @@ router.post(
|
|
| 45 |
router.put("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.updateLesson);
|
| 46 |
router.delete("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.deleteLesson);
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
module.exports = router;
|
| 49 |
|
|
|
|
| 5 |
const { upload } = require("../middleware/upload");
|
| 6 |
const ctrl = require("../controllers/admin.controller");
|
| 7 |
const contentCtrl = require("../controllers/content.controller");
|
| 8 |
+
const voucherCtrl = require("../controllers/voucher.controller");
|
| 9 |
|
| 10 |
const router = express.Router();
|
| 11 |
|
|
|
|
| 46 |
router.put("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.updateLesson);
|
| 47 |
router.delete("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.deleteLesson);
|
| 48 |
|
| 49 |
+
router.post("/courses/:id/thumbnail", upload.single("file"), contentCtrl.uploadThumbnail);
|
| 50 |
+
|
| 51 |
+
// --- Vouchers: admins issue free-access codes for a specific course ----
|
| 52 |
+
router.get("/vouchers", voucherCtrl.listVouchers);
|
| 53 |
+
router.get("/vouchers/:id", voucherCtrl.getVoucher);
|
| 54 |
+
router.post(
|
| 55 |
+
"/vouchers",
|
| 56 |
+
[
|
| 57 |
+
body("courseId").isMongoId().withMessage("A valid course must be selected."),
|
| 58 |
+
body("code").optional({ nullable: true }).trim().isLength({ min: 3, max: 40 }),
|
| 59 |
+
body("maxRedemptions").optional({ nullable: true }).isInt({ min: 1, max: 10000 }),
|
| 60 |
+
body("restrictedToEmails").optional({ nullable: true }).isArray(),
|
| 61 |
+
body("restrictedToEmails.*").optional().isEmail(),
|
| 62 |
+
body("expiresAt").optional({ nullable: true }).isISO8601(),
|
| 63 |
+
body("note").optional({ nullable: true }).trim().isLength({ max: 500 }),
|
| 64 |
+
],
|
| 65 |
+
validate,
|
| 66 |
+
voucherCtrl.createVoucher
|
| 67 |
+
);
|
| 68 |
+
router.post("/vouchers/:id/deactivate", voucherCtrl.deactivateVoucher);
|
| 69 |
+
router.post("/vouchers/:id/reactivate", voucherCtrl.reactivateVoucher);
|
| 70 |
+
|
| 71 |
module.exports = router;
|
| 72 |
|
src/routes/course.routes.js
CHANGED
|
@@ -4,10 +4,12 @@ const { validate } = require("../middleware/validate");
|
|
| 4 |
const { requireAuth, requireRole } = require("../middleware/auth");
|
| 5 |
const ctrl = require("../controllers/course.controller");
|
| 6 |
const contentCtrl = require("../controllers/content.controller");
|
|
|
|
| 7 |
|
| 8 |
const router = express.Router();
|
| 9 |
|
| 10 |
router.get("/", ctrl.listCourses);
|
|
|
|
| 11 |
router.get("/:id", ctrl.getCourse);
|
| 12 |
|
| 13 |
// Curriculum viewing / lesson playback (learner-facing, requires login;
|
|
@@ -23,6 +25,7 @@ const courseValidators = [
|
|
| 23 |
body("duration").trim().notEmpty(),
|
| 24 |
body("level").trim().notEmpty(),
|
| 25 |
body("description").trim().notEmpty(),
|
|
|
|
| 26 |
];
|
| 27 |
|
| 28 |
router.post("/", requireAuth, requireRole("admin"), courseValidators, validate, ctrl.createCourse);
|
|
|
|
| 4 |
const { requireAuth, requireRole } = require("../middleware/auth");
|
| 5 |
const ctrl = require("../controllers/course.controller");
|
| 6 |
const contentCtrl = require("../controllers/content.controller");
|
| 7 |
+
const { CATEGORIES } = require("../models/Course");
|
| 8 |
|
| 9 |
const router = express.Router();
|
| 10 |
|
| 11 |
router.get("/", ctrl.listCourses);
|
| 12 |
+
router.get("/categories", (req, res) => res.json({ categories: CATEGORIES }));
|
| 13 |
router.get("/:id", ctrl.getCourse);
|
| 14 |
|
| 15 |
// Curriculum viewing / lesson playback (learner-facing, requires login;
|
|
|
|
| 25 |
body("duration").trim().notEmpty(),
|
| 26 |
body("level").trim().notEmpty(),
|
| 27 |
body("description").trim().notEmpty(),
|
| 28 |
+
body("category").isIn(CATEGORIES).withMessage(`Category must be one of: ${CATEGORIES.join(", ")}`),
|
| 29 |
];
|
| 30 |
|
| 31 |
router.post("/", requireAuth, requireRole("admin"), courseValidators, validate, ctrl.createCourse);
|
src/routes/order.routes.js
CHANGED
|
@@ -26,6 +26,14 @@ router.post(
|
|
| 26 |
ctrl.createOrder
|
| 27 |
);
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
router.post(
|
| 30 |
"/:id/payment-proof",
|
| 31 |
orderLimiter,
|
|
|
|
| 26 |
ctrl.createOrder
|
| 27 |
);
|
| 28 |
|
| 29 |
+
router.post(
|
| 30 |
+
"/payoneer/checkout-session",
|
| 31 |
+
orderLimiter,
|
| 32 |
+
[body("courseId").isMongoId().withMessage("A valid course must be selected.")],
|
| 33 |
+
validate,
|
| 34 |
+
ctrl.createPayoneerCheckoutSession
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
router.post(
|
| 38 |
"/:id/payment-proof",
|
| 39 |
orderLimiter,
|
src/routes/voucher.routes.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { body } = require("express-validator");
|
| 3 |
+
const { validate } = require("../middleware/validate");
|
| 4 |
+
const { requireAuth } = require("../middleware/auth");
|
| 5 |
+
const { voucherRedeemLimiter } = require("../middleware/rateLimiters");
|
| 6 |
+
const ctrl = require("../controllers/voucher.controller");
|
| 7 |
+
|
| 8 |
+
const router = express.Router();
|
| 9 |
+
|
| 10 |
+
router.use(requireAuth);
|
| 11 |
+
|
| 12 |
+
router.post(
|
| 13 |
+
"/redeem",
|
| 14 |
+
voucherRedeemLimiter,
|
| 15 |
+
[body("code").trim().isLength({ min: 3, max: 40 }).withMessage("Enter a voucher code.")],
|
| 16 |
+
validate,
|
| 17 |
+
ctrl.redeemVoucher
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
module.exports = router;
|
src/routes/webhook.routes.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { handlePayoneerWebhook } = require("../controllers/webhook.controller");
|
| 3 |
+
|
| 4 |
+
const router = express.Router();
|
| 5 |
+
|
| 6 |
+
router.post("/payoneer", handlePayoneerWebhook);
|
| 7 |
+
|
| 8 |
+
module.exports = router;
|
src/seed/seed.js
CHANGED
|
@@ -1,128 +1,134 @@
|
|
| 1 |
-
/* eslint-disable no-console */
|
| 2 |
-
const { env } = require("../config/env");
|
| 3 |
-
const { connectDB, disconnectDB } = require("../config/db");
|
| 4 |
-
const User = require("../models/User");
|
| 5 |
-
const Course = require("../models/Course");
|
| 6 |
-
|
| 7 |
-
const courses = [
|
| 8 |
-
{
|
| 9 |
-
title: "Artificial Intelligence Essentials",
|
| 10 |
-
slug: "artificial-intelligence-essentials",
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
"
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
});
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable no-console */
|
| 2 |
+
const { env } = require("../config/env");
|
| 3 |
+
const { connectDB, disconnectDB } = require("../config/db");
|
| 4 |
+
const User = require("../models/User");
|
| 5 |
+
const Course = require("../models/Course");
|
| 6 |
+
|
| 7 |
+
const courses = [
|
| 8 |
+
{
|
| 9 |
+
title: "Artificial Intelligence Essentials",
|
| 10 |
+
slug: "artificial-intelligence-essentials",
|
| 11 |
+
category: "Artificial Intelligence",
|
| 12 |
+
price: 350,
|
| 13 |
+
duration: "8 weeks",
|
| 14 |
+
level: "Beginner",
|
| 15 |
+
description:
|
| 16 |
+
"A practical introduction to AI: learn what AI can (and cannot) do, work with real datasets, and build your first machine-learning models.",
|
| 17 |
+
highlights: [
|
| 18 |
+
"AI concepts, terminology and real-world use cases",
|
| 19 |
+
"Hands-on Python & ML experiments",
|
| 20 |
+
"Build a simple predictive model end-to-end",
|
| 21 |
+
],
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
title: "Cyber Security Essentials",
|
| 25 |
+
slug: "cyber-security-essentials",
|
| 26 |
+
category: "Cyber Security",
|
| 27 |
+
price: 300,
|
| 28 |
+
duration: "6 weeks",
|
| 29 |
+
level: "Beginner",
|
| 30 |
+
description:
|
| 31 |
+
"Learn the foundations of cyber defense: protect devices, networks, and data against the most common digital threats.",
|
| 32 |
+
highlights: [
|
| 33 |
+
"Threat landscape and attacker tactics",
|
| 34 |
+
"Secure browsing, password & identity hygiene",
|
| 35 |
+
"Basic network and endpoint protection",
|
| 36 |
+
],
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
title: "AI-Powered Cybersecurity",
|
| 40 |
+
slug: "ai-powered-cybersecurity",
|
| 41 |
+
category: "Cyber Security",
|
| 42 |
+
price: 550,
|
| 43 |
+
duration: "10 weeks",
|
| 44 |
+
level: "Intermediate to Advanced",
|
| 45 |
+
description:
|
| 46 |
+
"Combine AI with security operations: deploy intelligent threat detection, automate incident response, and build resilient SOC workflows.",
|
| 47 |
+
highlights: [
|
| 48 |
+
"ML-based threat detection and anomaly analytics",
|
| 49 |
+
"Automated incident response playbooks",
|
| 50 |
+
"AI governance, ethics and adversarial risks",
|
| 51 |
+
],
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
title: "The AI Career Guidance",
|
| 55 |
+
slug: "the-ai-career-guidance",
|
| 56 |
+
category: "Professional Ethics",
|
| 57 |
+
price: 200,
|
| 58 |
+
duration: "4 weeks",
|
| 59 |
+
level: "Career Foundations",
|
| 60 |
+
description:
|
| 61 |
+
"Map your path into the AI economy. Discover roles, build a portfolio, and prepare for interviews with guided mentorship.",
|
| 62 |
+
highlights: [
|
| 63 |
+
"AI career landscape and skill roadmap",
|
| 64 |
+
"Portfolio project planning and resume review",
|
| 65 |
+
"Mock interviews and mentorship sessions",
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
title: "AI for Business Managers",
|
| 70 |
+
slug: "ai-for-business-managers",
|
| 71 |
+
category: "Professional Ethics",
|
| 72 |
+
price: 450,
|
| 73 |
+
duration: "6 weeks",
|
| 74 |
+
level: "Manager Level",
|
| 75 |
+
description:
|
| 76 |
+
"Learn how to evaluate, procure, and manage AI projects that drive real business value without needing to code.",
|
| 77 |
+
highlights: [
|
| 78 |
+
"AI strategy, ROI and use-case prioritisation",
|
| 79 |
+
"Managing data, vendors and project risk",
|
| 80 |
+
"Leading AI-enabled teams and change",
|
| 81 |
+
],
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
title: "AI for Business Executives",
|
| 85 |
+
slug: "ai-for-business-executives",
|
| 86 |
+
category: "Professional Ethics",
|
| 87 |
+
price: 650,
|
| 88 |
+
duration: "5 weeks",
|
| 89 |
+
level: "Executive Level",
|
| 90 |
+
description:
|
| 91 |
+
"A leadership-level course on AI governance, competitive strategy, and building an AI-ready organisation.",
|
| 92 |
+
highlights: [
|
| 93 |
+
"Board-level AI vision and investment decisions",
|
| 94 |
+
"Regulatory, ethical and governance frameworks",
|
| 95 |
+
"Building an AI culture and Centre of Excellence",
|
| 96 |
+
],
|
| 97 |
+
},
|
| 98 |
+
];
|
| 99 |
+
|
| 100 |
+
async function seed() {
|
| 101 |
+
await connectDB();
|
| 102 |
+
|
| 103 |
+
for (const c of courses) {
|
| 104 |
+
await Course.findOneAndUpdate({ slug: c.slug }, c, { upsert: true, new: true, setDefaultsOnInsert: true });
|
| 105 |
+
}
|
| 106 |
+
console.log(`Seeded ${courses.length} courses.`);
|
| 107 |
+
|
| 108 |
+
if (env.seedAdmin.email && env.seedAdmin.password) {
|
| 109 |
+
const existingAdmin = await User.findOne({ email: env.seedAdmin.email.toLowerCase() });
|
| 110 |
+
if (existingAdmin) {
|
| 111 |
+
console.log(`Admin account already exists for ${env.seedAdmin.email}, skipping.`);
|
| 112 |
+
} else {
|
| 113 |
+
const admin = new User({
|
| 114 |
+
name: env.seedAdmin.name,
|
| 115 |
+
email: env.seedAdmin.email.toLowerCase(),
|
| 116 |
+
role: "admin",
|
| 117 |
+
});
|
| 118 |
+
await admin.setPassword(env.seedAdmin.password);
|
| 119 |
+
await admin.save();
|
| 120 |
+
console.log(`Created admin account: ${env.seedAdmin.email}`);
|
| 121 |
+
console.log("IMPORTANT: log in and change this password immediately.");
|
| 122 |
+
}
|
| 123 |
+
} else {
|
| 124 |
+
console.log("SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set β skipping admin creation.");
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
await disconnectDB();
|
| 128 |
+
process.exit(0);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
seed().catch((err) => {
|
| 132 |
+
console.error("Seeding failed:", err);
|
| 133 |
+
process.exit(1);
|
| 134 |
+
});
|
src/utils/cloudinaryUpload.js
CHANGED
|
@@ -60,6 +60,39 @@ function deleteAsset(publicId, resourceType) {
|
|
| 60 |
return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
/**
|
| 64 |
* Builds the overlay layers that stamp a video with the Aynwerd logo
|
| 65 |
* plus the viewing learner's email, so any leaked/recorded copy is
|
|
@@ -138,4 +171,11 @@ function resourceTypeForMimetype(mimetype) {
|
|
| 138 |
return "raw"; // PDFs, docs, slides, zips, etc.
|
| 139 |
}
|
| 140 |
|
| 141 |
-
module.exports = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
|
| 61 |
}
|
| 62 |
|
| 63 |
+
const THUMBNAIL_FOLDER = "aynwerd-course-thumbnails";
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Uploads a course thumbnail image. Deliberately PUBLIC (type: "upload",
|
| 67 |
+
* the Cloudinary default) β unlike lesson videos/documents, a
|
| 68 |
+
* thumbnail's whole purpose is to be visible to any visitor browsing
|
| 69 |
+
* the storefront before they've bought anything, so there's nothing to
|
| 70 |
+
* gate here.
|
| 71 |
+
*/
|
| 72 |
+
async function uploadThumbnail(filePath, { filenameHint }) {
|
| 73 |
+
const result = await cloudinary.uploader.upload(filePath, {
|
| 74 |
+
folder: THUMBNAIL_FOLDER,
|
| 75 |
+
resource_type: "image",
|
| 76 |
+
type: "upload",
|
| 77 |
+
use_filename: true,
|
| 78 |
+
unique_filename: true,
|
| 79 |
+
filename_override: filenameHint,
|
| 80 |
+
// Keep thumbnails reasonably sized regardless of what the admin
|
| 81 |
+
// uploads, so course cards load fast on the storefront.
|
| 82 |
+
transformation: [{ width: 1024, height: 768, crop: "limit" }],
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
if (!result || !result.public_id || !result.secure_url) {
|
| 86 |
+
throw new Error(`Cloudinary thumbnail upload did not return the expected result (got: ${JSON.stringify(result)}).`);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return result;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function deleteThumbnail(publicId) {
|
| 93 |
+
return cloudinary.uploader.destroy(publicId, { resource_type: "image", type: "upload" });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
/**
|
| 97 |
* Builds the overlay layers that stamp a video with the Aynwerd logo
|
| 98 |
* plus the viewing learner's email, so any leaked/recorded copy is
|
|
|
|
| 171 |
return "raw"; // PDFs, docs, slides, zips, etc.
|
| 172 |
}
|
| 173 |
|
| 174 |
+
module.exports = {
|
| 175 |
+
uploadFromPath,
|
| 176 |
+
deleteAsset,
|
| 177 |
+
getSignedPlaybackUrl,
|
| 178 |
+
resourceTypeForMimetype,
|
| 179 |
+
uploadThumbnail,
|
| 180 |
+
deleteThumbnail,
|
| 181 |
+
};
|
src/utils/email.js
CHANGED
|
@@ -104,6 +104,23 @@ async function sendPaymentConfirmedEmail({ to, name, order, courseTitle }) {
|
|
| 104 |
});
|
| 105 |
}
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
async function sendPaymentRejectedEmail({ to, name, order, courseTitle, reason }) {
|
| 108 |
const html = baseLayout(`
|
| 109 |
<p>Hi ${name},</p>
|
|
@@ -142,6 +159,7 @@ async function sendAdminNewOrderAlert({ order, courseTitle, customerEmail }) {
|
|
| 142 |
module.exports = {
|
| 143 |
sendOrderPlacedEmail,
|
| 144 |
sendPaymentConfirmedEmail,
|
|
|
|
| 145 |
sendPaymentRejectedEmail,
|
| 146 |
sendAdminNewOrderAlert,
|
| 147 |
};
|
|
|
|
| 104 |
});
|
| 105 |
}
|
| 106 |
|
| 107 |
+
async function sendVoucherRedeemedEmail({ to, name, order, courseTitle, voucherCode }) {
|
| 108 |
+
const html = baseLayout(`
|
| 109 |
+
<p>Hi ${name},</p>
|
| 110 |
+
<p>Your voucher <strong>${voucherCode}</strong> has been redeemed for <strong>${courseTitle}</strong> β
|
| 111 |
+
no payment was required.</p>
|
| 112 |
+
<p><strong>Order number:</strong> ${order.orderNumber}</p>
|
| 113 |
+
<p>Full access to the course has been unlocked on your account. Log in any time to get started.</p>
|
| 114 |
+
`);
|
| 115 |
+
|
| 116 |
+
return sendMail({
|
| 117 |
+
to,
|
| 118 |
+
subject: `Voucher redeemed β ${courseTitle} unlocked`,
|
| 119 |
+
html,
|
| 120 |
+
text: `Voucher ${voucherCode} redeemed for ${courseTitle}. Course access unlocked. Order ${order.orderNumber}.`,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
async function sendPaymentRejectedEmail({ to, name, order, courseTitle, reason }) {
|
| 125 |
const html = baseLayout(`
|
| 126 |
<p>Hi ${name},</p>
|
|
|
|
| 159 |
module.exports = {
|
| 160 |
sendOrderPlacedEmail,
|
| 161 |
sendPaymentConfirmedEmail,
|
| 162 |
+
sendVoucherRedeemedEmail,
|
| 163 |
sendPaymentRejectedEmail,
|
| 164 |
sendAdminNewOrderAlert,
|
| 165 |
};
|
src/utils/orderConfirmation.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const Order = require("../models/Order");
|
| 3 |
+
const User = require("../models/User");
|
| 4 |
+
const Course = require("../models/Course");
|
| 5 |
+
const AuditLog = require("../models/AuditLog");
|
| 6 |
+
const { sendPaymentConfirmedEmail } = require("./email");
|
| 7 |
+
const logger = require("./logger");
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* The single source of truth for "an order has been paid β unlock the
|
| 11 |
+
* course and tell the customer." Used by both:
|
| 12 |
+
* - admin.controller.js's confirmOrder (a human admin manually
|
| 13 |
+
* reviewing a bank-wire/manual payment reference), and
|
| 14 |
+
* - webhook.controller.js's Payoneer webhook handler (an automated
|
| 15 |
+
* Payoneer Checkout payment confirmation)
|
| 16 |
+
*
|
| 17 |
+
* so that access-granting and the confirmation email can never drift
|
| 18 |
+
* out of sync between the manual and automated payment paths.
|
| 19 |
+
*
|
| 20 |
+
* `confirmedBy` is a User id for a manual admin confirmation, or null
|
| 21 |
+
* for an automated one (the order's paymentMethod already records how
|
| 22 |
+
* it was paid).
|
| 23 |
+
*
|
| 24 |
+
* Idempotent: safe to call more than once for the same order (a
|
| 25 |
+
* webhook can legitimately be retried/redelivered by the provider) β
|
| 26 |
+
* an already-confirmed order is left untouched and simply returned.
|
| 27 |
+
*/
|
| 28 |
+
async function confirmOrderAndGrantAccess({ orderId, confirmedBy = null, ip = null, userAgent = null }) {
|
| 29 |
+
const session = await mongoose.startSession();
|
| 30 |
+
try {
|
| 31 |
+
let resultOrder;
|
| 32 |
+
let resultUser;
|
| 33 |
+
let course;
|
| 34 |
+
let alreadyConfirmed = false;
|
| 35 |
+
|
| 36 |
+
await session.withTransaction(async () => {
|
| 37 |
+
const order = await Order.findById(orderId).session(session);
|
| 38 |
+
if (!order) throw Object.assign(new Error("Order not found."), { status: 404 });
|
| 39 |
+
|
| 40 |
+
if (order.status === "confirmed") {
|
| 41 |
+
alreadyConfirmed = true;
|
| 42 |
+
resultOrder = order;
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (!["awaiting_confirmation", "pending_payment"].includes(order.status)) {
|
| 47 |
+
throw Object.assign(new Error("Order cannot be confirmed from its current status."), { status: 409 });
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
order.status = "confirmed";
|
| 51 |
+
order.confirmedBy = confirmedBy;
|
| 52 |
+
order.confirmedAt = new Date();
|
| 53 |
+
await order.save({ session });
|
| 54 |
+
|
| 55 |
+
const user = await User.findById(order.user).session(session);
|
| 56 |
+
if (!user) throw Object.assign(new Error("Associated user not found."), { status: 404 });
|
| 57 |
+
|
| 58 |
+
const alreadyEnrolled = user.enrolledCourses.some((ec) => ec.course.toString() === order.course.toString());
|
| 59 |
+
if (!alreadyEnrolled) {
|
| 60 |
+
user.enrolledCourses.push({ course: order.course, order: order._id, accessGrantedAt: new Date() });
|
| 61 |
+
await user.save({ session });
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
course = await Course.findById(order.course).session(session);
|
| 65 |
+
resultOrder = order;
|
| 66 |
+
resultUser = user;
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
if (alreadyConfirmed) {
|
| 70 |
+
return { order: resultOrder, alreadyConfirmed: true };
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
await AuditLog.create({
|
| 74 |
+
actor: confirmedBy,
|
| 75 |
+
actorEmail: confirmedBy ? undefined : "system:payoneer-webhook",
|
| 76 |
+
action: confirmedBy ? "admin.order_confirmed" : "system.order_auto_confirmed",
|
| 77 |
+
targetType: "Order",
|
| 78 |
+
targetId: resultOrder._id,
|
| 79 |
+
ip,
|
| 80 |
+
userAgent,
|
| 81 |
+
metadata: { userId: resultUser._id.toString(), paymentMethod: resultOrder.paymentMethod },
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
sendPaymentConfirmedEmail({
|
| 85 |
+
to: resultUser.email,
|
| 86 |
+
name: resultUser.name,
|
| 87 |
+
order: resultOrder,
|
| 88 |
+
courseTitle: course ? course.title : resultOrder.courseTitleSnapshot,
|
| 89 |
+
}).catch((err) => logger.error("Payment-confirmed email failed", { error: err.message }));
|
| 90 |
+
|
| 91 |
+
return { order: resultOrder, alreadyConfirmed: false };
|
| 92 |
+
} finally {
|
| 93 |
+
session.endSession();
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
module.exports = { confirmOrderAndGrantAccess };
|
src/utils/payoneerCheckout.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Adapter around Payoneer Checkout (the hosted-payment product that
|
| 3 |
+
* lets a webstore accept card/wallet payments that settle directly
|
| 4 |
+
* into a Payoneer account) β https://checkoutdocs.payoneer.com
|
| 5 |
+
*
|
| 6 |
+
* IMPORTANT β READ BEFORE GOING LIVE:
|
| 7 |
+
* Payoneer Checkout is not a self-serve API like Stripe. Per Payoneer's
|
| 8 |
+
* own documentation, credentials and the exact request/response
|
| 9 |
+
* contract (endpoint paths, field names, webhook signature scheme) are
|
| 10 |
+
* issued directly to you by Payoneer's integration team only after
|
| 11 |
+
* your business applies and is approved as a Payoneer Checkout
|
| 12 |
+
* merchant. That contract is account-specific, so it isn't something
|
| 13 |
+
* that can be correctly guessed or hard-coded here in advance.
|
| 14 |
+
*
|
| 15 |
+
* What IS real and finished in this file:
|
| 16 |
+
* - The env-driven config wiring (config/env.js -> payoneerCheckout)
|
| 17 |
+
* - The calling convention the rest of the app uses (see
|
| 18 |
+
* controllers/checkout.controller.js and webhook.controller.js)
|
| 19 |
+
* - A standard HMAC-SHA256 webhook-signature check, which is the
|
| 20 |
+
* common pattern most hosted-checkout providers use (Stripe,
|
| 21 |
+
* Paysera, PayMongo, etc. all verify webhooks this way) β but you
|
| 22 |
+
* MUST confirm the header name and signing scheme against your own
|
| 23 |
+
* Payoneer Checkout webhook docs once you have them, since generic
|
| 24 |
+
* industry convention isn't a guarantee it matches Payoneer's
|
| 25 |
+
* specific implementation.
|
| 26 |
+
*
|
| 27 |
+
* What's a clearly-marked PLACEHOLDER, pending your approved
|
| 28 |
+
* integration docs from Payoneer:
|
| 29 |
+
* - The exact endpoint path for creating a checkout session
|
| 30 |
+
* - The exact request body field names Payoneer's API expects
|
| 31 |
+
* - The exact response field names for the session id / redirect URL
|
| 32 |
+
* - The exact webhook payload field names / event-type values
|
| 33 |
+
* - The exact webhook signature header name
|
| 34 |
+
*
|
| 35 |
+
* Search for "PAYONEER-DOCS-NEEDED" below for every spot that needs a
|
| 36 |
+
* one-line fix once you have that contract in hand β the surrounding
|
| 37 |
+
* logic (error handling, order matching, idempotency) doesn't need to
|
| 38 |
+
* change.
|
| 39 |
+
*/
|
| 40 |
+
const crypto = require("crypto");
|
| 41 |
+
const { env } = require("../config/env");
|
| 42 |
+
|
| 43 |
+
function assertConfigured() {
|
| 44 |
+
if (!env.payoneerCheckout.apiBase || !env.payoneerCheckout.apiKey || !env.payoneerCheckout.apiSecret) {
|
| 45 |
+
throw Object.assign(
|
| 46 |
+
new Error(
|
| 47 |
+
"Payoneer Checkout is not configured yet. Set PAYONEER_CHECKOUT_API_BASE, " +
|
| 48 |
+
"PAYONEER_CHECKOUT_API_KEY and PAYONEER_CHECKOUT_API_SECRET once your Payoneer " +
|
| 49 |
+
"Checkout merchant account is approved."
|
| 50 |
+
),
|
| 51 |
+
{ status: 503 }
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Creates a hosted checkout session for one order and returns where to
|
| 58 |
+
* send the buyer to actually pay, plus any tax Payoneer's session
|
| 59 |
+
* response reports (so the buyer can be shown a clear amount + tax +
|
| 60 |
+
* total before being redirected β see checkout.controller.js).
|
| 61 |
+
*
|
| 62 |
+
* @param {object} params
|
| 63 |
+
* @param {object} params.order - the just-created Order document
|
| 64 |
+
* @param {object} params.course - the Course being purchased
|
| 65 |
+
* @param {string} params.customerEmail
|
| 66 |
+
* @param {string} params.customerName
|
| 67 |
+
* @param {string} params.successUrl - where Payoneer redirects after a successful payment
|
| 68 |
+
* @param {string} params.cancelUrl - where Payoneer redirects after a cancelled/failed payment
|
| 69 |
+
*/
|
| 70 |
+
async function createCheckoutSession({ order, course, customerEmail, customerName, successUrl, cancelUrl }) {
|
| 71 |
+
assertConfigured();
|
| 72 |
+
|
| 73 |
+
// PAYONEER-DOCS-NEEDED: confirm this path against your approved
|
| 74 |
+
// Payoneer Checkout API reference.
|
| 75 |
+
const endpoint = `${env.payoneerCheckout.apiBase}/v1/checkout-sessions`;
|
| 76 |
+
|
| 77 |
+
const response = await fetch(endpoint, {
|
| 78 |
+
method: "POST",
|
| 79 |
+
headers: {
|
| 80 |
+
"Content-Type": "application/json",
|
| 81 |
+
// PAYONEER-DOCS-NEEDED: confirm the auth header/scheme Payoneer
|
| 82 |
+
// issues you (this assumes a bearer API key, a common pattern,
|
| 83 |
+
// but Payoneer may use a different header or OAuth2 client-
|
| 84 |
+
// credentials flow instead).
|
| 85 |
+
Authorization: `Bearer ${env.payoneerCheckout.apiKey}`,
|
| 86 |
+
},
|
| 87 |
+
body: JSON.stringify({
|
| 88 |
+
// PAYONEER-DOCS-NEEDED: field names below are our best-guess
|
| 89 |
+
// shape for a hosted checkout session, modelled on the common
|
| 90 |
+
// pattern across similar providers β confirm every field name
|
| 91 |
+
// against Payoneer's reference before relying on this in
|
| 92 |
+
// production.
|
| 93 |
+
amount: order.amount,
|
| 94 |
+
currency: order.currency,
|
| 95 |
+
order_id: order.orderNumber,
|
| 96 |
+
description: course.title,
|
| 97 |
+
customer: { email: customerEmail, name: customerName },
|
| 98 |
+
success_url: successUrl,
|
| 99 |
+
cancel_url: cancelUrl,
|
| 100 |
+
}),
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
if (!response.ok) {
|
| 104 |
+
const body = await response.text().catch(() => "");
|
| 105 |
+
throw new Error(`Payoneer Checkout session creation failed (${response.status}): ${body}`);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const data = await response.json();
|
| 109 |
+
|
| 110 |
+
// PAYONEER-DOCS-NEEDED: confirm these response field names.
|
| 111 |
+
return {
|
| 112 |
+
sessionId: data.id || data.session_id,
|
| 113 |
+
checkoutUrl: data.checkout_url || data.redirect_url,
|
| 114 |
+
tax: data.tax
|
| 115 |
+
? { amount: data.tax.amount, currency: data.tax.currency || order.currency }
|
| 116 |
+
: null,
|
| 117 |
+
};
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Verifies an incoming webhook's signature and normalizes it into
|
| 122 |
+
* { sessionId, status, providerOrderId }. Throws if the signature is
|
| 123 |
+
* missing or invalid β the caller (webhook.controller.js) treats a
|
| 124 |
+
* throw as "reject this request," never as "treat it as paid."
|
| 125 |
+
*
|
| 126 |
+
* `rawBody` must be the exact, unparsed request body bytes/string used
|
| 127 |
+
* to compute the signature β see routes/webhook.routes.js, which
|
| 128 |
+
* registers this route with a raw body parser specifically so the
|
| 129 |
+
* bytes here match exactly what Payoneer signed.
|
| 130 |
+
*/
|
| 131 |
+
function verifyAndParseWebhook({ rawBody, signatureHeader }) {
|
| 132 |
+
if (!env.payoneerCheckout.webhookSecret) {
|
| 133 |
+
throw Object.assign(new Error("PAYONEER_CHECKOUT_WEBHOOK_SECRET is not configured."), { status: 503 });
|
| 134 |
+
}
|
| 135 |
+
if (!signatureHeader) {
|
| 136 |
+
throw Object.assign(new Error("Missing webhook signature."), { status: 400 });
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const expectedSignature = crypto
|
| 140 |
+
.createHmac("sha256", env.payoneerCheckout.webhookSecret)
|
| 141 |
+
.update(rawBody)
|
| 142 |
+
.digest("hex");
|
| 143 |
+
|
| 144 |
+
// PAYONEER-DOCS-NEEDED: confirm Payoneer's actual signature encoding
|
| 145 |
+
// (hex vs base64) and whether the header carries just the signature
|
| 146 |
+
// or a prefixed/versioned value (e.g. Stripe's "t=...,v1=...").
|
| 147 |
+
const signatureValid =
|
| 148 |
+
signatureHeader.length === expectedSignature.length &&
|
| 149 |
+
crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSignature));
|
| 150 |
+
|
| 151 |
+
if (!signatureValid) {
|
| 152 |
+
throw Object.assign(new Error("Invalid webhook signature."), { status: 400 });
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const payload = JSON.parse(rawBody.toString("utf8"));
|
| 156 |
+
|
| 157 |
+
// PAYONEER-DOCS-NEEDED: confirm the actual field/event names Payoneer
|
| 158 |
+
// sends (this assumes something like { event: "payment.completed",
|
| 159 |
+
// session_id, order_id }).
|
| 160 |
+
const eventType = payload.event || payload.type;
|
| 161 |
+
const status = eventType && eventType.includes("completed") ? "paid" : "other";
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
sessionId: payload.session_id || payload.id,
|
| 165 |
+
providerOrderId: payload.order_id,
|
| 166 |
+
status,
|
| 167 |
+
raw: payload,
|
| 168 |
+
};
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
module.exports = { createCheckoutSession, verifyAndParseWebhook };
|
src/utils/voucherCode.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const crypto = require("crypto");
|
| 2 |
+
|
| 3 |
+
// Excludes visually-confusable characters (0/O, 1/I/L) since these codes
|
| 4 |
+
// are meant to be read aloud, typed by hand, or printed on a certificate.
|
| 5 |
+
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
| 6 |
+
|
| 7 |
+
function randomSegment(length) {
|
| 8 |
+
let out = "";
|
| 9 |
+
const bytes = crypto.randomBytes(length);
|
| 10 |
+
for (let i = 0; i < length; i++) {
|
| 11 |
+
out += ALPHABET[bytes[i] % ALPHABET.length];
|
| 12 |
+
}
|
| 13 |
+
return out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/** e.g. AYN-7K4Q-XP2M β grouped for readability, not a security boundary by itself. */
|
| 17 |
+
function generateVoucherCode() {
|
| 18 |
+
return `AYN-${randomSegment(4)}-${randomSegment(4)}`;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
module.exports = { generateVoucherCode };
|