/*********************************************************************************************************************************************************************** * 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) * - PayPal redirect checkout (create order → capture → cancel) * - View own payment history * Author: rgrgogu * Date Created: Jun. 6, 2026 * Modified: Jun. 9, 2026 ***********************************************************************************************************************************************************************/ 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_PlanCourses = require('../../models/tiers/plan_courses.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'); 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) => { try { const tier = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, status: 'active' }, order: [['createdAt', 'DESC']], }); return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' }); } 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 (with courses) ───────────────────────────────────────────────────── 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: [] }, }], }); 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); } }; // ─── PAYPAL 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 } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); const checkout = calculateCheckoutAmount(plan.price, promo_code); if (Number(checkout.total) <= 0) return R.error(res, 'PayPal checkout requires a payable amount.', 400); const ppOrder = await paypal.createOrder({ amount: checkout.total, currency: plan.currency, 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, plan_id, status: 'pending', amount: checkout.total, currency: plan.currency, promo_code: checkout.promoCode, discount: checkout.discount, provider: 'paypal', provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl, checkout: { subtotal: checkout.subtotal, discount: checkout.discount, promo_code: checkout.promoCode, }, }, }); return R.success(res, 'Order created.', { 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, }, 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', provider: 'paypal', user_id: req.user.user_id, }, include: [{ model: mdl_TierPlans, as: 'plan' }], 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) { await payment.update({ status: 'failed', provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {}, }, }); return R.error(res, 'PayPal 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 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 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, 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', provider: 'paypal' }, 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); } }; // ─── 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); } }; // — add refundOrder export ──────────────── 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); // 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(), }, }); // Cancel tier at end of period — keep access until expires_at await activeTier.update({ status: 'revoked' }); return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', { refund_id: refundData.id, status: refundData.status, expires_at: activeTier.expires_at, }); } catch (err) { console.error('[CLIENT][REFUND]', err); return R.error(res, 'Could not process refund.', 500); } };