import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db; import { AuthRequest } from '../../types/express.types.js'; import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES, ROLES } from '../../common/config/constants.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; const LINE_ITEM_DESCRIPTION_PREFIX = { DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', FINANCE_VALIDATED: '[FINANCE_VALIDATED]' } as const; const DEPARTMENT_CLAIM_DESCRIPTION = `${LINE_ITEM_DESCRIPTION_PREFIX.DEPARTMENT_CLAIM} Department Clearance - Manual Update`; const withPrefix = (description: string, prefix: string) => { if (!description) return prefix; return description.startsWith(prefix) ? description : `${prefix} ${description}`; }; const isDepartmentClaimLineItem = (item: any) => typeof item?.description === 'string' && item.description.startsWith(LINE_ITEM_DESCRIPTION_PREFIX.DEPARTMENT_CLAIM); const isFinanceValidatedLineItem = (item: any) => typeof item?.description === 'string' && item.description.startsWith(LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED); const getActiveLineItems = (lineItems: any[]) => (lineItems || []).filter((item: any) => item.isActive !== false); const ensureFinanceDraftsFromDepartmentClaims = async (fnfId: string, userId: string | null = null) => { const allLineItems = await FnFLineItem.findAll({ where: { fnfId }, order: [['createdAt', 'ASC']] }); const activeDepartmentClaims = allLineItems.filter((item: any) => item.isActive !== false && (item.sourceType === 'DepartmentClaim' || isDepartmentClaimLineItem(item)) ); for (const claim of activeDepartmentClaims) { const claimRootId = (claim as any).parentLineItemId || claim.id; const hasFinanceLineForClaim = allLineItems.some((item: any) => (item.sourceType === 'FinanceValidated' || isFinanceValidatedLineItem(item)) && ((item as any).parentLineItemId === claimRootId || item.department === claim.department) ); if (hasFinanceLineForClaim) continue; const claimAmount = Math.abs(Number((claim as any).claimAmount ?? claim.amount) || 0); await FnFLineItem.create({ fnfId, itemType: claim.itemType, description: withPrefix('Auto-seeded from department claim', LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED), department: claim.department, amount: claimAmount, addedBy: userId, sourceType: 'FinanceValidated', version: 1, isActive: true, parentLineItemId: claimRootId, claimAmount, validatedAmount: claimAmount, varianceAmount: 0, financeDecision: 'Accepted', varianceReason: null }); } }; export const getDepartments = async (req: Request, res: Response) => { try { res.json({ success: true, departments: FNF_DEPARTMENTS }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching departments' }); } }; export const getOnboardingPayments = async (req: Request, res: Response) => { try { const payments = await FinancePayment.findAll({ include: [{ model: Application, as: 'application', attributes: ['applicantName', 'applicationId'] }], order: [['createdAt', 'ASC']] }); res.json({ success: true, payments }); } catch (error) { console.error('Get onboarding payments error:', error); res.status(500).json({ success: false, message: 'Error fetching payments' }); } }; export const updatePayment = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const { paidDate, amount, transactionReference, status, remarks } = req.body; const payment = await FinancePayment.findByPk(id); if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' }); const previousPaymentSnapshot = { paymentStatus: payment.paymentStatus, paymentType: payment.paymentType, amount: payment.amount, transactionId: payment.transactionId, }; const isVerifying = status === 'Paid' && payment.paymentStatus !== 'Paid'; await payment.update({ paymentDate: paidDate || payment.paymentDate, amount: amount || payment.amount, transactionId: transactionReference || payment.transactionId, paymentStatus: status || payment.paymentStatus, remarks: remarks || payment.remarks, verifiedBy: isVerifying ? req.user?.id : payment.verifiedBy, verificationDate: isVerifying ? new Date() : payment.verificationDate, updatedAt: new Date() }); // Re-fetch with verifier details for frontend const updatedPayment = await FinancePayment.findByPk(id, { include: [ { model: Application, as: 'application', attributes: ['applicantName', 'applicationId'] }, { model: User, as: 'verifier', attributes: ['fullName'] } ] }); const p = updatedPayment || payment; await safeAuditLogCreate({ userId: req.user?.id || null, action: AUDIT_ACTIONS.PAYMENT_UPDATED, entityType: 'application', entityId: payment.applicationId, oldData: { paymentId: payment.id, ...previousPaymentSnapshot }, newData: { paymentId: payment.id, paymentType: p.paymentType, paymentStatus: p.paymentStatus, financeVerified: !!isVerifying, amount: p.amount, transactionId: p.transactionId, remarks: p.remarks, }, }); res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment }); } catch (error) { console.error('Update payment error:', error); res.status(500).json({ success: false, message: 'Error updating payment' }); } }; export const updateFnF = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const { finalSettlementAmount, status, settlementDate, paymentMode, transactionReference, remarks } = req.body; const fnf = await FnF.findByPk(id); if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); const normalizedStatus = normalizeFnFStatus(status || fnf.status); await fnf.update({ status: normalizedStatus, netAmount: finalSettlementAmount || fnf.netAmount, settlementAmount: finalSettlementAmount || fnf.settlementAmount, settlementDate: settlementDate || fnf.settlementDate, paymentMode: paymentMode || fnf.paymentMode, transactionReference: transactionReference || fnf.transactionReference, remarks: remarks || fnf.remarks, updatedAt: new Date() }); await AuditLog.create({ userId: req.user?.id || null, action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: id, newData: { status: normalizedStatus, netAmount: finalSettlementAmount, remarks } }); // If status is being set to Completed, transition parent request via workflow services if (normalizedStatus === FNF_STATUS.COMPLETED) { if (fnf.resignationId) { const resignation = await Resignation.findByPk(fnf.resignationId); if (resignation) { await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, req.user?.id || null, { action: 'F&F Settlement Completed', remarks: remarks || 'F&F marked completed from settlement module.', status: 'Completed' }); } } else if (fnf.terminationRequestId) { const termination = await TerminationRequest.findByPk(fnf.terminationRequestId); if (termination) { await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.TERMINATED, req.user?.id || null, { action: 'F&F Settlement Completed', remarks: remarks || 'F&F marked completed from settlement module.', status: 'Terminated' }); } } } res.json({ success: true, message: 'F&F settlement updated successfully', data: fnf }); } catch (error) { console.error('Update F&F error:', error); res.status(500).json({ success: false, message: 'Error updating F&F settlement' }); } }; export const getFnFSettlements = async (req: Request, res: Response) => { try { const settlements = await FnF.findAll({ include: [ { model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] }, { model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] }, { model: db.Dealer, as: 'dealer', attributes: ['legalName', 'businessName', 'id'] }, { model: Outlet, as: 'outlet', include: [{ model: User, as: 'dealer', attributes: ['fullName', 'id'] }] }, { model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' } ], order: [['createdAt', 'DESC']] }); res.json({ success: true, settlements }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching settlements' }); } }; export const getFnFById = async (req: Request, res: Response) => { try { const { id } = req.params; const includeConfig = [ { model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] }, { model: TerminationRequest, as: 'terminationRequest' }, { model: Outlet, as: 'outlet', include: [{ model: User, as: 'dealer', include: [{ model: db.Dealer, as: 'dealerProfile', include: [ { model: db.DealerCode, as: 'dealerCode' }, { model: db.DealerBankDetail, as: 'bankDetails' } ] }] }] }, { model: db.Dealer, as: 'dealer', include: [ { model: db.DealerCode, as: 'dealerCode' }, { model: db.DealerBankDetail, as: 'bankDetails' } ] }, { model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] } ]; const fnf = await FnF.findByPk(id, { include: includeConfig }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); await ensureFinanceDraftsFromDepartmentClaims(id, null); const fnfWithDrafts = await FnF.findByPk(id, { include: [ ...includeConfig ] }); if (!fnfWithDrafts) return res.status(404).json({ success: false, message: 'F&F not found' }); res.json({ success: true, fnf: fnfWithDrafts }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching F&F' }); } }; export const addLineItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const { itemType, description, department, amount } = req.body; const fnf = await FnF.findByPk(id); if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); if (fnf.status === FNF_STATUS.COMPLETED) { return res.status(400).json({ success: false, message: 'Cannot add line items after settlement completion' }); } const lineItem = await FnFLineItem.create({ fnfId: id, itemType, description: withPrefix(description, LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED), department, amount: Math.abs(Number(amount) || 0), addedBy: req.user?.id, sourceType: 'FinanceValidated', version: 1, isActive: true, validatedAmount: Math.abs(Number(amount) || 0), financeDecision: 'Accepted', varianceAmount: 0 }); // Update FnF progress and department statuses await calculateFnFLogic(id as string, req.user?.id); await AuditLog.create({ userId: req.user?.id || null, action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: id, newData: { action: 'ADD_LINE_ITEM', department, amount, description } }); res.json({ success: true, lineItem }); } catch (error) { res.status(500).json({ success: false, message: 'Error adding line item' }); } }; export const updateLineItem = async (req: AuthRequest, res: Response) => { try { const { itemId } = req.params; const { description, department, amount } = req.body; const lineItem = await FnFLineItem.findByPk(itemId); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); const fnf = await FnF.findByPk(lineItem.fnfId); if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); if (fnf.status === FNF_STATUS.COMPLETED) { return res.status(400).json({ success: false, message: 'Cannot update line items after settlement completion' }); } if (isDepartmentClaimLineItem(lineItem)) { return res.status(400).json({ success: false, message: 'Department claim lines are immutable in Finance edit flow. Use department clearance update.' }); } const nextAmount = Math.abs(Number(amount) || 0); const nextDescription = withPrefix(description || lineItem.description, LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED); const nextDepartment = department || lineItem.department; const prevValidated = Math.abs(Number((lineItem as any).validatedAmount ?? lineItem.amount) || 0); const claimAmount = Math.abs(Number((lineItem as any).claimAmount) || 0) || null; const varianceAmount = claimAmount !== null ? claimAmount - nextAmount : 0; const financeDecision = varianceAmount === 0 ? 'Accepted' : 'Partially Accepted'; const varianceReason = varianceAmount === 0 ? null : 'Adjusted during finance reconciliation'; await lineItem.update({ isActive: false }); const updatedLineItem = await FnFLineItem.create({ fnfId: lineItem.fnfId, itemType: lineItem.itemType, description: nextDescription, department: nextDepartment, amount: nextAmount, addedBy: req.user?.id || lineItem.addedBy, sourceType: 'FinanceValidated', version: Number((lineItem as any).version || 1) + 1, isActive: true, parentLineItemId: (lineItem as any).parentLineItemId || lineItem.id, claimAmount, validatedAmount: nextAmount, varianceAmount, financeDecision, varianceReason }); await AuditLog.create({ userId: req.user?.id || null, action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: lineItem.fnfId, newData: { action: 'FINANCE_VALIDATION_VERSION_CREATED', previousLineItemId: lineItem.id, newLineItemId: updatedLineItem.id, previousValidatedAmount: prevValidated, validatedAmount: nextAmount, claimAmount, varianceAmount, financeDecision, varianceReason } }); // Update FnF progress and department statuses // Update FnF progress and department statuses await calculateFnFLogic(lineItem.fnfId, req.user?.id); return res.json({ success: true, lineItem: updatedLineItem }); } catch (error) { res.status(500).json({ success: false, message: 'Error updating line item' }); } }; export const deleteLineItem = async (req: AuthRequest, res: Response) => { try { const { itemId } = req.params; const lineItem = await FnFLineItem.findByPk(itemId); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); if (isDepartmentClaimLineItem(lineItem)) { return res.status(400).json({ success: false, message: 'Department claim lines cannot be deleted from Finance edit flow.' }); } const fnfId = lineItem.fnfId; await lineItem.update({ isActive: false }); // Update FnF progress and department statuses // Update FnF progress and department statuses await calculateFnFLogic(fnfId, req.user?.id); await AuditLog.create({ userId: req.user?.id || null, action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: fnfId, newData: { action: 'DELETE_LINE_ITEM', department: lineItem.department, amount: lineItem.amount, softDeleted: true } }); res.json({ success: true, message: 'Line item deleted' }); } catch (error) { res.status(500).json({ success: false, message: 'Error deleting line item' }); } }; // Helper to calculate and update FnF progress const calculateFnFLogic = async (id: string, userId: string | null = null) => { const fnf = await FnF.findByPk(id, { include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }] }); if (!fnf) return null; await ensureFinanceDraftsFromDepartmentClaims(id, userId); const refreshedFnf = await FnF.findByPk(id, { include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }] }); if (!refreshedFnf) return null; const lineItems = getActiveLineItems((refreshedFnf as any).lineItems || []); const clearances = (refreshedFnf as any).clearances || []; let totalReceivables = 0; let totalPayables = 0; let totalDeductions = 0; const hasFinanceValidatedLines = lineItems.some((item: any) => isFinanceValidatedLineItem(item)); const calculationLineItems = hasFinanceValidatedLines ? lineItems.filter((item: any) => isFinanceValidatedLineItem(item)) : lineItems; calculationLineItems.forEach((item: any) => { const amt = Math.abs(parseFloat(item.amount) || 0); if (item.itemType === 'Receivable' || item.itemType === 'Recovery') totalReceivables += amt; else if (item.itemType === 'Payable') totalPayables += amt; else if (item.itemType === 'Deduction') totalDeductions += amt; }); const netAmount = totalPayables - totalReceivables - totalDeductions; const allCleared = clearances.length > 0 && clearances.every((c: any) => ['Cleared', 'NOC Submitted', 'Dues Pending', 'N/A'].includes(c.status) ); // Calculate progress percentage based on clearances let progressPercentage = 0; if (clearances.length > 0) { const clearedCount = clearances.filter((c: any) => ['Cleared', 'NOC Submitted', 'Dues Pending', 'N/A'].includes(c.status) ).length; progressPercentage = Math.round((clearedCount / clearances.length) * 100); } // Sync individual department clearance statuses based on dues for (const clearance of clearances) { // Only update if it's already been processed (not Pending) if (clearance.status === 'Pending') continue; const deptDues = calculationLineItems.filter((li: any) => li.department === clearance.department); const totalDeptAmount = deptDues.reduce((sum: number, li: any) => sum + (parseFloat(li.amount) || 0), 0); const targetStatus = totalDeptAmount > 0 ? 'Dues Pending' : 'NOC Submitted'; if (clearance.status !== targetStatus) { await clearance.update({ status: targetStatus }); } } // Determine Overall F&F Status let newStatus = normalizeFnFStatus(refreshedFnf.status); if (refreshedFnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) { newStatus = FNF_STATUS.DD_CLEARANCE; } if (allCleared && refreshedFnf.status !== FNF_STATUS.COMPLETED) { newStatus = FNF_STATUS.FINANCE_APPROVAL; } await refreshedFnf.update({ totalReceivables, totalPayables, totalDeductions, netAmount, status: newStatus, progressPercentage }); // If status moved to Completed, also update parent resignation/termination if (newStatus === FNF_STATUS.COMPLETED || newStatus === 'Completed') { if (refreshedFnf.resignationId) { const resignation = await Resignation.findByPk(refreshedFnf.resignationId); if (resignation) { await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, userId, { action: 'F&F Settlement Completed', remarks: 'Full & Final settlement process finalized. Transitioning resignation to Completed.', status: 'Completed' }); } } else if (refreshedFnf.terminationRequestId) { const terminationRequest = await TerminationRequest.findByPk(refreshedFnf.terminationRequestId); if (terminationRequest) { await TerminationWorkflowService.transitionTermination(terminationRequest, TERMINATION_STAGES.TERMINATED, userId, { action: 'F&F Settlement Completed', remarks: 'Full & Final settlement process finalized. Transitioning termination to Terminated.', status: 'Terminated' }); } } } return refreshedFnf; }; export const updateClearance = async (req: AuthRequest, res: Response) => { try { const { id, clearanceId } = req.params; const body = (req.body || {}) as Record; const { status, remarks, documentId, supportingDocument, amount, type } = body; const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } }); if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' }); const fnf = await FnF.findByPk(id); if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); if ([FNF_STATUS.FINANCE_APPROVAL, FNF_STATUS.COMPLETED].includes(fnf.status) && ![ROLES.FINANCE, ROLES.SUPER_ADMIN].includes(req.user?.role as any)) { return res.status(400).json({ success: false, message: 'Department response window is closed for this case.' }); } const uploadedSupportingDocument = req.file ? `/uploads/documents/${req.file.filename}` : undefined; const enteredAmount = Math.abs(Number(amount) || 0); const clearanceType = String(type || '').toLowerCase(); const itemType = clearanceType === 'payable' ? 'Payable' : clearanceType === 'deduction' ? 'Deduction' : 'Receivable'; const syntheticDescription = DEPARTMENT_CLAIM_DESCRIPTION; // Persist amount/type as immutable department-claim line item. const existingSyntheticLine = await FnFLineItem.findOne({ where: { fnfId: id, department: clearance.department, description: syntheticDescription, isActive: true } }); if (enteredAmount > 0) { if (existingSyntheticLine) { await existingSyntheticLine.update({ isActive: false }); await FnFLineItem.create({ fnfId: id, itemType, description: syntheticDescription, department: clearance.department, amount: enteredAmount, addedBy: req.user?.id || null, sourceType: 'DepartmentClaim', version: Number((existingSyntheticLine as any).version || 1) + 1, isActive: true, parentLineItemId: (existingSyntheticLine as any).parentLineItemId || existingSyntheticLine.id, claimAmount: enteredAmount, validatedAmount: null, varianceAmount: 0, financeDecision: null, varianceReason: null }); } else { await FnFLineItem.create({ fnfId: id, itemType, description: syntheticDescription, department: clearance.department, amount: enteredAmount, addedBy: req.user?.id || null, sourceType: 'DepartmentClaim', version: 1, isActive: true, claimAmount: enteredAmount, validatedAmount: null, varianceAmount: 0, financeDecision: null, varianceReason: null }); } } else if (existingSyntheticLine) { await existingSyntheticLine.update({ isActive: false }); } const normalizedStatus = normalizeClearanceStatus(status || clearance.status, enteredAmount); await clearance.update({ status: normalizedStatus, remarks: remarks || clearance.remarks, documentId: documentId || clearance.documentId, supportingDocument: uploadedSupportingDocument || supportingDocument || clearance.supportingDocument, clearedBy: req.user?.id, clearedAt: normalizedStatus !== 'Pending' ? new Date() : clearance.clearedAt }); // Automatically update FnF progress console.log(`[SettlementController] Updating clearance for F&F: ${id}`); const fnfRecord = await calculateFnFLogic(id as string, req.user?.id); // 1. Local F&F Audit (Dedicated Table: fnf_audit_logs) try { console.log(`[SettlementController] Creating FnFAudit for ${id}`); await db.FnFAudit.create({ userId: req.user?.id || null, fnfId: id, action: 'CLEARANCE_UPDATED', remarks: remarks || 'No remarks', details: { department: clearance.department, status: normalizedStatus, amount: enteredAmount, type: itemType } }); } catch (auditError) { console.error('[SettlementController] Local FnFAudit creation failed:', auditError); } // 2. Interconnected Mirror (Dedicated Table: resignation_audit_logs or termination_audit_logs) if (fnfRecord && (fnfRecord.resignationId || fnfRecord.terminationRequestId)) { try { const isResignation = !!fnfRecord.resignationId; const parentAuditModel = isResignation ? db.ResignationAudit : db.TerminationAudit; const parentKey = isResignation ? 'resignationId' : 'terminationRequestId'; const parentId = fnfRecord.resignationId || fnfRecord.terminationRequestId; console.log(`[SettlementController] Creating Parent Audit for ${parentId} (${isResignation ? 'resignation' : 'termination'})`); await parentAuditModel.create({ userId: req.user?.id || null, [parentKey]: parentId, action: 'STAKEHOLDER_CLEARANCE_UPDATED', remarks: `Automated sync from F&F: ${remarks || 'No remarks'}`, details: { department: clearance.department, status: normalizedStatus, amount: enteredAmount, type: itemType } }); } catch (parentAuditError) { console.error('[SettlementController] Parent Audit creation failed:', parentAuditError); } } res.json({ success: true, message: 'Clearance updated successfully', clearance }); } catch (error) { console.error('Update clearance error:', error); res.status(500).json({ success: false, message: 'Error updating clearance' }); } }; export const calculateFnF = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const fnf = await calculateFnFLogic(id as string, req.user?.id); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); res.json({ success: true, fnf }); } catch (error) { console.error('Calculate F&F error:', error); res.status(500).json({ success: false, message: 'Error calculating F&F' }); } };