/*********************************************************************************************************************************************************************** * 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_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 paymentSvc = require('../../services/payment.service'); const R = require('../../utils/response.util'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); require('../../models/tiers/tier.associations'); // ─── MY TIER ────────────────────────────────────────────────────────────────── exports.getMyTier = async (req, res) => { try { const tier = await mdl_UserTiers.findOne({ 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: [{ 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) { const freeCategory = await mdl_TierCategories.findOne({ where: { slug: 'free' }, 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 }); } if (!tier.plan?.category) { const category = await mdl_TierCategories.findOne({ where: { slug: tier.tier }, 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; return R.success(res, 'Active tier retrieved.', plain); } return R.success(res, 'Active tier retrieved.', tier); } 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({ 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) => { const plain = p.toJSON(); plain.course_count = plain.courses?.length ?? 0; 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, 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, 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); // 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 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), }, 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]; await mdl_UserTiers.update( { status: 'expired' }, { where: { user_id: req.user.user_id, status: 'active' } } ); 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', starts_at: startsAt, expires_at: expiresAt, granted_by: null, }); await payment.update({ 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, }, }); await onTierActivated(req.user.user_id, newTier.tier); return R.success(res, 'Payment successful. Tier activated.', { tier: newTier.tier, expires_at: newTier.expires_at, }); } 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; 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'] }], 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); } };