revised: payment strategy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-31 02:27:05 +08:00
parent 2e5fd10b38
commit aad298bc54
4 changed files with 208 additions and 72 deletions
+50 -19
View File
@@ -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"]],
});