mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
revised: payment strategy
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -383,10 +383,12 @@ exports.grantTier = async (req, res) => {
|
|||||||
if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404);
|
if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404);
|
||||||
const tier = plan.tier;
|
const tier = plan.tier;
|
||||||
|
|
||||||
await mdl_UserTiers.update(
|
const existingActive = await mdl_UserTiers.findOne({
|
||||||
{ status: 'expired' },
|
where: { user_id, tier, status: 'active' },
|
||||||
{ where: { user_id, 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 startsAt = new Date();
|
||||||
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
|
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
|
||||||
@@ -419,18 +421,26 @@ exports.revokeTier = async (req, res) => {
|
|||||||
revoked_at: new Date(),
|
revoked_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await mdl_UserTiers.create({
|
// Only fall back to free if the user has no other concurrently active tier —
|
||||||
user_id: tierRecord.user_id,
|
// revoking one subscription shouldn't drop them below a tier they still hold.
|
||||||
tier: 'free',
|
const remainingActive = await mdl_UserTiers.count({
|
||||||
status: 'active',
|
where: { user_id: tierRecord.user_id, status: 'active' },
|
||||||
starts_at: new Date(),
|
|
||||||
expires_at: null,
|
|
||||||
granted_by: req.user.user_id,
|
|
||||||
notes: 'Auto-downgrade after revoke.',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 } });
|
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) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][REVOKE TIER]', err);
|
console.error('[ADMIN][REVOKE TIER]', err);
|
||||||
return R.error(res, 'Could not revoke tier.', 500);
|
return R.error(res, 'Could not revoke tier.', 500);
|
||||||
|
|||||||
@@ -90,21 +90,32 @@ async function expireSession(session, passingScore) {
|
|||||||
return expiredAttempt;
|
return expiredAttempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds user context for evaluateCourseAccess: active tier slug, live tier
|
// Builds user context for evaluateCourseAccess: the user's best (highest-rank)
|
||||||
// rank map, the active plan's access_rules (if any), and group memberships
|
// active tier slug, live tier rank map, one ruleset per concurrently-active plan
|
||||||
// (needed for the group_restriction rule type).
|
// 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) {
|
async function buildUserContext(user_id) {
|
||||||
const activeTier = await getActiveTier(user_id);
|
const activeTiers = await getActiveTiers(user_id);
|
||||||
const tier = activeTier?.tier ?? 'free';
|
|
||||||
|
|
||||||
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||||
const tierRankMap = {};
|
const tierRankMap = {};
|
||||||
for (const c of categories) tierRankMap[c.slug] = c.rank;
|
for (const c of categories) tierRankMap[c.slug] = c.rank;
|
||||||
|
|
||||||
let access_rules = [];
|
let tier = 'free';
|
||||||
if (activeTier?.plan_id) {
|
let bestRank = -Infinity;
|
||||||
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } });
|
for (const t of activeTiers) {
|
||||||
access_rules = policy?.access_rules ?? [];
|
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({
|
const memberships = await mdl_UserGroupMembers.findAll({
|
||||||
@@ -113,7 +124,7 @@ async function buildUserContext(user_id) {
|
|||||||
});
|
});
|
||||||
const group_ids = memberships.map((m) => m.group_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 ─────────────────────────────────────
|
// ─── Shared tier + purchase access check ─────────────────────────────────────
|
||||||
@@ -126,11 +137,25 @@ async function canAccessCourse(user_id, course_id) {
|
|||||||
|
|
||||||
const userCtx = await buildUserContext(user_id);
|
const userCtx = await buildUserContext(user_id);
|
||||||
|
|
||||||
// evaluateCourseAccess already falls back to plain rank comparison when the
|
// Plain rank check against the user's best active tier — evaluateCourseAccess
|
||||||
// active plan has no access_rules configured — same behavior as before for
|
// falls back to rank comparison when access_rules is empty. Same behavior as
|
||||||
// every course/plan combination that hasn't opted into the richer engine.
|
// before for every course/plan combination that hasn't opted into the richer
|
||||||
const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap);
|
// rule engine.
|
||||||
if (allowed) return true;
|
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
|
// Individual purchase as fallback
|
||||||
const product = await mdl_Product.findOne({ where: { course_id } });
|
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'] });
|
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
|
||||||
if (unit?.subscription) {
|
if (unit?.subscription) {
|
||||||
const userCtx = await buildUserContext(user_id);
|
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;
|
if (allowed) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +246,10 @@ function sanitizeQuestions(questions = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the caller's active tier (returns null if free/expired)
|
// Resolve ALL of the caller's concurrently-active tiers (empty array if free/expired) —
|
||||||
async function getActiveTier(user_id) {
|
// a user can hold more than one active tier at once (e.g. premium + exclusive).
|
||||||
return mdl_UserTiers.findOne({
|
async function getActiveTiers(user_id) {
|
||||||
|
return mdl_UserTiers.findAll({
|
||||||
where: { user_id, status: "active" },
|
where: { user_id, status: "active" },
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,59 +28,83 @@ require('../../models/tiers/tier.associations');
|
|||||||
|
|
||||||
// ─── MY TIER ──────────────────────────────────────────────────────────────────
|
// ─── 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) => {
|
exports.getMyTier = async (req, res) => {
|
||||||
try {
|
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' },
|
where: { user_id: req.user.user_id, status: 'active' },
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_TierPlans,
|
model: mdl_TierPlans,
|
||||||
as: 'plan',
|
as: 'plan',
|
||||||
required: false,
|
required: false,
|
||||||
include: [{
|
include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }],
|
||||||
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']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Inline safety net: expire between cron ticks ──────────────────────────
|
// ── Inline safety net: expire between cron ticks ──────────────────────────
|
||||||
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
|
let just_expired = false;
|
||||||
await tier.update({ status: 'expired' });
|
const stillActive = [];
|
||||||
UserNotification.create({
|
for (const tier of tiers) {
|
||||||
user_id: req.user.user_id,
|
if (tier.expires_at && new Date(tier.expires_at) <= new Date()) {
|
||||||
...NOTIFICATION_REGISTRY.tier_expired.build({
|
await tier.update({ status: 'expired' });
|
||||||
tier: tier.tier,
|
UserNotification.create({
|
||||||
label: tier.plan?.label ?? null,
|
user_id: req.user.user_id,
|
||||||
planId: tier.plan?.plan_id ?? null,
|
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||||
}),
|
tier: tier.tier,
|
||||||
}).catch(() => {});
|
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.', {
|
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 categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||||
const freeCategory = await mdl_TierCategories.findOne({
|
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank]));
|
||||||
where: { slug: 'free' },
|
|
||||||
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
|
const active_tiers = [];
|
||||||
});
|
for (const tier of stillActive) {
|
||||||
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: [badgeInclude] });
|
||||||
|
const plain = tier.toJSON();
|
||||||
|
plain.category = category?.toJSON() ?? null;
|
||||||
|
active_tiers.push(plain);
|
||||||
|
} else {
|
||||||
|
active_tiers.push(tier.toJSON());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tier.plan?.category) {
|
let top_tier = active_tiers[0].tier;
|
||||||
const category = await mdl_TierCategories.findOne({
|
for (const t of active_tiers) {
|
||||||
where: { slug: tier.tier },
|
if ((rankMap[t.tier] ?? 0) > (rankMap[top_tier] ?? 0)) top_tier = t.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);
|
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) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][GET MY TIER]', err);
|
console.error('[CLIENT][GET MY TIER]', err);
|
||||||
return R.error(res, 'Could not retrieve tier.', 500);
|
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 } });
|
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
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 effectivePrice = Number(plan.price);
|
||||||
const effectiveCurrency = plan.currency;
|
const effectiveCurrency = plan.currency;
|
||||||
|
|
||||||
@@ -266,10 +297,30 @@ exports.captureOrder = async (req, res) => {
|
|||||||
|
|
||||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
||||||
|
|
||||||
await mdl_UserTiers.update(
|
// Scoped, defensive re-check: createOrder already blocked this, but time may
|
||||||
{ status: 'expired' },
|
// have passed (or two checkout tabs raced) between order creation and capture.
|
||||||
{ where: { user_id: req.user.user_id, status: 'active' } }
|
// 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 startsAt = new Date();
|
||||||
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
|
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) => {
|
exports.refundOrder = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user_id = req.user.user_id;
|
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({
|
const activeTierWhere = { user_id, status: 'active' };
|
||||||
where: { user_id, status: 'active' },
|
if (plan_id) activeTierWhere.plan_id = plan_id;
|
||||||
|
|
||||||
|
const activeTierCandidates = await mdl_UserTiers.findAll({
|
||||||
|
where: activeTierWhere,
|
||||||
order: [['createdAt', 'DESC']],
|
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({
|
const payment = await mdl_Payments.findOne({
|
||||||
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
|
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
|
||||||
@@ -389,6 +450,16 @@ exports.refundOrder = async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
|
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({
|
await mdl_UserTiers.create({
|
||||||
user_id,
|
user_id,
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user