/*********************************************************************************************************************************************************************** * File Name: tiers.controller.js (client) * Type of Program: Controller * Description: User-facing tier and payment endpoints. * - View active tier + history * - Browse active plans (with courses per plan) * - 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. 29, 2026 ***********************************************************************************************************************************************************************/ 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_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations'); 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 Unit = require('../../models/courses/units.mdl'); const Lesson = require('../../models/courses/lessons.mdl'); const paymentSvc = require('../../services/payment.service'); const { snapshotPlanGrants } = require('../../services/tierGrants.service'); const R = require('../../utils/response.util'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { sendEmail } = require('../../services/email.service'); const { fmtDate } = require('../../utils/datetime.util'); require('../../models/tiers/tier.associations'); // ─── MY TIER ────────────────────────────────────────────────────────────────── // A user can hold more than one active tier concurrently (e.g. premium + exclusive // bought separately). Returns the full active set plus the highest-rank one as // `top_tier`, for callers that just want "the best tier this user currently has". exports.getMyTier = async (req, res) => { try { const badgeInclude = { model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }; const tiers = await mdl_UserTiers.findAll({ where: { user_id: req.user.user_id, status: 'active' }, include: [{ model: mdl_TierPlans, as: 'plan', required: false, include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }], }], order: [['createdAt', 'DESC']], }); // ── Inline safety net: expire between cron ticks ────────────────────────── let just_expired = false; const stillActive = []; for (const tier of tiers) { if (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, planId: tier.plan?.plan_id ?? null, }), }).catch(() => {}); just_expired = true; continue; } stillActive.push(tier); } if (!stillActive.length) { const freeCategory = await mdl_TierCategories.findOne({ where: { slug: 'free' }, include: [badgeInclude] }); const freeTier = { tier: 'free', status: 'active', category: freeCategory ?? null }; // Spread freeTier at top level too — keeps `myTier.tier`/`myTier.status`/`myTier.category` // working for existing frontend code that predates the active_tiers/top_tier shape. return R.success(res, 'Active tier retrieved.', { ...freeTier, active_tiers: [freeTier], top_tier: 'free', just_expired, my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] }, }); } // Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id // granted by ANY of this user's currently-active tiers, flattened, so the // client can compute per-plan overlap (see PlanList.jsx) without a // separate endpoint per plan. const myGrantRows = await mdl_UserTierGrants.findAll({ where: { user_tier_id: stillActive.map((t) => t.tier_id) }, attributes: ['item_type', 'item_id'], }); const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] }; for (const g of myGrantRows) { if (g.item_type === 'course') my_grants.course_ids.push(g.item_id); else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id); else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id); } const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank])); const active_tiers = []; for (const tier of stillActive) { if (!tier.plan?.category) { const category = await mdl_TierCategories.findOne({ where: { slug: tier.tier }, include: [badgeInclude] }); const plain = tier.toJSON(); plain.category = category?.toJSON() ?? null; active_tiers.push(plain); } else { active_tiers.push(tier.toJSON()); } } let top_tier = active_tiers[0].tier; for (const t of active_tiers) { if ((rankMap[t.tier] ?? 0) > (rankMap[top_tier] ?? 0)) top_tier = t.tier; } const topTierObj = active_tiers.find((t) => t.tier === top_tier) ?? active_tiers[0]; // Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/ // `myTier.category`/`myTier.expires_at` working for existing frontend code // that predates the active_tiers/top_tier shape (it'll just see the best tier). return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants }); } catch (err) { console.error('[CLIENT][GET MY TIER]', err); return R.error(res, 'Could not retrieve tier.', 500); } }; exports.getMyTierHistory = async (req, res) => { try { const history = await mdl_UserTiers.findAll({ where: { user_id: req.user.user_id }, order: [['createdAt', 'DESC']], }); return R.success(res, 'Tier history retrieved.', history); } catch (err) { console.error('[CLIENT][GET MY TIER HISTORY]', err); return R.error(res, 'Could not retrieve tier history.', 500); } }; // ─── PLANS ──────────────────────────────────────────────────────────────────── exports.getPlans = async (req, res) => { try { const plans = await mdl_TierPlans.findAll({ where: { status: 'published' }, order: [['tier', 'ASC'], ['duration_days', 'ASC']], attributes: ['plan_id', 'tier', 'label', 'description', 'features', '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: Unit, as: 'units', attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'], through: { attributes: [] }, }, { model: Lesson, as: 'lessons', attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'], through: { attributes: [] }, }, ], }); // Each plan holds exactly one bundle type (single-type bundles, Tier Plans // v2) — expose the exact item id sets so the client can compute // overlap-with-existing-access without extra round-trips (see PlanList.jsx). const result = plans.map((p) => { const plain = p.toJSON(); plain.course_count = plain.courses?.length ?? 0; plain.unit_count = plain.units?.length ?? 0; plain.lesson_count = plain.lessons?.length ?? 0; plain.course_ids = (plain.courses ?? []).map((c) => c.course_id); plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id); plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id); return plain; }); return R.success(res, 'Plans retrieved.', result); } catch (err) { console.error('[CLIENT][GET PLANS]', err); return R.error(res, 'Could not retrieve plans.', 500); } }; // ─── PROMO CODE VALIDATION ──────────────────────────────────────────────────── exports.validatePromo = async (req, res) => { try { const { plan_id, code } = 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, status: 'published' } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); const policy = await paymentSvc.getPolicyForPlan(plan_id); const result = await paymentSvc.evaluatePromo(policy, plan, code, null); 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; if (!plan_id) return R.error(res, 'plan_id is required.', 400); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); // Repurchasing the SAME plan while it's already active is allowed — it // extends the existing grant's expires_at (see captureOrder) rather than // being blocked. A different plan at the same tier slug is NOT the same // purchase — it creates its own independent user_tiers row with its own // item-specific grants, so this check is keyed on plan_id, not tier. // Surfaced here only for checkout-page messaging. const existingActive = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, plan_id, status: 'active' }, attributes: ['expires_at'], }); const effectivePrice = Number(plan.price); const effectiveCurrency = plan.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 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}`, }); const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; const payment = await mdl_Payments.create({ user_id: req.user.user_id, plan_id, status: 'pending', amount: total, currency: effectiveCurrency, promo_code: promoResult.code, discount: discount.toFixed(2), provider, provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl, checkout: { 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, approval_url: approvalUrl, amount: total, currency: effectiveCurrency, promo_code: promoResult.code, discount: discount.toFixed(2), extends_existing: !!existingActive, current_expires_at: existingActive?.expires_at ?? null, }, 201); } catch (err) { console.error('[CLIENT][CREATE ORDER]', err); return R.error(res, 'Could not create order.', 500); } }; exports.captureOrder = async (req, res) => { try { const { order_id } = req.body; if (!order_id) return R.error(res, 'order_id is required.', 400); const payment = await mdl_Payments.findOne({ where: { status: 'pending', user_id: req.user.user_id }, include: [{ model: mdl_TierPlans, as: 'plan' }], order: [['createdAt', 'DESC']], }); if (!payment || payment.provider_payload?.order_id !== order_id) return R.error(res, 'Pending payment not found.', 404); // Guard: plan was deactivated while user was on PayPal's approval page if (!payment.plan?.is_active) { await payment.update({ status: 'cancelled', provider_payload: { ...payment.provider_payload, cancelled_at: new Date().toISOString(), cancelled_by: 'system', cancel_reason: 'plan_deactivated', }, }); 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]; // PayPal can return an HTTP 2xx from the capture endpoint even when the // charge itself was declined or held for review (e.g. capture.status // "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual // status field must be checked explicitly before granting any access. const captureStatus = capture?.status ?? captureData.status; if (captureStatus !== 'COMPLETED') { await payment.update({ status: 'failed', provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' }, }); return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402); } // Repurchasing THIS SAME plan while already active extends its expires_at // by the new duration, rather than being blocked/refunded. A different // plan — even at the same tier slug — is a distinct purchase and gets its // own user_tiers row with its own item-specific grants (see // snapshotPlanGrants below); it must NOT be merged into an unrelated // plan's row just because the tier slug matches (Tier Plans v2 — a Unit // bundle and a Course bundle can both be "premium" and both need to stay // independently active/tracked). const existingActive = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' }, }); let resultTier; let successMessage; if (existingActive) { const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000); await existingActive.update({ expires_at: newExpiresAt }); resultTier = existingActive; successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`; } else { const startsAt = new Date(); const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); resultTier = await mdl_UserTiers.create({ 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, }); successMessage = 'Payment successful. Tier activated.'; } // Snapshot the plan's current bundle contents into user_tier_grants — // refreshed on every purchase/extension so an admin's bundle edits since // the last purchase are picked up, but past purchasers of OTHER plans are // never retroactively affected (Tier Plans v2 item-specific entitlement). await snapshotPlanGrants(resultTier, payment.plan_id); await payment.update({ status: 'completed', tier_id: resultTier.tier_id, paid_at: new Date(), provider_payload: { ...payment.provider_payload, capture_id: capture?.id, payer_id: captureData.payer?.payer_id, capture: captureData, }, }); // Idempotent (grantAchievement checks for an existing row first) — safe // to call again on an extension, won't grant a duplicate achievement. await onTierActivated(req.user.user_id, resultTier.tier); return R.success(res, successMessage, { tier: resultTier.tier, expires_at: resultTier.expires_at, extended: !!existingActive, }); } catch (err) { console.error('[CLIENT][CAPTURE ORDER]', err); return R.error(res, 'Could not capture order.', 500); } }; exports.cancelOrder = async (req, res) => { try { const { order_id } = req.body; 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' }, order: [['createdAt', 'DESC']], }); if (!payment || payment.provider_payload?.order_id !== order_id) return R.error(res, 'Pending payment not found.', 404); await payment.update({ status: 'cancelled', provider_payload: { ...payment.provider_payload, cancelled_at: new Date().toISOString(), cancelled_by: 'payer', }, }); return R.success(res, 'Payment cancelled.'); } catch (err) { console.error('[CLIENT][CANCEL ORDER]', err); return R.error(res, 'Could not cancel payment.', 500); } }; exports.refundOrder = async (req, res) => { try { const user_id = req.user.user_id; // plan_id disambiguates which active subscription to refund now that a user // can hold more than one concurrently — optional only while a user has just one. const { plan_id } = req.body; const activeTierWhere = { user_id, status: 'active' }; if (plan_id) activeTierWhere.plan_id = plan_id; const activeTierCandidates = await mdl_UserTiers.findAll({ where: activeTierWhere, order: [['createdAt', 'DESC']], }); if (!activeTierCandidates.length) return R.error(res, 'No active tier to refund.', 404); if (activeTierCandidates.length > 1) { return R.error(res, 'You have more than one active subscription — specify plan_id to refund a specific one.', 400); } const activeTier = activeTierCandidates[0]; 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 }); const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] }); try { const notify = NOTIFICATION_REGISTRY.payment_refunded.build({ label: plan?.label ?? 'your plan', amount: payment.amount, currency: payment.currency, planId: plan?.plan_id ?? null, }); await UserNotification.create({ user_id, ...notify, seen: false }); } catch (notifyErr) { console.error('[CLIENT][REFUND][NOTIFY]', notifyErr); } try { const name = req.user.personal_info?.name?.full_name ?? 'there'; sendEmail({ to: req.user.email, type: 'REFUND_PROCESSED', data: { name, label: plan?.label ?? 'your plan', amount: payment.amount, currency: payment.currency, date: fmtDate(now), refundId: refundData.id, }, }).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr)); } catch (emailErr) { console.error('[CLIENT][REFUND][EMAIL]', emailErr); } // Only fall back to free if the user has no other concurrently active tier — // revoking one subscription shouldn't drop them below a tier they still hold. const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); if (remainingActive > 0) { return R.success(res, 'Refund processed successfully. Your access to this plan has been revoked.', { refund_id: refundData.id, status: refundData.status, }); } 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'] }], attributes: { exclude: ['provider_payload'] }, order: [['createdAt', 'DESC']], }); return R.success(res, 'Payment history retrieved.', payments); } catch (err) { console.error('[CLIENT][GET MY PAYMENTS]', err); return R.error(res, 'Could not retrieve payment history.', 500); } }; // ─── 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_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); } catch (err) { console.error('[CLIENT][GET TIER CATEGORIES]', err); return R.error(res, 'Could not retrieve tier categories.', 500); } }; exports.getSystemBadges = async (req, res) => { try { const badges = await mdl_SystemBadges.findAll({ attributes: ['key', 'label', 'description', 'information', 'active_from', 'active_until'], include: [{ model: Asset, as: 'asset', attributes: ['file_url', 'display_name'], required: false }], order: [['key', 'ASC']], }); return R.success(res, 'System badges retrieved.', badges); } catch (err) { console.error('[CLIENT][GET SYSTEM BADGES]', err); return R.error(res, 'Could not retrieve system badges.', 500); } };