479 lines
18 KiB
JavaScript
479 lines
18 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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);
|
|
}
|
|
};
|