testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:31:45 +08:00
parent 89acdfc239
commit 1372f4e975
62 changed files with 6696 additions and 281 deletions
+72 -7
View File
@@ -23,7 +23,7 @@ const {
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession,
CourseInstructor,
CourseInstructor, CourseAchievement,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
@@ -100,6 +100,8 @@ exports.createCourse = async (req, res) => {
course_code, level, subscription,
objectives = [],
category_ids = [],
achievement_keys = [],
badge_color, badge_asset_id, badge_image_url,
createdBy,
} = req.body;
@@ -107,18 +109,28 @@ exports.createCourse = async (req, res) => {
const course = await Course.create({
title,
description: description ?? null,
order_index: order_index ?? 0,
course_code: course_code ?? null,
level: level ?? null,
subscription: subscription ?? "free",
description: description ?? null,
order_index: order_index ?? 0,
course_code: course_code ?? null,
level: level ?? null,
subscription: subscription ?? "free",
duration_seconds: 0,
createdBy: createdBy ?? null,
badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null,
badge_image_url: badge_image_url ?? null,
createdBy: createdBy ?? null,
}, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit();
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
return R.success(res, "Course created.", { data: course }, 201);
@@ -140,6 +152,7 @@ exports.updateCourse = async (req, res) => {
title, description, order_index,
course_code, level, subscription,
objectives, category_ids,
badge_color, badge_asset_id, badge_image_url,
updatedBy,
} = req.body;
@@ -149,6 +162,9 @@ exports.updateCourse = async (req, res) => {
if (course_code !== undefined) course.course_code = course_code;
if (level !== undefined) course.level = level;
if (subscription !== undefined) course.subscription = subscription;
if (badge_color !== undefined) course.badge_color = badge_color;
if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id;
if (badge_image_url !== undefined) course.badge_image_url = badge_image_url;
course.updatedBy = updatedBy ?? null;
await course.save({ transaction: t });
@@ -1760,3 +1776,52 @@ exports.getAssessmentSessions = async (req, res) => {
return R.error(res, "Could not retrieve assessment sessions.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ACHIEVEMENTS
// ══════════════════════════════════════════════════════════════════════════════
exports.getCourseAchievements = async (req, res) => {
try {
const { courseId } = req.params;
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements retrieved.", { data: rows });
} catch (err) {
console.error("[COURSE][ACHIEVEMENTS][GET]", err);
return R.error(res, "Could not retrieve course achievements.", 500);
}
};
exports.syncCourseAchievements = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3)
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.map((key, i) => ({ course_id: courseId, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit();
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements updated.", { data: rows });
} catch (err) {
await t.rollback();
console.error("[COURSE][ACHIEVEMENTS][SYNC]", err);
return R.error(res, "Could not update course achievements.", 500);
}
};
+19 -4
View File
@@ -113,16 +113,31 @@ exports.issueTokensBatch = async (req, res) => {
storage_provider: "s3",
deletedAt: null,
},
attributes: ["asset_id", "file_type", "storage_key", "mime_type"],
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
});
const ip = resolveIp(req);
const tokens = {};
const ip = resolveIp(req);
const tokens = {};
const thumbnails = {};
for (const asset of assets) {
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip);
// For image/video assets with a thumbnail — presign it so the browser can
// load it directly from Garage without going through the stream proxy.
if (asset.thumbnail_storage_key) {
try {
thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl(
asset.thumbnail_storage_key,
TOKEN_TTL_SEC,
);
} catch {
// Non-fatal — stream token is the fallback
}
}
}
return R.success(res, "Tokens issued.", { tokens });
return R.success(res, "Tokens issued.", { tokens, thumbnails });
} catch (err) {
console.error("[ADMIN][MEDIA][TOKENS BATCH]", err);
return R.error(res, "Could not issue media tokens.", 500);
+139
View File
@@ -0,0 +1,139 @@
/***********************************************************************************************************************************************************************
* File Name: plan_prices.controller.js (admin)
* Type of Program: Controller
* Description: Admin CRUD for localized price overrides per tier plan.
* Routes: GET/POST/PUT/DELETE /admin/tiers/:id/prices[/:currency]
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { isSupported, SUPPORTED_CURRENCIES, validateLocalizedPrice } = require('../../utils/currency.util');
// ─── GET /admin/tiers/:id/prices ─────────────────────────────────────────────
exports.getPrices = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const prices = await mdl_PlanPrices.findAll({
where: { plan_id: plan.plan_id },
order: [['currency', 'ASC']],
});
return R.success(res, 'Localized prices retrieved.', prices);
} catch (err) {
console.error('[ADMIN][GET PLAN PRICES]', err);
return R.error(res, 'Could not retrieve localized prices.', 500);
}
};
// ─── POST /admin/tiers/:id/prices ────────────────────────────────────────────
exports.addPrice = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const { currency, price } = req.body;
if (!currency || price === undefined) return R.error(res, 'currency and price are required.', 400);
if (!isSupported(currency)) return R.error(res, `Unsupported currency: ${currency}.`, 400);
if (currency === plan.currency) return R.error(res, `${currency} is already the plan's base currency.`, 400);
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
const exists = await mdl_PlanPrices.findOne({ where: { plan_id: plan.plan_id, currency } });
if (exists) return R.error(res, `A localized price for ${currency} already exists. Use PUT to update it.`, 409);
// ── Rate validation ────────────────────────────────────────────────────────
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
if (validation.zone === 'block') return R.error(res, validation.message, 422);
const entry = await mdl_PlanPrices.create({
plan_id: plan.plan_id,
currency: currency.toUpperCase(),
price: Number(price),
});
logActivity(req.user?.user_id, 'add_plan_price', {
entityType: 'plan_price',
details: { plan_id: plan.plan_id, currency, price },
});
if (validation.zone === 'warn')
return res.status(201).json({ success: true, warning: true, message: validation.message, data: entry });
return R.success(res, 'Localized price added.', entry, 201);
} catch (err) {
console.error('[ADMIN][ADD PLAN PRICE]', err);
return R.error(res, 'Could not add localized price.', 500);
}
};
// ─── PUT /admin/tiers/:id/prices/:currency ───────────────────────────────────
exports.updatePrice = async (req, res) => {
try {
const { id, currency } = req.params;
const { price } = req.body;
if (price === undefined) return R.error(res, 'price is required.', 400);
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
// ── Rate validation ────────────────────────────────────────────────────────
const plan = await mdl_TierPlans.findByPk(id);
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
if (validation.zone === 'block') return R.error(res, validation.message, 422);
await entry.update({ price: Number(price) });
logActivity(req.user?.user_id, 'update_plan_price', {
entityType: 'plan_price',
details: { plan_id: id, currency, price },
});
if (validation.zone === 'warn')
return res.status(200).json({ success: true, warning: true, message: validation.message, data: entry });
return R.success(res, 'Localized price updated.', entry);
} catch (err) {
console.error('[ADMIN][UPDATE PLAN PRICE]', err);
return R.error(res, 'Could not update localized price.', 500);
}
};
// ─── DELETE /admin/tiers/:id/prices/:currency ────────────────────────────────
exports.removePrice = async (req, res) => {
try {
const { id, currency } = req.params;
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
await entry.destroy();
logActivity(req.user?.user_id, 'remove_plan_price', {
entityType: 'plan_price',
details: { plan_id: id, currency },
});
return R.success(res, 'Localized price removed.');
} catch (err) {
console.error('[ADMIN][REMOVE PLAN PRICE]', err);
return R.error(res, 'Could not remove localized price.', 500);
}
};
// ─── GET /admin/currencies ────────────────────────────────────────────────────
exports.getCurrencies = async (_req, res) => {
return R.success(res, 'Supported currencies retrieved.', SUPPORTED_CURRENCIES);
};
+105 -7
View File
@@ -1,12 +1,13 @@
'use strict';
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl');
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
require('../../models/tiers/tier.associations');
@@ -104,6 +105,103 @@ exports.upsertPlanPolicy = async (req, res) => {
}
};
// ─── PAYMENT POLICIES ─────────────────────────────────────────────────────────
const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
const VALID_WINDOW_UNITS = new Set(['minutes', 'hours', 'days']);
function validatePromoRules(rules) {
if (!Array.isArray(rules)) return 'promo_rules must be an array.';
for (const r of rules) {
if (!r.code || typeof r.code !== 'string') return 'Each promo rule must have a code string.';
if (!VALID_PROMO_TYPES.has(r.type)) return `Invalid promo type "${r.type}". Must be 'flat' or 'percent'.`;
if (!r.value || Number(r.value) <= 0) return 'Promo rule value must be a positive number.';
if (r.max_uses != null && (!Number.isInteger(r.max_uses) || r.max_uses < 1))
return 'max_uses must be a positive integer.';
if (r.expires_at != null && isNaN(new Date(r.expires_at).getTime()))
return 'expires_at must be a valid ISO date string.';
if (r.min_amount != null && Number(r.min_amount) < 0)
return 'min_amount must be a non-negative number.';
}
return null;
}
function validateRefundPolicy(rp) {
if (typeof rp !== 'object' || rp === null || Array.isArray(rp))
return 'refund_policy must be an object.';
if (rp.allowed != null && typeof rp.allowed !== 'boolean')
return 'refund_policy.allowed must be a boolean.';
if (rp.window_unit != null && !VALID_WINDOW_UNITS.has(rp.window_unit))
return `refund_policy.window_unit must be 'minutes', 'hours', or 'days'.`;
if (rp.window_value != null && (typeof rp.window_value !== 'number' || rp.window_value <= 0))
return 'refund_policy.window_value must be a positive number.';
return null;
}
exports.getPaymentPolicy = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const policy = await mdl_PaymentPolicies.findOne({ where: { plan_id: req.params.planId } });
return R.success(res, 'Payment policy retrieved.', policy ?? null);
} catch (err) {
console.error('[ADMIN][GET PAYMENT POLICY]', err);
return R.error(res, 'Could not retrieve payment policy.', 500);
}
};
exports.upsertPaymentPolicy = async (req, res) => {
try {
const { planId } = req.params;
const plan = await mdl_TierPlans.findByPk(planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const { promo_rules, refund_policy, allowed_providers } = req.body;
if (promo_rules !== undefined) {
const err = validatePromoRules(promo_rules);
if (err) return R.error(res, err, 400);
}
if (refund_policy !== undefined) {
const err = validateRefundPolicy(refund_policy);
if (err) return R.error(res, err, 400);
}
if (allowed_providers !== undefined) {
if (!Array.isArray(allowed_providers) || !allowed_providers.every((p) => typeof p === 'string'))
return R.error(res, 'allowed_providers must be an array of provider name strings.', 400);
}
let existing = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
const DEFAULTS = { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false };
const payload = {
plan_id: planId,
promo_rules: promo_rules !== undefined ? promo_rules : (existing?.promo_rules ?? []),
refund_policy: refund_policy !== undefined ? refund_policy : (existing?.refund_policy ?? DEFAULTS),
allowed_providers: allowed_providers !== undefined ? allowed_providers : (existing?.allowed_providers ?? ['paypal']),
};
if (!existing) {
existing = await mdl_PaymentPolicies.create(payload);
logActivity(req.user?.user_id, 'create_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
} else {
await existing.update(payload);
logActivity(req.user?.user_id, 'update_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
}
const result = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
return R.success(res, 'Payment policy saved.', result);
} catch (err) {
console.error('[ADMIN][UPSERT PAYMENT POLICY]', err);
return R.error(res, 'Could not save payment policy.', 500);
}
};
// ─── SYSTEM BADGES ────────────────────────────────────────────────────────────
exports.getSystemBadges = async (req, res) => {
+40 -5
View File
@@ -83,11 +83,18 @@ exports.getPlan = async (req, res) => {
}
};
const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function computeDurationDays(value, unit) {
const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1;
return parseFloat(value) * multiplier;
}
exports.createPlan = async (req, res) => {
try {
const { tier_category_id, label, duration_days, price, currency } = req.body;
if (!tier_category_id || !label || !duration_days || !price)
return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400);
const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body;
if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
const category = await mdl_TierCategories.findByPk(tier_category_id);
if (!category || !category.is_active)
@@ -96,10 +103,12 @@ exports.createPlan = async (req, res) => {
if (category.is_default)
return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400);
const duration_days = computeDurationDays(duration_value, duration_unit);
const plan = await mdl_TierPlans.create({
tier_category_id: category.tier_category_id,
tier: category.slug,
label, duration_days, price, currency,
label, description, duration_days, duration_unit, price, currency,
});
const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
@@ -115,12 +124,22 @@ exports.updatePlan = async (req, res) => {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const allowed = ['label', 'duration_days', 'price', 'currency', 'is_active', 'tier_category_id'];
const allowed = ['label', 'description', 'price', 'currency', 'is_active', 'tier_category_id'];
const updates = {};
for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k];
}
// Recompute duration_days when value or unit changes
const { duration_value, duration_unit } = req.body;
if (duration_value !== undefined) {
const unit = duration_unit ?? plan.duration_unit ?? 'day';
updates.duration_days = computeDurationDays(duration_value, unit);
updates.duration_unit = unit;
} else if (duration_unit !== undefined) {
updates.duration_unit = duration_unit;
}
// If tier_category_id is being changed, sync the tier slug
if (updates.tier_category_id) {
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
@@ -140,6 +159,22 @@ exports.updatePlan = async (req, res) => {
}
};
exports.getPlanImpact = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const active_subscriber_count = await mdl_UserTiers.count({
where: { plan_id: req.params.id, status: 'active' },
});
return R.success(res, 'Plan impact retrieved.', { active_subscriber_count });
} catch (err) {
console.error('[ADMIN][GET PLAN IMPACT]', err);
return R.error(res, 'Could not retrieve plan impact.', 500);
}
};
exports.archivePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
@@ -1,7 +1,7 @@
'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paypal = require('../../services/paypal.service');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
@@ -14,7 +14,6 @@ exports.createCourseOrder = async (req, res) => {
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404);
// Block if user already has an active completed purchase for this product
const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' },
});
@@ -23,7 +22,7 @@ exports.createCourseOrder = async (req, res) => {
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
}
const ppOrder = await paypal.createOrder({
const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
@@ -69,7 +68,7 @@ exports.captureCourseOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
where: { user_id: req.user.user_id, status: 'pending' },
include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']],
});
@@ -79,13 +78,13 @@ exports.captureCourseOrder = async (req, res) => {
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
captureData = await paymentSvc.captureOrder(purchase.provider, order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'PayPal capture failed.', 402);
return R.error(res, 'Payment capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
@@ -120,7 +119,7 @@ exports.cancelCourseOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
where: { user_id: req.user.user_id, status: 'pending' },
order: [['createdAt', 'DESC']],
});
+30 -2
View File
@@ -124,6 +124,7 @@ const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description",
"course_code", "level", "subscription",
"duration_seconds", "order_index",
"badge_color", "badge_asset_id", "badge_image_url",
];
// Strip correct-answer data before sending quiz questions to the client.
@@ -149,6 +150,22 @@ async function getActiveTier(user_id) {
});
}
// ─── COURSE CATEGORIES (public list for filter chips) ─────────────────────────
exports.getCategories = async (req, res) => {
try {
const rows = await mdl_Category.findAll({
where: { is_active: true },
attributes: ['id', 'name', 'slug'],
order: [['name', 'ASC']],
});
return R.success(res, 'Categories retrieved.', rows);
} catch (err) {
console.error('[CLIENT][COURSES][CATEGORIES]', err);
return R.error(res, 'Could not retrieve categories.', 500);
}
};
// ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
exports.getCourses = async (req, res) => {
@@ -276,6 +293,11 @@ exports.getCourse = async (req, res) => {
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
attributes: ["question_id"],
required: false,
}],
},
],
order: [
@@ -313,7 +335,13 @@ exports.getCourse = async (req, res) => {
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
});
is_completed = !!passedAttempt;
plain.assessment = { ...plain.assessment, has_passed: is_completed };
const questionCount = plain.assessment.questions?.length ?? 0;
plain.assessment = {
...plain.assessment,
has_passed: is_completed,
question_count: questionCount,
questions: undefined,
};
}
plain.is_completed = is_completed;
@@ -957,7 +985,7 @@ exports.getCourseByUuid = async (req, res) => {
const { uuid } = req.params;
const course = await Course.findOne({
where: { uuid, ...notDeleted },
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
attributes: ["course_id", "uuid", "title", "description", "level", "subscription", "badge_color", "badge_asset_id", "badge_image_url"],
});
if (!course) return R.error(res, "Course not found.", 404);
+5
View File
@@ -122,6 +122,11 @@ function pipeRemoteStream(remoteUrl, req, res) {
//
// S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type }
//
// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly,
// the fix lives on the frontend — not here. Use a useRef cache key by
// asset_id on the consumer side so this endpoint is called exactly once per
// asset per session. The 4h token TTL makes ref-caching safe within a session.
exports.issueToken = async (req, res) => {
try {
+17
View File
@@ -66,6 +66,23 @@ exports.updateProfile = async (req, res) => {
}
};
// ─── PATCH preferred currency ──────────────────────────────────────────────────
exports.updateCurrency = async (req, res) => {
try {
const { currency } = req.body;
if (!currency || typeof currency !== 'string' || currency.length !== 3)
return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
await user.update({ preferred_currency: currency.toUpperCase() });
return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency });
} catch (err) {
console.error('[CLIENT] updateCurrency error:', err);
return R.error(res, 'Could not update currency preference.', 500);
}
};
// ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
@@ -36,7 +36,7 @@ const isMember = async (userId, groupId) => {
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
const deriveStorageKey = (fileUrl) => {
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
const bucket = process.env.S3_BUCKET || 'philproperties';
const bucket = process.env.S3_BUCKET;
const prefix = `${publicUrl}/${bucket}/`;
if (fileUrl && fileUrl.startsWith(prefix)) {
+1 -1
View File
@@ -71,7 +71,7 @@ const resolveOwnerType = (mimetype = '') => {
//
// Returns:
// {
// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf",
// file_url : "https://cdn.yourdomain.com/your-bucket/documents/uuid.pdf",
// file_name : "social_media_slides.pdf",
// file_size : 2400000,
// mime_type : "application/pdf",
+232 -181
View File
@@ -4,47 +4,29 @@
* Description: User-facing tier and payment endpoints.
* - View active tier + history
* - Browse active plans (with courses per plan)
* - PayPal redirect checkout (create order → capture → cancel)
* - Promo code validation (server-side)
* - PayPal redirect checkout (create order → capture → cancel → refund)
* - View own payment history
* Author: rgrgogu
* Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026
* Modified: Jun. 29, 2026
***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
const { Course } = require('../../models/courses/courses.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations');
// ─── Promo codes ──────────────────────────────────────────────────────────────
const PROMO_CODES = {
PHIL10: 10, // $10 flat discount
};
const calculateCheckoutAmount = (price, promoCode) => {
const subtotalCents = Math.round(Number(price) * 100);
const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null;
const discountCents = normalizedCode && PROMO_CODES[normalizedCode]
? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents)
: 0;
const totalCents = Math.max(subtotalCents - discountCents, 0);
return {
promoCode: discountCents > 0 ? normalizedCode : null,
subtotal: (subtotalCents / 100).toFixed(2),
discount: (discountCents / 100).toFixed(2),
total: (totalCents / 100).toFixed(2),
};
};
// ─── MY TIER ──────────────────────────────────────────────────────────────────
exports.getMyTier = async (req, res) => {
@@ -59,27 +41,39 @@ exports.getMyTier = async (req, res) => {
model: mdl_TierCategories,
as: 'category',
required: false,
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
}],
}],
order: [['createdAt', 'DESC']],
});
// ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' });
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
}),
}).catch(() => {});
return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true,
});
}
if (!tier) {
// Free users with no user_tier row: look up free category badge
const freeCategory = await mdl_TierCategories.findOne({
where: { slug: 'free' },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
});
return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null });
}
// Supplement with the tier category badge even when the user's tier slug doesn't come via a plan
// (e.g., manually granted tiers that only store a slug, not a plan_id)
if (!tier.plan?.category) {
const category = await mdl_TierCategories.findOne({
where: { slug: tier.tier },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
});
const plain = tier.toJSON();
plain.category = category?.toJSON() ?? null;
@@ -106,20 +100,26 @@ exports.getMyTierHistory = async (req, res) => {
}
};
// ─── PLANS (with courses) ─────────────────────────────────────────────────────
// ─── PLANS ────────────────────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
where: { is_active: true },
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'],
include: [{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
}],
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
include: [
{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
},
{
model: mdl_PlanPrices,
as: 'prices',
attributes: ['currency', 'price'],
},
],
});
const result = plans.map((p) => {
@@ -135,57 +135,109 @@ exports.getPlans = async (req, res) => {
}
};
// ─── PAYPAL CHECKOUT ──────────────────────────────────────────────────────────
// ─── PROMO CODE VALIDATION ────────────────────────────────────────────────────
exports.validatePromo = async (req, res) => {
try {
const { plan_id, code, currency } = req.body;
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Resolve localized price if a preferred currency was sent
let effectivePrice = null;
if (currency && currency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } });
if (priceEntry) effectivePrice = priceEntry.price;
}
const policy = await paymentSvc.getPolicyForPlan(plan_id);
const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice);
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
} catch (err) {
console.error('[CLIENT][VALIDATE PROMO]', err);
return R.error(res, 'Could not validate promo code.', 500);
}
};
// ─── CHECKOUT ─────────────────────────────────────────────────────────────────
exports.createOrder = async (req, res) => {
try {
const { plan_id, promo_code } = req.body;
const { plan_id, promo_code, currency: requestedCurrency } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const checkout = calculateCheckoutAmount(plan.price, promo_code);
if (Number(checkout.total) <= 0)
// Resolve localized price — falls back to plan base price when no override exists
let effectivePrice = Number(plan.price);
let effectiveCurrency = plan.currency;
if (requestedCurrency && requestedCurrency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } });
if (priceEntry) {
effectivePrice = Number(priceEntry.price);
effectiveCurrency = priceEntry.currency;
}
}
const policy = await paymentSvc.getPolicyForPlan(plan_id);
let promoResult = { valid: false, code: null, discount: 0 };
if (promo_code) {
promoResult = await paymentSvc.evaluatePromo(policy, plan, promo_code, effectivePrice);
if (!promoResult.valid)
return R.error(res, promoResult.reason ?? 'Invalid promo code.', 400);
}
const subtotal = effectivePrice;
const discount = promoResult.discount ?? 0;
const total = Math.max(subtotal - discount, 0).toFixed(2);
if (Number(total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const ppOrder = await paypal.createOrder({
amount: checkout.total,
currency: plan.currency,
const provider = (policy?.allowed_providers?.[0]) ?? 'paypal';
const ppOrder = await paymentSvc.createOrder(provider, {
amount: total,
currency: effectiveCurrency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
});
// Extract PayPal approval URL from links array
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const payment = await mdl_Payments.create({
user_id: req.user.user_id,
user_id: req.user.user_id,
plan_id,
status: 'pending',
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
provider: 'paypal',
status: 'pending',
amount: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
provider,
provider_payload: {
order_id: ppOrder.id,
order_id: ppOrder.id,
approval_url: approvalUrl,
checkout: {
subtotal: checkout.subtotal,
discount: checkout.discount,
promo_code: checkout.promoCode,
subtotal: subtotal.toFixed(2),
discount: discount.toFixed(2),
promo_code: promoResult.code,
base_price: Number(plan.price).toFixed(2),
base_currency: plan.currency,
},
},
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
amount: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
}, 201);
} catch (err) {
console.error('[CLIENT][CREATE ORDER]', err);
@@ -199,70 +251,75 @@ exports.captureOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: {
status: 'pending',
provider: 'paypal',
user_id: req.user.user_id,
},
where: { status: 'pending', user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']],
order: [['createdAt', 'DESC']],
});
// Match by order_id inside provider_payload
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
// Guard: plan was deactivated while user was on PayPal's approval page
if (!payment.plan?.is_active) {
await payment.update({
status: 'failed',
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
error: ppErr?.response?.data ?? {},
cancelled_at: new Date().toISOString(),
cancelled_by: 'system',
cancel_reason: 'plan_deactivated',
},
});
return R.error(res, 'PayPal capture failed.', 402);
return R.error(res, 'This plan is no longer available. No payment was taken.', 409);
}
let captureData;
try {
captureData = await paymentSvc.captureOrder(payment.provider, order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'Payment capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Expire current active tier
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } }
);
const startsAt = new Date();
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
await payment.update({
status: 'completed',
tier_id: newTier.tier_id,
paid_at: new Date(),
status: 'completed',
tier_id: newTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
// await grantAchievement(req.user.user_id, payment.plan.tier);
await onTierActivated(req.user.user_id, newTier.tier);
return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier,
tier: newTier.tier,
expires_at: newTier.expires_at,
});
} catch (err) {
@@ -277,7 +334,7 @@ exports.cancelOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
where: { user_id: req.user.user_id, status: 'pending' },
order: [['createdAt', 'DESC']],
});
@@ -285,7 +342,7 @@ exports.cancelOrder = async (req, res) => {
return R.error(res, 'Pending payment not found.', 404);
await payment.update({
status: 'cancelled',
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
cancelled_at: new Date().toISOString(),
@@ -300,15 +357,87 @@ exports.cancelOrder = async (req, res) => {
}
};
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Load plan's payment policy to get the configured refund window
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
if (!paymentSvc.isRefundAllowed(policy))
return R.error(res, 'Refunds are not available for this plan.', 403);
const windowMs = paymentSvc.getRefundWindowMs(policy);
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > windowMs) {
const rp = policy?.refund_policy ?? {};
const label = `${rp.window_value ?? 5} ${rp.window_unit ?? 'minutes'}`;
return R.error(res, `Refund window has expired. Refunds are only available within ${label} of payment.`, 403);
}
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
let refundData;
try {
refundData = await paymentSvc.refundCapture(payment.provider, captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] provider error:', ppErr?.response?.data);
return R.error(res, 'Refund failed. Please try again.', 402);
}
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
const now = new Date();
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};
// ─── MY PAYMENTS ──────────────────────────────────────────────────────────────
exports.getMyPayments = async (req, res) => {
try {
const payments = await mdl_Payments.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Payment history retrieved.', payments);
} catch (err) {
@@ -317,14 +446,14 @@ exports.getMyPayments = async (req, res) => {
}
};
// ─── SYSTEM BADGES (read-only for client profile) ────────────────────────────
// ─── TIER CATEGORIES + SYSTEM BADGES ─────────────────────────────────────────
exports.getCategories = async (req, res) => {
try {
const categories = await mdl_TierCategories.findAll({
where: { is_active: true },
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }],
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
order: [['rank', 'ASC']],
});
return R.success(res, 'Tier categories retrieved.', categories);
@@ -347,81 +476,3 @@ exports.getSystemBadges = async (req, res) => {
return R.error(res, 'Could not retrieve system badges.', 500);
}
};
// — add refundOrder export ────────────────
const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Enforce 5-minute refund window
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > REFUND_WINDOW_MS)
return R.error(res, 'Refund window has expired. Refunds are only available within 5 minutes of payment.', 403);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
// Call PayPal refund API
let refundData;
try {
refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data);
return R.error(res, 'PayPal refund failed. Please try again.', 402);
}
// Update payment status to refunded
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
// Immediately terminate access — cut expires_at to now and revoke
const now = new Date();
await activeTier.update({
status: 'revoked',
expires_at: now,
revoked_at: now,
});
// Drop user back to free immediately
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};