ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
+340
View File
@@ -0,0 +1,340 @@
/***********************************************************************************************************************************************************************
* 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);
}
};