mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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_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 { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
|
||||
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 Unit = require('../../models/courses/units.mdl');
|
||||
const Lesson = require('../../models/courses/lessons.mdl');
|
||||
const paymentSvc = require('../../services/payment.service');
|
||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
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 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: [badgeInclude] }],
|
||||
}],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// ── Inline safety net: expire between cron ticks ──────────────────────────
|
||||
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 subscription retrieved.', {
|
||||
...freeTier,
|
||||
active_tiers: [freeTier],
|
||||
top_tier: 'free',
|
||||
just_expired,
|
||||
my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] },
|
||||
});
|
||||
}
|
||||
|
||||
// Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id
|
||||
// granted by ANY of this user's currently-active tiers, flattened, so the
|
||||
// client can compute per-plan overlap (see PlanList.jsx) without a
|
||||
// separate endpoint per plan.
|
||||
const myGrantRows = await mdl_UserTierGrants.findAll({
|
||||
where: { user_tier_id: stillActive.map((t) => t.tier_id) },
|
||||
attributes: ['item_type', 'item_id'],
|
||||
});
|
||||
const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] };
|
||||
for (const g of myGrantRows) {
|
||||
if (g.item_type === 'course') my_grants.course_ids.push(g.item_id);
|
||||
else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id);
|
||||
else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 subscription retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET MY TIER]', err);
|
||||
return R.error(res, 'Could not retrieve subscription.', 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, 'Subscription history retrieved.', history);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET MY TIER HISTORY]', err);
|
||||
return R.error(res, 'Could not retrieve subscription history.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLANS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPlans = async (req, res) => {
|
||||
try {
|
||||
const plans = await mdl_TierPlans.findAll({
|
||||
where: { status: 'published' },
|
||||
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
|
||||
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active', 'is_recommended'],
|
||||
include: [
|
||||
{
|
||||
model: Course,
|
||||
as: 'courses',
|
||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Unit,
|
||||
as: 'units',
|
||||
attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Each plan holds exactly one bundle type (single-type bundles, Tier Plans
|
||||
// v2) — expose the exact item id sets so the client can compute
|
||||
// overlap-with-existing-access without extra round-trips (see PlanList.jsx).
|
||||
const result = plans.map((p) => {
|
||||
const plain = p.toJSON();
|
||||
plain.course_count = plain.courses?.length ?? 0;
|
||||
plain.unit_count = plain.units?.length ?? 0;
|
||||
plain.lesson_count = plain.lessons?.length ?? 0;
|
||||
plain.course_ids = (plain.courses ?? []).map((c) => c.course_id);
|
||||
plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id);
|
||||
plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id);
|
||||
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 } = 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, status: 'published' } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
|
||||
|
||||
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 } = 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, status: 'published' } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
// Repurchasing the SAME plan while it's already active is allowed — it
|
||||
// extends the existing grant's expires_at (see captureOrder) rather than
|
||||
// being blocked. A different plan at the same tier slug is NOT the same
|
||||
// purchase — it creates its own independent user_tiers row with its own
|
||||
// item-specific grants, so this check is keyed on plan_id, not tier.
|
||||
// Surfaced here only for checkout-page messaging.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, plan_id, status: 'active' },
|
||||
attributes: ['expires_at'],
|
||||
});
|
||||
|
||||
const effectivePrice = Number(plan.price);
|
||||
const effectiveCurrency = plan.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),
|
||||
extends_existing: !!existingActive,
|
||||
current_expires_at: existingActive?.expires_at ?? null,
|
||||
}, 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];
|
||||
|
||||
// PayPal can return an HTTP 2xx from the capture endpoint even when the
|
||||
// charge itself was declined or held for review (e.g. capture.status
|
||||
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
|
||||
// status field must be checked explicitly before granting any access.
|
||||
const captureStatus = capture?.status ?? captureData.status;
|
||||
if (captureStatus !== 'COMPLETED') {
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
|
||||
});
|
||||
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
|
||||
}
|
||||
|
||||
// Repurchasing THIS SAME plan while already active extends its expires_at
|
||||
// by the new duration, rather than being blocked/refunded. A different
|
||||
// plan — even at the same tier slug — is a distinct purchase and gets its
|
||||
// own user_tiers row with its own item-specific grants (see
|
||||
// snapshotPlanGrants below); it must NOT be merged into an unrelated
|
||||
// plan's row just because the tier slug matches (Tier Plans v2 — a Unit
|
||||
// bundle and a Course bundle can both be "premium" and both need to stay
|
||||
// independently active/tracked).
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' },
|
||||
});
|
||||
|
||||
let resultTier;
|
||||
let successMessage;
|
||||
|
||||
if (existingActive) {
|
||||
const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000);
|
||||
await existingActive.update({ expires_at: newExpiresAt });
|
||||
resultTier = existingActive;
|
||||
successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`;
|
||||
} else {
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
|
||||
|
||||
resultTier = 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,
|
||||
});
|
||||
successMessage = 'Payment successful. Subscription activated.';
|
||||
}
|
||||
|
||||
// Snapshot the plan's current bundle contents into user_tier_grants —
|
||||
// refreshed on every purchase/extension so an admin's bundle edits since
|
||||
// the last purchase are picked up, but past purchasers of OTHER plans are
|
||||
// never retroactively affected (Tier Plans v2 item-specific entitlement).
|
||||
await snapshotPlanGrants(resultTier, payment.plan_id);
|
||||
|
||||
await payment.update({
|
||||
status: 'completed',
|
||||
tier_id: resultTier.tier_id,
|
||||
paid_at: new Date(),
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
capture_id: capture?.id,
|
||||
payer_id: captureData.payer?.payer_id,
|
||||
capture: captureData,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent (grantAchievement checks for an existing row first) — safe
|
||||
// to call again on an extension, won't grant a duplicate achievement.
|
||||
await onTierActivated(req.user.user_id, resultTier.tier);
|
||||
|
||||
return R.success(res, successMessage, {
|
||||
tier: resultTier.tier,
|
||||
expires_at: resultTier.expires_at,
|
||||
extended: !!existingActive,
|
||||
});
|
||||
} 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;
|
||||
// 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 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 (!activeTierCandidates.length) return R.error(res, 'No active subscription 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' },
|
||||
order: [['paid_at', 'DESC']],
|
||||
});
|
||||
if (!payment) return R.error(res, 'No completed payment found for this subscription.', 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 });
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] });
|
||||
|
||||
try {
|
||||
const notify = NOTIFICATION_REGISTRY.payment_refunded.build({
|
||||
label: plan?.label ?? 'your plan',
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
planId: plan?.plan_id ?? null,
|
||||
});
|
||||
await UserNotification.create({ user_id, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[CLIENT][REFUND][NOTIFY]', notifyErr);
|
||||
}
|
||||
|
||||
try {
|
||||
const name = req.user.personal_info?.name?.full_name ?? 'there';
|
||||
sendEmail({
|
||||
to: req.user.email,
|
||||
type: 'REFUND_PROCESSED',
|
||||
data: {
|
||||
name,
|
||||
label: plan?.label ?? 'your plan',
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
date: fmtDate(now),
|
||||
refundId: refundData.id,
|
||||
},
|
||||
}).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr));
|
||||
} catch (emailErr) {
|
||||
console.error('[CLIENT][REFUND][EMAIL]', emailErr);
|
||||
}
|
||||
|
||||
// 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',
|
||||
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, 'Subscription categories retrieved.', categories);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET TIER CATEGORIES]', err);
|
||||
return R.error(res, 'Could not retrieve subscription 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user