const ReferralTokenRepository = require('../../repositories/referral/ReferralTokenRepository'); const PersonalUserRepository = require('../../repositories/user/personal/PersonalUserRepository'); const CompanyUserRepository = require('../../repositories/user/company/CompanyUserRepository'); const UserStatusService = require('../status/UserStatusService'); const UserSettingsRepository = require('../../repositories/settings/UserSettingsRepository'); const MailService = require('../email/MailService'); const { logger } = require('../../middleware/logger'); class ReferralService { // --- Token creation and management --- static async createReferralToken({ userId, expiresInDays, maxUses, unitOfWork }) { logger.info('ReferralService:createReferralToken:start', { userId, expiresInDays, maxUses }); const normalizeUnlimited = (val, label) => { if (val === undefined || val === null || val === '') return -1; if (typeof val === 'string') { const trimmed = val.trim().toLowerCase(); if (['unlimited','∞','-1'].includes(trimmed)) return -1; const num = Number(val); if (!Number.isNaN(num)) val = num; } // Treat 0 as unlimited (frontend currently sends 0 for unlimited) if (val === 0) { logger.debug(`ReferralService:createReferralToken:interpreting_0_as_unlimited`, { field: label }); return -1; } return val; }; expiresInDays = normalizeUnlimited(expiresInDays, 'expiresInDays'); maxUses = normalizeUnlimited(maxUses, 'maxUses'); if (!(expiresInDays === -1 || (Number.isInteger(expiresInDays) && expiresInDays >= 1 && expiresInDays <= 7))) { throw new Error('Expiry must be between 1 and 7 days or unlimited'); } if (!(maxUses === -1 || (Number.isInteger(maxUses) && maxUses >= 1))) { throw new Error('maxUses must be a positive integer or unlimited'); } const unlimited = maxUses === -1; if (unlimited) logger.info('ReferralService:createReferralToken:unlimited', { userId }); const UNLIMITED_EXPIRY_DATE = new Date('2999-12-31T23:59:59Z'); const expiresAt = (expiresInDays === -1) ? UNLIMITED_EXPIRY_DATE : new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); const repo = new ReferralTokenRepository(unitOfWork); const token = await repo.createToken({ createdByUserId: userId, expiresAt, maxUses }); logger.info('ReferralService:createReferralToken:success', { token: token.token, unlimited, storedMaxUses: token.maxUses, storedUsesRemaining: token.usesRemaining }); return this.presentToken({ id: token.id, token: token.token, created_by_user_id: token.createdByUserId, expires_at: token.expiresAt, status: token.status, max_uses: token.maxUses, uses_remaining: token.usesRemaining }); } static async getUserReferralTokens(userId, unitOfWork) { logger.info('ReferralService:getUserReferralTokens:start', { userId }); const repo = new ReferralTokenRepository(unitOfWork); const rows = await repo.getTokensByUser(userId); const base = process.env.REFERRAL_PUBLIC_BASE_URL || 'https://profit-planet.partners/register?ref='; const tokens = rows.map(r => { const max = r.max_uses; const remaining = r.uses_remaining; const isUnlimited = (max === -1) || (remaining === -1); let used; if (isUnlimited) { used = 0; } else if (typeof r.used_count === 'number') { used = r.used_count; } else if (Number.isFinite(max) && Number.isFinite(remaining)) { used = max - remaining; } else { used = 0; } if (!Number.isFinite(used) || used < 0) used = 0; if (!isUnlimited && used > max) used = max; const maxUsesCanonical = isUnlimited ? null : max; const usage = isUnlimited ? 'unlimited' : `${used}/${maxUsesCanonical}`; return { id: r.id, token: r.token, link: `${base}${r.token}`, status: r.status, created: r.created_at ? new Date(r.created_at).toISOString() : null, expires: isUnlimited ? null : (r.expires_at ? new Date(r.expires_at).toISOString() : null), used, maxUses: maxUsesCanonical, isUnlimited, usage, progress: usage, // Aliases (defensive) useCount: used, usedCount: used }; }); tokens.slice(0, 5).forEach(t => { logger.debug('ReferralService:getUserReferralTokens:canonical', { id: t.id, usage: t.usage, used: t.used, maxUses: t.maxUses, isUnlimited: t.isUnlimited }); }); logger.info('ReferralService:getUserReferralTokens:success', { count: tokens.length }); return tokens; } static async getUserReferralStats(userId, unitOfWork) { logger.info('ReferralService:getUserReferralStats:start', { userId }); const repo = new ReferralTokenRepository(unitOfWork); const stats = await repo.getStatsByUser(userId); logger.info('ReferralService:getUserReferralStats:success', { stats }); // Pass through all fields including new ones return stats; } static async deactivateReferralToken(userId, tokenId, unitOfWork) { logger.info('ReferralService:deactivateReferralToken:start', { userId, tokenId }); const repo = new ReferralTokenRepository(unitOfWork); await repo.deactivateToken(tokenId, userId); logger.info('ReferralService:deactivateReferralToken:success', { userId, tokenId }); } static async countActiveReferralTokens(userId, unitOfWork) { logger.info('ReferralService:countActiveReferralTokens:start', { userId }); const repo = new ReferralTokenRepository(unitOfWork); const count = await repo.countActiveTokensByUser(userId); logger.info('ReferralService:countActiveReferralTokens:success', { userId, count }); return count; } static presentToken(row) { if (!row) return row; // Raw DB fields const maxNum = (row.max_uses !== undefined ? row.max_uses : row.maxUses); const remNum = (row.uses_remaining !== undefined ? row.uses_remaining : row.usesRemaining); const createdRaw = row.created_at || row.createdAt; const expiresRaw = row.expires_at || row.expiresAt; const createdDate = createdRaw ? new Date(createdRaw) : null; const createdValid = createdDate && !isNaN(createdDate.getTime()); const createdISO = createdValid ? createdDate.toISOString() : null; const expiresDate = expiresRaw ? new Date(expiresRaw) : null; const expiresValid = expiresDate && !isNaN(expiresDate.getTime()); const unlimited = (maxNum === -1) || (remNum === -1); const maxDisplay = row.max_uses_display || row.max_uses_label || (unlimited ? 'unlimited' : String(maxNum)); const remDisplay = row.uses_remaining_display || row.uses_remaining_label || (unlimited ? 'unlimited' : String(remNum)); // Numeric total & used let totalCount = unlimited ? -1 : ( typeof row.total_count === 'number' ? row.total_count : (Number.isFinite(maxNum) ? maxNum : 0) ); let usedCount = unlimited ? 0 : ( typeof row.used_count === 'number' ? row.used_count : (Number.isFinite(maxNum) && Number.isFinite(remNum) ? (maxNum - remNum) : 0) ); if (!unlimited) { if (!Number.isFinite(usedCount) || usedCount < 0) usedCount = 0; if (Number.isFinite(totalCount) && usedCount > totalCount) usedCount = totalCount; } const remainingCount = unlimited ? -1 : (Number.isFinite(remNum) ? remNum : (Number.isFinite(totalCount) ? (totalCount - usedCount) : 0)); // Build usage string deterministically (do NOT trust usage_display if present) const usageString = unlimited ? 'unlimited' : (totalCount > 0 ? `${usedCount}/${totalCount}` : '0/0'); const infinitySymbol = '∞'; const denominatorDisplay = unlimited ? infinitySymbol : String(totalCount); const numeratorDisplay = unlimited ? '0' : String(usedCount); // Expiry display (Never for unlimited OR far future year >= 2999) const expiresAtISO = unlimited ? null : (expiresValid ? expiresDate.toISOString() : null); const expiresAtDisplay = unlimited ? 'Never' : (expiresValid ? expiresDate.toISOString() : 'Unknown'); // Numeric maxUses for FE (for unlimited keep -1 sentinel) const maxUsesNumber = unlimited ? -1 : (Number.isFinite(totalCount) ? totalCount : 0); // NEW: derive referrer identity (email & name) early so it can be inserted into tokenObj const referrerEmailDerived = row.referrer_email || row.referrerEmail || row.email || row.created_by_email || row.createdByEmail || null; // Try to build a display name if first/last name fields exist (silent if not) const refFirst = row.referrer_first_name || row.first_name || row.firstName; const refLast = row.referrer_last_name || row.last_name || row.lastName; let referrerNameDerived = row.referrer_name || row.referrerName || null; if (!referrerNameDerived && (refFirst || refLast)) { referrerNameDerived = [refFirst, refLast].filter(Boolean).join(' ').trim() || null; } if (!referrerNameDerived) { // fallback to email for display if no name referrerNameDerived = referrerEmailDerived || null; } const tokenObj = { id: row.id, token: row.token, status: row.status, // Core time fields createdAt: createdISO, created_at: createdISO, createdAtISO: createdISO, createdAtEpoch: createdValid ? createdDate.getTime() : null, expiresAt: expiresAtISO, expires_at: expiresAtISO, expiresAtISO, expiresAtEpoch: (expiresAtISO ? expiresDate.getTime() : null), expiresAtDisplay, // Display & numeric usage usage: usageString, usageDisplay: usageString, progress: usageString, // Display values maxUsesDisplay: unlimited ? 'unlimited' : String(maxUsesNumber), usesRemainingDisplay: unlimited ? 'unlimited' : String(remainingCount), // Primary numeric values (camelCase) maxUses: maxUsesNumber, usesRemaining: remainingCount, used: usedCount, isUnlimited: unlimited, // Raw helpers maxUsesRaw: maxUsesNumber, usesRemainingRaw: remainingCount, usesUsedRaw: usedCount, // Additional alias set to satisfy unknown FE expectations limit: maxUsesNumber, total: maxUsesNumber, totalCount: maxUsesNumber, max: maxUsesNumber, capacity: maxUsesNumber, remaining: remainingCount, remainingCount, left: remainingCount, usedCount, currentUses: usedCount, progressUsed: usedCount, totalUses: maxUsesNumber, remainingUses: remainingCount, usedUses: usedCount, // Snake_case (legacy) max_uses: maxUsesNumber, uses_remaining: remainingCount, // Displays for numerator/denominator parts numeratorDisplay, denominatorDisplay, infinitySymbol, // Structured usage object usageData: { used: usedCount, remaining: remainingCount, max: maxUsesNumber, limit: maxUsesNumber, total: maxUsesNumber, isUnlimited: unlimited, display: usageString, numerator: numeratorDisplay, denominator: denominatorDisplay }, // Keep original creation if present (for FE that lists columns generically) created_by_user_id: row.created_by_user_id || row.createdByUserId, createdByUserId: row.created_by_user_id || row.createdByUserId, // Count of individual usage rows usageCount: row.usage_count, // NEW: referrer identity fields (added near end to avoid interfering with existing logic) referrerEmail: referrerEmailDerived, referrerName: referrerNameDerived, // legacy / alias fields some FE might probe ownerEmail: referrerEmailDerived, createdByEmail: referrerEmailDerived, userEmail: referrerEmailDerived }; if (!tokenObj.referrerEmail) { logger.debug('ReferralService:presentToken:referrer_email_missing', { tokenId: row.id, hasRawField: !!row.referrer_email }); } return tokenObj; } // --- Registration via referral --- static evaluateTokenRecord(rec) { if (!rec) return { valid: false, reason: 'not_found' }; // Defensive normalization const rawMax = (rec.max_uses === null || rec.max_uses === undefined) ? -1 : rec.max_uses; const rawRemaining = (rec.uses_remaining === null || rec.uses_remaining === undefined) ? -1 : rec.uses_remaining; const isUnlimited = (rawMax === -1) || (rawRemaining === -1); // Diagnostics logger.debug('ReferralService:evaluateTokenRecord:raw', { status: rec.status, expires_at: rec.expires_at, max_uses: rawMax, uses_remaining: rawRemaining, isUnlimited }); if (rec.status !== 'active') return { valid: false, reason: 'inactive' }; const now = Date.now(); const expMs = rec.expires_at ? new Date(rec.expires_at).getTime() : NaN; if (!isUnlimited) { if (isNaN(expMs) || expMs <= now) return { valid: false, reason: 'expired' }; if (rawRemaining === 0) return { valid: false, reason: 'exhausted' }; if (rawRemaining < 0) { // Inconsistent negative (not -1 sentinel) -> treat as internal issue; allow but log logger.warn('ReferralService:evaluateTokenRecord:negative_remaining_non_unlimited', { uses_remaining: rawRemaining, max_uses: rawMax }); } } return { valid: true, reason: null, isUnlimited, usesRemaining: isUnlimited ? null : rawRemaining }; } static async getReferrerInfo(token, unitOfWork) { logger.info('ReferralService:getReferrerInfo:start', { token }); const repo = new ReferralTokenRepository(unitOfWork); const raw = await repo.getReferrerInfoByToken(token); if (!raw) { logger.warn('ReferralService:getReferrerInfo:not_found', { token }); return { valid: false, reason: 'not_found' }; } const evalResult = this.evaluateTokenRecord(raw); if (!evalResult.valid) { logger.warn('ReferralService:getReferrerInfo:invalid', { token, reason: evalResult.reason, max_uses: raw.max_uses, uses_remaining: raw.uses_remaining }); return { valid: false, reason: evalResult.reason }; } const normalized = this.presentToken(raw); const refEmail = normalized.referrerEmail || raw.referrer_email || raw.email || null; const refName = normalized.referrerName || refEmail; if (!refEmail) { logger.warn('ReferralService:getReferrerInfo:referrer_email_unresolved', { token, tokenId: raw.id }); } else { logger.debug('ReferralService:getReferrerInfo:referrer_email_resolved', { token, refEmail }); } return { valid: true, referrerId: raw.referrer_id || normalized.referrerId || null, referrerName: refName, referrerEmail: refEmail, isUnlimited: normalized.isUnlimited, usesRemaining: normalized.isUnlimited ? -1 : normalized.usesRemaining, maxUses: normalized.maxUses, usage: normalized.usage }; } static async registerPersonalWithReferral(registrationData, refToken, unitOfWork) { logger.info('ReferralService:registerPersonalWithReferral:start', { refToken }); const repo = new ReferralTokenRepository(unitOfWork); const raw = await repo.getReferrerInfoByToken(refToken); const evalResult = this.evaluateTokenRecord(raw); if (!evalResult.valid) { logger.warn('ReferralService:registerPersonalWithReferral:token_invalid', { refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining }); throw new Error(evalResult.reason || 'invalid_token'); } const personalRepo = new PersonalUserRepository(unitOfWork); const user = await personalRepo.create(registrationData); await UserStatusService.initializeUserStatus(user.id, 'personal', unitOfWork, 'inactive'); if (UserSettingsRepository) { const settingsRepo = new UserSettingsRepository(unitOfWork); await settingsRepo.createDefaultSettings(user.id, unitOfWork); } else if (unitOfWork.connection) { await unitOfWork.connection.query(` INSERT INTO user_settings (user_id) VALUES (?) ON DUPLICATE KEY UPDATE user_id = user_id `, [user.id]); } await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork); await MailService.sendRegistrationEmail({ email: registrationData.email, firstName: registrationData.firstName, lastName: registrationData.lastName, userType: 'personal' }); logger.info('ReferralService:registerPersonalWithReferral:success', { userId: user.id, email: user.email }); return user; } static async registerCompanyWithReferral(registrationData, refToken, unitOfWork) { logger.info('ReferralService:registerCompanyWithReferral:start', { refToken }); const repo = new ReferralTokenRepository(unitOfWork); const raw = await repo.getReferrerInfoByToken(refToken); const evalResult = this.evaluateTokenRecord(raw); if (!evalResult.valid) { logger.warn('ReferralService:registerCompanyWithReferral:token_invalid', { refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining }); throw new Error(evalResult.reason || 'invalid_token'); } const companyRepo = new CompanyUserRepository(unitOfWork); const { companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone } = registrationData; const user = await companyRepo.create({ companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone }); await UserStatusService.initializeUserStatus(user.id, 'company', unitOfWork, 'inactive'); if (UserSettingsRepository) { const settingsRepo = new UserSettingsRepository(unitOfWork); await settingsRepo.createDefaultSettings(user.id, unitOfWork); } else if (unitOfWork.connection) { await unitOfWork.connection.query(` INSERT INTO user_settings (user_id) VALUES (?) ON DUPLICATE KEY UPDATE user_id = user_id `, [user.id]); } await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork); await MailService.sendRegistrationEmail({ email: registrationData.companyEmail, companyName: registrationData.companyName, userType: 'company' }); logger.info('ReferralService:registerCompanyWithReferral:success', { userId: user.id, email: user.email }); return user; } // --- Referral usage processing --- static async processReferral(userId, referralEmail, unitOfWork) { logger.info('ReferralService:processReferral:start', { userId, referralEmail }); try { const referrer = await this.findReferrerByEmail(referralEmail, unitOfWork); if (!referrer) { logger.warn('ReferralService:processReferral:referrer_not_found', { referralEmail }); return false; } const referralToken = await this.findActiveReferralToken(referrer.id, unitOfWork); if (!referralToken) { logger.warn('ReferralService:processReferral:no_active_token', { referrerId: referrer.id }); return false; } await this.createReferralUsage(referralToken.id, userId, unitOfWork); await this.updateTokenUsage(referralToken.id, unitOfWork); logger.info('ReferralService:processReferral:success', { userId, referralTokenId: referralToken.id }); return true; } catch (error) { logger.error('ReferralService:processReferral:error', { userId, referralEmail, error }); return false; } } static async findReferrerByEmail(email, unitOfWork) { logger.debug('ReferralService:findReferrerByEmail', { email }); const conn = unitOfWork.connection; const query = `SELECT id FROM users WHERE email = ?`; const [rows] = await conn.query(query, [email]); return rows.length > 0 ? rows[0] : null; } static async findActiveReferralToken(userId, unitOfWork) { logger.debug('ReferralService:findActiveReferralToken', { userId }); const conn = unitOfWork.connection; const query = ` SELECT * FROM referral_tokens WHERE created_by_user_id = ? AND status = 'active' AND expires_at > NOW() LIMIT 1 `; const [rows] = await conn.query(query, [userId]); return rows.length > 0 ? rows[0] : null; } static async createReferralUsage(tokenId, userId, unitOfWork) { logger.debug('ReferralService:createReferralUsage', { tokenId, userId }); const conn = unitOfWork.connection; const query = ` INSERT INTO referral_token_usage (referral_token_id, used_by_user_id) VALUES (?, ?) `; await conn.query(query, [tokenId, userId]); } static async updateTokenUsage(tokenId, unitOfWork) { logger.debug('ReferralService:updateTokenUsage', { tokenId }); const conn = unitOfWork.connection; const query = ` UPDATE referral_tokens SET uses_remaining = uses_remaining - 1 WHERE id = ? AND uses_remaining > 0 `; await conn.query(query, [tokenId]); } static async getReferredUsers(userId, unitOfWork) { logger.info('ReferralService:getReferredUsers:start', { userId }); const repo = new ReferralTokenRepository(unitOfWork); const rows = await repo.getReferredUsersByCreator(userId); const users = rows.map(r => { let registeredAt = null; if (r.registered_at) { const d = new Date(r.registered_at); registeredAt = isNaN(d.getTime()) ? null : d.toISOString(); } return { id: r.id, name: r.name || r.email, email: r.email, type: r.user_type, registeredAt, status: r.status || null }; }); const result = { total_referred_users: users.length, users }; logger.info('ReferralService:getReferredUsers:success', { userId, total: result.total_referred_users }); return result; } } module.exports = ReferralService;