/*********************************************************************************************************************************************************************** * File Name: tiers.controller.js (admin) * Type of Program: Controller * Description: Admin-level tier and plan management. * - CRUD + archive/restore for tier_plans * - View/grant/revoke user tiers * - Paginated payments list * Author: rgrgogu * Date Created: Jun. 6, 2026 ***********************************************************************************************************************************************************************/ const { Op, ForeignKeyConstraintError } = require('sequelize'); const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl'); const { Course } = require('../../models/courses/courses.mdl'); require('../../models/tiers/tier.associations'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { getFieldValues } = require('../../utils/fieldValues.util'); const logActivity = require('../../utils/logActivity.util'); const { excludeAttributes: plansExclude, jsonbSchemas: plansSchemas, computedAttributes: plansComputed, } = require('../../models/tiers/tier_plans.attributes'); const { excludeAttributes: paymentsExclude, jsonbSchemas: paymentsSchemas, computedAttributes: paymentsComputed, } = require('../../models/tiers/payments.attributes'); const cc = require('currency-codes'); const PENDING_PAYMENT_EXPIRY_MINUTES = 60; // ─── CURRENCIES ─────────────────────────────────────────────────────────────── exports.getCurrencies = (req, res) => { const list = cc.codes().map((code) => { const entry = cc.code(code); return { code: entry.code, name: entry.currency }; }).sort((a, b) => a.code.localeCompare(b.code)); return R.success(res, 'OK', list); }; const expireStalePendingPayments = async () => { const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000); await mdl_Payments.update( { status: 'expired' }, { where: { status: 'pending', createdAt: { [Op.lt]: expiresBefore } } } ); }; // ─── PLANS ──────────────────────────────────────────────────────────────────── exports.getPlans = async (req, res) => { try { const archived = req.query.archived === 'true'; const result = await paginate(mdl_TierPlans, req, { excludeAttributes: plansExclude, jsonbSchemas: plansSchemas, computedAttributes: plansComputed, context: archived ? 'archived' : 'list', findOptions: archived ? { paranoid: false, where: { deletedAt: { [Op.ne]: null } }, } : {}, }); return R.success(res, 'Plans retrieved.', result); } catch (err) { console.error('[ADMIN][GET PLANS]', err); return R.error(res, 'Could not retrieve plans.', 500); } }; exports.getPlan = async (req, res) => { try { const plan = await mdl_TierPlans.findByPk(req.params.id); if (!plan) return R.error(res, 'Plan not found.', 404); return R.success(res, 'Plan retrieved.', plan); } catch (err) { console.error('[ADMIN][GET PLAN]', err); return R.error(res, 'Could not retrieve plan.', 500); } }; 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, description, features, 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) return R.error(res, 'Tier category not found or inactive.', 404); 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, description, features, 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 } }); return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201); } catch (err) { console.error('[ADMIN][CREATE PLAN]', err); return R.error(res, 'Could not create plan.', 500); } }; exports.updatePlan = async (req, res) => { try { const plan = await mdl_TierPlans.findByPk(req.params.id); if (!plan) return R.error(res, 'Plan not found.', 404); const allowed = ['label', 'description', 'features', '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); if (!category || !category.is_active) return R.error(res, 'Tier category not found or inactive.', 404); if (category.is_default) return R.error(res, 'Plans cannot be moved to the Free tier category.', 400); updates.tier = category.slug; } await plan.update(updates); logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); return R.success(res, 'Plan updated.', plan); } catch (err) { console.error('[ADMIN][UPDATE PLAN]', err); return R.error(res, 'Could not update plan.', 500); } }; 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); if (!plan) return R.error(res, 'Plan not found.', 404); if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400); await plan.update({ is_active: false }); await plan.destroy(); logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); return R.success(res, 'Plan archived successfully.'); } catch (err) { console.error('[ADMIN][ARCHIVE PLAN]', err); return R.error(res, 'Could not archive plan.', 500); } }; exports.bulkArchivePlans = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No plan IDs provided.', 400); const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids } }); if (!plans.length) return R.error(res, 'No plans found.', 404); const activePlans = plans.filter((p) => !p.deletedAt); if (!activePlans.length) return R.error(res, 'All selected plans are already archived.', 400); const activeIds = activePlans.map((p) => p.plan_id); await mdl_TierPlans.update({ is_active: false }, { where: { plan_id: activeIds } }); await mdl_TierPlans.destroy({ where: { plan_id: activeIds } }); logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK ARCHIVE PLANS]', err); return R.error(res, 'Could not archive plans.', 500); } }; exports.restorePlan = async (req, res) => { try { const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false, }); if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan.deletedAt) return R.error(res, 'Plan is not archived.', 400); await plan.restore(); await plan.update({ is_active: true }); logActivity(req.user?.user_id, 'restore_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); return R.success(res, 'Plan restored successfully.'); } catch (err) { console.error('[ADMIN][RESTORE PLAN]', err); return R.error(res, 'Could not restore plan.', 500); } }; exports.bulkRestorePlans = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No plan IDs provided.', 400); const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false }); if (!plans.length) return R.error(res, 'No plans found.', 404); const archivedPlans = plans.filter((p) => p.deletedAt); if (!archivedPlans.length) return R.error(res, 'All selected plans are already active.', 400); const archivedIds = archivedPlans.map((p) => p.plan_id); await mdl_TierPlans.restore({ where: { plan_id: archivedIds } }); await mdl_TierPlans.update({ is_active: true }, { where: { plan_id: archivedIds }, paranoid: false }); logActivity(req.user?.user_id, 'bulk_restore_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} plan(s) restored successfully.`, { restored_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK RESTORE PLANS]', err); return R.error(res, 'Could not restore plans.', 500); } }; exports.getPlanPermanentDeleteImpact = async (req, res) => { try { const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false }); 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' }, }); const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } }); return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count }); } catch (err) { console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err); return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500); } }; exports.permanentlyDeletePlan = async (req, res) => { try { const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false, }); if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400); await plan.destroy({ force: true }); logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); return R.success(res, 'Plan permanently deleted.'); } catch (err) { if (err instanceof ForeignKeyConstraintError) { return R.error(res, 'Cannot delete: this plan still has payment records on file. Payment history is preserved and cannot be removed.', 400); } console.error('[ADMIN][PERMANENT DELETE PLAN]', err); return R.error(res, 'Could not permanently delete plan.', 500); } }; exports.bulkPermanentlyDeletePlans = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No plan IDs provided.', 400); const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false }); if (!plans.length) return R.error(res, 'No plans found.', 404); const archivedPlans = plans.filter((p) => p.deletedAt); if (!archivedPlans.length) return R.error(res, 'All selected plans must be archived before they can be permanently deleted.', 400); const archivedIds = archivedPlans.map((p) => p.plan_id); await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true }); logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { if (err instanceof ForeignKeyConstraintError) { return R.error(res, 'Cannot delete: one or more selected plans still have payment records on file. Payment history is preserved and cannot be removed.', 400); } console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err); return R.error(res, 'Could not permanently delete plans.', 500); } }; exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', { blockedFields: [], }); // ─── USER TIERS ─────────────────────────────────────────────────────────────── exports.getUserTiers = async (req, res) => { try { const tiers = await mdl_UserTiers.findAll({ where: { user_id: req.params.id }, include: [ { model: mdl_Users, as: 'grantedByUser', attributes: ['user_id', 'email'] }, { model: mdl_Users, as: 'revokedByUser', attributes: ['user_id', 'email'] }, ], order: [['createdAt', 'DESC']], }); return R.success(res, 'User tiers retrieved.', tiers); } catch (err) { console.error('[ADMIN][GET USER TIERS]', err); return R.error(res, 'Could not retrieve user tiers.', 500); } }; exports.grantTier = async (req, res) => { try { const { user_id, plan_id, notes } = req.body; if (!user_id || !plan_id) return R.error(res, 'user_id and plan_id are required.', 400); const user = await mdl_Users.findByPk(user_id); if (!user) return R.error(res, 'User not found.', 404); const plan = await mdl_TierPlans.findByPk(plan_id); if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404); const tier = plan.tier; await mdl_UserTiers.update( { status: 'expired' }, { where: { user_id, status: 'active' } } ); const startsAt = new Date(); const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000); const newTier = await mdl_UserTiers.create({ user_id, tier, plan_id, status: 'active', starts_at: startsAt, expires_at: expiresAt, granted_by: req.user.user_id, notes, }); logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } }); return R.success(res, 'Tier granted.', newTier, 201); } catch (err) { console.error('[ADMIN][GRANT TIER]', err); return R.error(res, 'Could not grant tier.', 500); } }; exports.revokeTier = async (req, res) => { try { const tierRecord = await mdl_UserTiers.findByPk(req.params.tid); if (!tierRecord) return R.error(res, 'Tier record not found.', 404); if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400); await tierRecord.update({ status: 'revoked', revoked_by: req.user.user_id, revoked_at: new Date(), }); await mdl_UserTiers.create({ user_id: tierRecord.user_id, tier: 'free', status: 'active', starts_at: new Date(), expires_at: null, granted_by: req.user.user_id, notes: 'Auto-downgrade after revoke.', }); logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } }); return R.success(res, 'Tier revoked. User downgraded to free.'); } catch (err) { console.error('[ADMIN][REVOKE TIER]', err); return R.error(res, 'Could not revoke tier.', 500); } }; // ─── PAYMENTS ───────────────────────────────────────────────────────────────── exports.getPayments = async (req, res) => { try { await expireStalePendingPayments(); const result = await paginate(mdl_Payments, req, { excludeAttributes: paymentsExclude, jsonbSchemas: paymentsSchemas, computedAttributes: paymentsComputed, context: 'list', findOptions: { include: [ { model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] }, { model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label', 'tier', 'duration_days'] }, ], }, }); return R.success(res, 'Payments retrieved.', result); } catch (err) { console.error('[ADMIN][GET PAYMENTS]', err); return R.error(res, 'Could not retrieve payments.', 500); } }; exports.getPayment = async (req, res) => { try { await expireStalePendingPayments(); const payment = await mdl_Payments.findByPk(req.params.id, { include: [ { model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] }, { model: mdl_TierPlans, as: 'plan' }, { model: mdl_UserTiers, as: 'tier' }, ], }); if (!payment) return R.error(res, 'Payment not found.', 404); return R.success(res, 'Payment retrieved.', payment); } catch (err) { console.error('[ADMIN][GET PAYMENT]', err); return R.error(res, 'Could not retrieve payment.', 500); } }; exports.getPaymentFieldValues = getFieldValues(mdl_Payments, 'PAYMENT', { blockedFields: ['provider_payload'], }); // ─── PLAN COURSES ───────────────────────────────────────────────────────────── exports.getPlanCourses = async (req, res) => { try { const entries = await mdl_PlanCourses.findAll({ where: { plan_id: req.params.id }, include: [{ model: Course, as: 'course', attributes: ['course_id', 'title', 'course_code', 'subscription', 'level'], }], order: [['createdAt', 'ASC']], }); return R.success(res, 'Plan courses retrieved.', entries.map(e => e.course)); } catch (err) { console.error('[ADMIN][GET PLAN COURSES]', err); return R.error(res, 'Could not retrieve plan courses.', 500); } }; exports.syncPlanCourses = async (req, res) => { try { const { id } = req.params; const { course_ids = [] } = req.body; if (!Array.isArray(course_ids)) return R.error(res, 'course_ids must be an array.', 400); const plan = await mdl_TierPlans.findByPk(id); if (!plan) return R.error(res, 'Plan not found.', 404); // Remove all courses from this plan await mdl_PlanCourses.destroy({ where: { plan_id: id } }); if (course_ids.length) { // course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments // for these courses before inserting, so the insert isn't silently skipped await mdl_PlanCourses.destroy({ where: { course_id: course_ids } }); await mdl_PlanCourses.bulkCreate( course_ids.map(course_id => ({ plan_id: id, course_id })), ); } logActivity(req.user?.user_id, 'sync_plan_courses', { entityType: 'tier_plan', details: { plan_id: id, course_ids, count: course_ids.length } }); return R.success(res, 'Plan courses updated.'); } catch (err) { console.error('[ADMIN][SYNC PLAN COURSES]', err); return R.error(res, 'Could not update plan courses.', 500); } };