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
+111 -40
View File
@@ -28,59 +28,83 @@ 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 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' },
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 }],
}],
include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }],
}],
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,
planId: tier.plan?.plan_id ?? null,
}),
}).catch(() => {});
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 tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true,
...freeTier,
active_tiers: [freeTier],
top_tier: 'free',
just_expired,
});
}
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 });
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());
}
}
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);
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;
}
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) {
console.error('[CLIENT][GET MY TIER]', err);
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 } });
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 effectiveCurrency = plan.currency;
@@ -266,10 +297,30 @@ exports.captureOrder = async (req, res) => {
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } }
);
// Scoped, defensive re-check: createOrder already blocked this, but time may
// have passed (or two checkout tabs raced) between order creation and capture.
// 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 expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
@@ -340,12 +391,22 @@ exports.cancelOrder = async (req, res) => {
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 activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
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 (!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({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
@@ -389,6 +450,16 @@ exports.refundOrder = async (req, res) => {
const now = new Date();
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({
user_id,
tier: 'free',