AndreaNotes1 commited on
Commit
4c6b1e8
Β·
verified Β·
1 Parent(s): db9f9f5

Upload 46 files

Browse files
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 { sendPaymentConfirmedEmail, sendPaymentRejectedEmail } = require("../utils/email");
 
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
- let resultOrder;
71
- let resultUser;
72
- let course;
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 AuditLog.create({
102
- actor: req.user._id,
103
- actorEmail: req.user.email,
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
- sendPaymentConfirmedEmail({
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 { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype } = require("../utils/cloudinaryUpload");
 
 
 
 
 
 
 
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
- const courses = await Course.find(filter).sort({ createdAt: 1 });
7
- res.json({ courses });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- /** Customer submits their bank wire reference / Payoneer transaction ID after paying. */
111
- async function submitPaymentProof(req, res, next) {
 
 
 
 
 
 
 
 
 
 
 
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 = { getPaymentOptions, createOrder, submitPaymentProof, listMyOrders, getMyOrder };
 
 
 
 
 
 
 
 
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
- module.exports = { apiLimiter, authLimiter, orderLimiter };
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- price: 350,
12
- duration: "8 weeks",
13
- level: "Beginner",
14
- description:
15
- "A practical introduction to AI: learn what AI can (and cannot) do, work with real datasets, and build your first machine-learning models.",
16
- highlights: [
17
- "AI concepts, terminology and real-world use cases",
18
- "Hands-on Python & ML experiments",
19
- "Build a simple predictive model end-to-end",
20
- ],
21
- },
22
- {
23
- title: "Cyber Security Essentials",
24
- slug: "cyber-security-essentials",
25
- price: 300,
26
- duration: "6 weeks",
27
- level: "Beginner",
28
- description:
29
- "Learn the foundations of cyber defense: protect devices, networks, and data against the most common digital threats.",
30
- highlights: [
31
- "Threat landscape and attacker tactics",
32
- "Secure browsing, password & identity hygiene",
33
- "Basic network and endpoint protection",
34
- ],
35
- },
36
- {
37
- title: "AI-Powered Cybersecurity",
38
- slug: "ai-powered-cybersecurity",
39
- price: 550,
40
- duration: "10 weeks",
41
- level: "Intermediate to Advanced",
42
- description:
43
- "Combine AI with security operations: deploy intelligent threat detection, automate incident response, and build resilient SOC workflows.",
44
- highlights: [
45
- "ML-based threat detection and anomaly analytics",
46
- "Automated incident response playbooks",
47
- "AI governance, ethics and adversarial risks",
48
- ],
49
- },
50
- {
51
- title: "The AI Career Guidance",
52
- slug: "the-ai-career-guidance",
53
- price: 200,
54
- duration: "4 weeks",
55
- level: "Career Foundations",
56
- description:
57
- "Map your path into the AI economy. Discover roles, build a portfolio, and prepare for interviews with guided mentorship.",
58
- highlights: [
59
- "AI career landscape and skill roadmap",
60
- "Portfolio project planning and resume review",
61
- "Mock interviews and mentorship sessions",
62
- ],
63
- },
64
- {
65
- title: "AI for Business Managers",
66
- slug: "ai-for-business-managers",
67
- price: 450,
68
- duration: "6 weeks",
69
- level: "Manager Level",
70
- description:
71
- "Learn how to evaluate, procure, and manage AI projects that drive real business value without needing to code.",
72
- highlights: [
73
- "AI strategy, ROI and use-case prioritisation",
74
- "Managing data, vendors and project risk",
75
- "Leading AI-enabled teams and change",
76
- ],
77
- },
78
- {
79
- title: "AI for Business Executives",
80
- slug: "ai-for-business-executives",
81
- price: 650,
82
- duration: "5 weeks",
83
- level: "Executive Level",
84
- description:
85
- "A leadership-level course on AI governance, competitive strategy, and building an AI-ready organisation.",
86
- highlights: [
87
- "Board-level AI vision and investment decisions",
88
- "Regulatory, ethical and governance frameworks",
89
- "Building an AI culture and Centre of Excellence",
90
- ],
91
- },
92
- ];
93
-
94
- async function seed() {
95
- await connectDB();
96
-
97
- for (const c of courses) {
98
- await Course.findOneAndUpdate({ slug: c.slug }, c, { upsert: true, new: true, setDefaultsOnInsert: true });
99
- }
100
- console.log(`Seeded ${courses.length} courses.`);
101
-
102
- if (env.seedAdmin.email && env.seedAdmin.password) {
103
- const existingAdmin = await User.findOne({ email: env.seedAdmin.email.toLowerCase() });
104
- if (existingAdmin) {
105
- console.log(`Admin account already exists for ${env.seedAdmin.email}, skipping.`);
106
- } else {
107
- const admin = new User({
108
- name: env.seedAdmin.name,
109
- email: env.seedAdmin.email.toLowerCase(),
110
- role: "admin",
111
- });
112
- await admin.setPassword(env.seedAdmin.password);
113
- await admin.save();
114
- console.log(`Created admin account: ${env.seedAdmin.email}`);
115
- console.log("IMPORTANT: log in and change this password immediately.");
116
- }
117
- } else {
118
- console.log("SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set β€” skipping admin creation.");
119
- }
120
-
121
- await disconnectDB();
122
- process.exit(0);
123
- }
124
-
125
- seed().catch((err) => {
126
- console.error("Seeding failed:", err);
127
- process.exit(1);
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 = { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype };
 
 
 
 
 
 
 
 
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 };