import db from '../database/models/index.js'; import { Op } from 'sequelize'; const { User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FffClearance } = db; import { TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS, REQUEST_TYPES } from '../common/config/constants.js'; import { getTerminationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; import ExternalMocksService from '../common/utils/externalMocks.service.js'; import logger from '../common/utils/logger.js'; import { NomenclatureService } from '../common/utils/nomenclature.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { ParticipantService } from './ParticipantService.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class TerminationWorkflowService { /** * Standardized method to transition a termination request status */ static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) { const { action, remarks, status, transaction } = metadata; const sourceStage = termination.currentStage; const wasOnHold = String(termination.status || '').toLowerCase() === 'on hold'; const updateData: any = { currentStage: targetStage, status: status || getTerminationStatusForStage(targetStage), progressPercentage: this.calculateProgress(targetStage), updatedAt: new Date() }; // 1. Resolve Actor const actor = userId ? await User.findByPk(userId) : null; // 2. Prepare Timeline Entry const timelineEntry = { stage: sourceStage, // Correctly Associate remark with the stage where action happened targetStage: targetStage, timestamp: new Date(), user: actor ? actor.fullName : 'System', role: actor ? actor.roleCode : null, action: action || `Approved to ${targetStage}`, remarks: remarks || '' }; const updatedTimeline = [...(termination.timeline || []), timelineEntry]; // 3. Perform Consolidated Update await termination.update({ ...updateData, timeline: updatedTimeline }, transaction ? { transaction } : undefined); await syncSlaOnStageTransition({ entityType: 'termination', entityId: termination.id, fromStage: sourceStage, toStage: targetStage }); if (wasOnHold) { const { SLAService } = await import('./SLAService.js'); await SLAService.resumeEntityTracks('termination', termination.id); } // 4. Create Audit Log using standardized mapper const { actionType } = metadata; const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION); await db.TerminationAudit.create({ userId: userId, terminationRequestId: termination.id, action: formatOffboardingAction(auditAction), remarks: remarks || '', details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) } }, transaction ? { transaction } : undefined); // 5. Create Worknote for standardized communication trail if (remarks && userId) { try { // Prepend action to remarks for better context in Work Notes const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : ''; await writeWorkflowActivityWorknote({ requestId: termination.id, requestType: 'termination', userId: userId, noteText: `${actionPrefix}${remarks}`, noteType: (action && action.toLowerCase().includes('send back')) ? 'workflow' : 'internal' }); } catch (wnErr) { logger.error('[TerminationWorkflowService] failed to write worknote:', wnErr); } } // 4. Send Notifications const user = await User.findOne({ where: { [Op.or]: [ { id: termination.dealerId }, { dealerId: termination.dealerId } ] }, attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); if (user) { const portalBase = getFrontendBaseUrl(); const dealerPortalLink = `${portalBase}/termination/${termination.id}`; const isScnIssued = targetStage === TERMINATION_STAGES.SCN_ISSUED; const deadlineRaw = metadata.scnDeadline ?? metadata.deadline ?? termination.proposedLwd; let deadlineStr = ''; try { if (deadlineRaw instanceof Date) { deadlineStr = deadlineRaw.toLocaleDateString('en-IN', { dateStyle: 'medium' }); } else if (deadlineRaw) { deadlineStr = String(deadlineRaw); } else { deadlineStr = new Date(Date.now() + 7 * 86400000).toLocaleDateString('en-IN', { dateStyle: 'medium' }); } } catch { deadlineStr = String(deadlineRaw || ''); } if (isScnIssued) { await NotificationService.notify(user.id, user.email, { title: `URGENT: Show Cause Notice issued — ${termination.requestId}`, message: `A Show Cause Notice has been issued for your termination request.`, channels: ['email', 'whatsapp', 'system'], templateCode: 'TERMINATION_SCN_ISSUED', placeholders: { dealerName: user.fullName || 'Dealer', terminationId: termination.requestId, deadline: deadlineStr, link: dealerPortalLink, remarks: remarks || '', ctaLabel: 'Submit response', phone: user?.mobileNumber || user?.phone || '' } }); } const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); await notifyStakeholdersOnTransition( termination.id, REQUEST_TYPES.TERMINATION, targetStage, { code: termination.requestId, dealerName: user.fullName || 'Dealer', dealerId: user.id, actionUserFullName: actor ? actor.fullName : 'System', action: action || `Approved to ${targetStage}`, remarks: remarks || 'N/A', link: dealerPortalLink } ); // 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5) // We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) { logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`); await user.update({ status: 'inactive', isActive: false }); } } else { logger.warn(`[TerminationWorkflowService] No user account found with dealerId ${termination.dealerId}`); } return termination; } /** * Creates the FnF settlement record — ONLY call from explicit Push to F&F (`manualTrigger: true`). */ static async initiateFnF( termination: any, userId: string, transaction: any, options: { manualTrigger?: boolean } = {} ) { if (!options.manualTrigger) { throw new Error( 'F&F settlement for termination must be started via Push to F&F (manual trigger only).' ); } // 1. Get Dealer User with associated Outlets const dealerUser = await User.findOne({ where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER }, include: [{ model: db.Outlet, as: 'outlets' }] }); const primaryOutlet = dealerUser?.outlets?.find((o: any) => o.isPrimary) || dealerUser?.outlets?.[0]; const dealerProfile = await Dealer.findByPk(termination.dealerId); if (!dealerProfile) throw new Error('Dealer record not found for termination'); // 2. Resolve or Create FnF Settlement let fnf = await db.FnF.findOne({ where: { terminationRequestId: termination.id } }); let fnfId = fnf?.id; if (!fnf) { fnf = await db.FnF.create( { settlementId: await NomenclatureService.generateFnFId(), terminationRequestId: termination.id, dealerId: termination.dealerId, outletId: primaryOutlet?.id || null, status: 'Initiated', totalReceivables: 0, totalPayables: 0, netAmount: 0 }, transaction ? { transaction } : undefined ); await db.FffClearance.bulkCreate( FNF_DEPARTMENTS.map(dept => ({ fnfId: fnf.id, department: dept, status: 'Pending' })), transaction ? { transaction } : undefined ); const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js'); await startAllPendingFnfClearanceSlas(fnf.id); await db.FnFAudit.create( { userId, fnfId: fnf.id, action: 'INITIATED', remarks: 'F&F settlement created via manual Push to F&F (termination)', details: { source: 'Termination Workflow', terminationRequestId: termination.requestId, manualTrigger: true } }, transaction ? { transaction } : undefined ); fnfId = fnf.id; } // 3. External SAP Sync ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive') .catch(err => console.error('Error syncing termination deactivation to SAP:', err)); // 4. Assign Participants for F&F (Sub-application chat) if (fnfId) { await ParticipantService.assignFnFParticipants(fnfId); // SRS §1.1.1: Notify DD Admin and Finance via Email & WhatsApp try { const adminUsers = await User.findAll({ where: { roleCode: [ROLES.DD_ADMIN, ROLES.FINANCE] }, attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); const portalBase = getFrontendBaseUrl(); for (const u of adminUsers) { const phone = u.mobileNumber || null; await NotificationService.notify(u.id, u.email, { title: `F&F Settlement Initiated: ${termination.requestId}`, message: `Full & Final Settlement has been initiated for ${dealerUser?.fullName || 'Dealer'}.`, channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'], templateCode: 'FNF_INITIATED', placeholders: { dealerName: dealerUser?.fullName || 'Dealer', requestId: termination.requestId, link: `${portalBase}/fnf/${fnf.id}`, phone: phone || '' } }); } } catch (notifyErr) { console.error('[TerminationWorkflowService] F&F initiation notification failed:', notifyErr); } } return fnf; } /** * Maps termination stages to progress percentage */ static calculateProgress(stage: string): number { const progress: Record = { [TERMINATION_STAGES.SUBMITTED]: 10, [TERMINATION_STAGES.RBM_REVIEW]: 20, [TERMINATION_STAGES.ZBH_REVIEW]: 30, [TERMINATION_STAGES.DD_LEAD_REVIEW]: 40, [TERMINATION_STAGES.LEGAL_VERIFICATION]: 45, [TERMINATION_STAGES.DD_HEAD_REVIEW]: 50, [TERMINATION_STAGES.NBH_EVALUATION]: 60, [TERMINATION_STAGES.SCN_ISSUED]: 70, [TERMINATION_STAGES.PERSONAL_HEARING]: 75, [TERMINATION_STAGES.NBH_FINAL_APPROVAL]: 80, [TERMINATION_STAGES.CCO_APPROVAL]: 85, [TERMINATION_STAGES.CEO_APPROVAL]: 90, [TERMINATION_STAGES.LEGAL_LETTER]: 95, [TERMINATION_STAGES.TERMINATED]: 100, [TERMINATION_STAGES.REJECTED]: 100 }; return progress[stage] || 0; } /** * Records a dealer's response to SCN and moves to personal hearing stage */ static async handleScnResponse(termination: any, data: any, userId: string) { const { responseBody, documents } = data; await TerminationScnResponse.create({ terminationRequestId: termination.id, submittedBy: userId, responseBody, documents: documents || [] }); return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, { action: 'SCN_SUBMITTED', status: 'SCN Response Evaluation Pending', remarks: 'Dealer response submitted' }); } /** * Records a personal hearing outcome and moves to next stage or rejection */ static async handleHearingOutcome(termination: any, data: any, userId: string) { const { attendees, summary, recommendation, momDocumentId } = data; await TerminationHearingRecord.create({ terminationRequestId: termination.id, conductedBy: userId, attendees, summary, recommendation, momDocumentId }); const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL; const status = recommendation === 'Reject' ? 'Rejected after Evaluation' : 'NBH Final Approval Pending'; return this.transitionTermination(termination, nextStage, userId, { action: `Hearing Recorded - ${recommendation}`, status, remarks: summary }); } /** * Checks if a user is authorized to perform an action based on their role and current stage */ static async canUserAction(termination: any, user: any) { if (!user) return false; if (user.roleCode === ROLES.SUPER_ADMIN) return true; const stageToRole: Record = { [TERMINATION_STAGES.SUBMITTED]: ROLES.ASM, [TERMINATION_STAGES.RBM_REVIEW]: [ROLES.RBM, ROLES.DD_ZM], [TERMINATION_STAGES.ZBH_REVIEW]: ROLES.ZBH, [TERMINATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD, [TERMINATION_STAGES.LEGAL_VERIFICATION]: ROLES.LEGAL_ADMIN, [TERMINATION_STAGES.DD_HEAD_REVIEW]: ROLES.DD_HEAD, [TERMINATION_STAGES.NBH_EVALUATION]: ROLES.NBH, [TERMINATION_STAGES.SCN_ISSUED]: [ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN], [TERMINATION_STAGES.PERSONAL_HEARING]: [ROLES.NBH, ROLES.DD_LEAD, ROLES.RBM, ROLES.ZBH, ROLES.DD_HEAD], [TERMINATION_STAGES.NBH_FINAL_APPROVAL]: ROLES.NBH, [TERMINATION_STAGES.CCO_APPROVAL]: ROLES.CCO, [TERMINATION_STAGES.CEO_APPROVAL]: ROLES.CEO, [TERMINATION_STAGES.LEGAL_LETTER]: ROLES.LEGAL_ADMIN }; const stageAliases: Record = { 'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING, 'Show Cause Notice': TERMINATION_STAGES.SCN_ISSUED }; const normalizedStage = stageAliases[termination.currentStage] || termination.currentStage; const requiredRole = stageToRole[normalizedStage]; if (Array.isArray(requiredRole)) { return requiredRole.includes(user.roleCode); } return user.roleCode === requiredRole; } }