diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index 4e7b41e..087449f 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -383,10 +383,12 @@ exports.grantTier = async (req, res) => { 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 existingActive = await mdl_UserTiers.findOne({ + where: { user_id, tier, status: 'active' }, + }); + if (existingActive) { + return R.error(res, `User already has an active ${tier} subscription until ${existingActive.expires_at}.`, 409); + } const startsAt = new Date(); const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000); @@ -419,18 +421,26 @@ exports.revokeTier = async (req, res) => { 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.', + // 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: tierRecord.user_id, status: 'active' }, }); + if (remainingActive === 0) { + 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.'); + return R.success(res, remainingActive === 0 ? 'Tier revoked. User downgraded to free.' : 'Tier revoked.'); } catch (err) { console.error('[ADMIN][REVOKE TIER]', err); return R.error(res, 'Could not revoke tier.', 500); diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index f5dd0a0..f26b1f9 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -90,21 +90,32 @@ async function expireSession(session, passingScore) { return expiredAttempt; } -// Builds user context for evaluateCourseAccess: active tier slug, live tier -// rank map, the active plan's access_rules (if any), and group memberships -// (needed for the group_restriction rule type). +// Builds user context for evaluateCourseAccess: the user's best (highest-rank) +// active tier slug, live tier rank map, one ruleset per concurrently-active plan +// that has access_rules configured, and group memberships (needed for the +// group_restriction rule type). A user can hold more than one active tier at +// once (e.g. premium + exclusive), so "tier" here is the effective best one for +// plain rank checks, while "rulesets" preserves each active plan's own rules +// for the OR-across-active-plans check in canAccessCourse. async function buildUserContext(user_id) { - const activeTier = await getActiveTier(user_id); - const tier = activeTier?.tier ?? 'free'; + const activeTiers = await getActiveTiers(user_id); const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); const tierRankMap = {}; for (const c of categories) tierRankMap[c.slug] = c.rank; - let access_rules = []; - if (activeTier?.plan_id) { - const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } }); - access_rules = policy?.access_rules ?? []; + let tier = 'free'; + let bestRank = -Infinity; + for (const t of activeTiers) { + const rank = tierRankMap[t.tier] ?? 0; + if (rank > bestRank) { bestRank = rank; tier = t.tier; } + } + + const rulesets = []; + for (const t of activeTiers) { + if (!t.plan_id) continue; + const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: t.plan_id } }); + if (policy?.access_rules?.length) rulesets.push({ tier: t.tier, access_rules: policy.access_rules }); } const memberships = await mdl_UserGroupMembers.findAll({ @@ -113,7 +124,7 @@ async function buildUserContext(user_id) { }); const group_ids = memberships.map((m) => m.group_id); - return { tier, tierRankMap, access_rules, group_ids }; + return { tier, tierRankMap, rulesets, group_ids, activeTiers }; } // ─── Shared tier + purchase access check ───────────────────────────────────── @@ -126,11 +137,25 @@ async function canAccessCourse(user_id, course_id) { const userCtx = await buildUserContext(user_id); - // evaluateCourseAccess already falls back to plain rank comparison when the - // active plan has no access_rules configured — same behavior as before for - // every course/plan combination that hasn't opted into the richer engine. - const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap); - if (allowed) return true; + // Plain rank check against the user's best active tier — evaluateCourseAccess + // falls back to rank comparison when access_rules is empty. Same behavior as + // before for every course/plan combination that hasn't opted into the richer + // rule engine. + const { allowed: rankAllowed } = evaluateCourseAccess( + { tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, + course, userCtx.tierRankMap + ); + if (rankAllowed) return true; + + // Rule-based: a user can hold more than one active plan concurrently, and each + // one's access_rules is independent — access is granted if ANY of them allow it. + for (const ruleset of userCtx.rulesets) { + const { allowed } = evaluateCourseAccess( + { tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids }, + course, userCtx.tierRankMap + ); + if (allowed) return true; + } // Individual purchase as fallback const product = await mdl_Product.findOne({ where: { course_id } }); @@ -161,7 +186,12 @@ async function canAccessUnit(user_id, unit_id) { const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); if (unit?.subscription) { const userCtx = await buildUserContext(user_id); - const { allowed } = evaluateCourseAccess(userCtx, { subscription: unit.subscription }, userCtx.tierRankMap); + // Unit-level subscription gating is a plain rank check against the user's + // best active tier, not a rule-based one. + const { allowed } = evaluateCourseAccess( + { tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, + { subscription: unit.subscription }, userCtx.tierRankMap + ); if (allowed) return true; } @@ -216,9 +246,10 @@ function sanitizeQuestions(questions = []) { }); } -// Resolve the caller's active tier (returns null if free/expired) -async function getActiveTier(user_id) { - return mdl_UserTiers.findOne({ +// Resolve ALL of the caller's concurrently-active tiers (empty array if free/expired) — +// a user can hold more than one active tier at once (e.g. premium + exclusive). +async function getActiveTiers(user_id) { + return mdl_UserTiers.findAll({ where: { user_id, status: "active" }, order: [["createdAt", "DESC"]], }); diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 3a5b165..d7236e5 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -28,59 +28,83 @@ 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 tier = await mdl_UserTiers.findOne({ + 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: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }], - }], + include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }], }], 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, - planId: tier.plan?.plan_id ?? null, - }), - }).catch(() => {}); + 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.', { - tier: 'free', status: 'active', category: null, just_expired: true, + ...freeTier, + active_tiers: [freeTier], + top_tier: 'free', + just_expired, }); } - 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 }); + 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()); + } } - 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); + 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; } - return R.success(res, 'Active tier retrieved.', 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 }); } catch (err) { console.error('[CLIENT][GET MY TIER]', err); return R.error(res, 'Could not retrieve tier.', 500); @@ -160,6 +184,13 @@ exports.createOrder = async (req, res) => { 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 existingActive = await mdl_UserTiers.findOne({ + where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' }, + }); + if (existingActive) { + return R.error(res, `You already have an active ${plan.tier} subscription until ${existingActive.expires_at}. You can repurchase once it expires.`, 409); + } + const effectivePrice = Number(plan.price); const effectiveCurrency = plan.currency; @@ -266,10 +297,30 @@ exports.captureOrder = async (req, res) => { const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; - await mdl_UserTiers.update( - { status: 'expired' }, - { where: { user_id: req.user.user_id, status: 'active' } } - ); + // Scoped, defensive re-check: createOrder already blocked this, but time may + // have passed (or two checkout tabs raced) between order creation and capture. + // Money has already moved via PayPal at this point, so auto-refund rather than + // leaving the user charged with nothing to show for it. + const existingActive = await mdl_UserTiers.findOne({ + where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' }, + }); + if (existingActive) { + try { + await paymentSvc.refundCapture(payment.provider, capture?.id, payment.amount, payment.currency); + await payment.update({ + status: 'refunded', + provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_auto_refunded' }, + }); + return R.error(res, `You already have an active ${payment.plan.tier} subscription. Your payment has been automatically refunded.`, 409); + } catch (refundErr) { + console.error('[CLIENT][CAPTURE ORDER] auto-refund failed for duplicate active tier:', refundErr?.response?.data ?? refundErr); + await payment.update({ + status: 'failed', + provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_refund_failed' }, + }); + return R.error(res, `You already have an active ${payment.plan.tier} subscription. Refund could not be processed automatically — please contact support.`, 409); + } + } const startsAt = new Date(); const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); @@ -340,12 +391,22 @@ exports.cancelOrder = async (req, res) => { 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 activeTier = await mdl_UserTiers.findOne({ - where: { user_id, status: 'active' }, + 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 (!activeTier) return R.error(res, 'No active tier to refund.', 404); + 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' }, @@ -389,6 +450,16 @@ exports.refundOrder = async (req, res) => { const now = new Date(); await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now }); + // 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', diff --git a/database/migrations/20270101000075-add-user-tiers-active-per-tier-unique-index.js b/database/migrations/20270101000075-add-user-tiers-active-per-tier-unique-index.js new file mode 100644 index 0000000..d063306 --- /dev/null +++ b/database/migrations/20270101000075-add-user-tiers-active-per-tier-unique-index.js @@ -0,0 +1,24 @@ +'use strict'; + +// Backstop for the stacked-tier model: application code now enforces "at most +// one active row per (user_id, tier)" (see controllers/client/tiers.controller.js +// createOrder/captureOrder and controllers/admin/tiers.controller.js grantTier), +// but a race or a future code path could still violate it. This partial unique +// index makes the DB reject that case outright instead of silently allowing two +// active rows for the same user+tier. +// +// NOT auto-run — this file only defines the migration. Do not execute it against +// the shared dev/prod database without explicit confirmation. +module.exports = { + async up(queryInterface) { + await queryInterface.addIndex('user_tiers', ['user_id', 'tier'], { + unique: true, + where: { status: 'active' }, + name: 'user_tiers_one_active_per_user_tier', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_tier'); + }, +};