import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog, RequestParticipant, Dealer } = 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'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NotificationService } from '../../services/NotificationService.js'; const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; 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: AuthRequest, res: Response) => { try { res.json({ success: true, departments: FNF_DEPARTMENTS }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching departments' }); } }; export const uploadFnFDocument = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const fileUrl = req.file ? `/uploads/documents/${req.file.filename}` : req.body.fileUrl; if (!fileUrl) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } const fnf = await FnF.findByPk(id); if (!fnf) { return res.status(404).json({ success: false, message: 'F&F settlement not found' }); } const updatedClearances = [ ...(fnf.clearanceDocuments || []), { name: req.file?.originalname || 'Uploaded Document', supportingDocument: fileUrl, department: 'Finance', // Default to Finance for generic F&F docs clearedAt: new Date().toISOString() } ]; await fnf.update({ clearanceDocuments: updatedClearances }); res.json({ success: true, url: fileUrl, name: req.file?.originalname || 'Document' }); } catch (error) { console.error('Error uploading F&F document:', error); res.status(500).json({ success: false, message: 'Error uploading F&F document' }); } }; export const getOnboardingPayments = async (req: AuthRequest, res: Response) => { try { const page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 10; const offset = (page - 1) * limit; const { count, rows: payments } = await FinancePayment.findAndCountAll({ include: [{ model: Application, as: 'application', attributes: ['applicantName', 'applicationId'] }], order: [['createdAt', 'ASC']], limit, offset }); res.json({ success: true, payments, meta: { total: count, totalPages: Math.ceil(count / limit), currentPage: page, limit } }); } 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, }, }); // SRS §11.1.3.1 — Notify DD-Admin and DD-Lead when payment is verified if (isVerifying && status === 'Paid') { const notifyRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD]; for (const role of notifyRoles) { const roleUsers = await User.findAll({ where: { roleCode: role } }); for (const u of roleUsers) { NotificationService.notify(u.id, u.email, { title: `Payment Verified: ${p.application?.applicationId || 'New Dealer'}`, message: `Finance has verified the ${p.paymentType || 'Security Deposit'} for ${p.application?.applicantName || 'Dealer'}.`, channels: ['system', 'email'], templateCode: 'ONBOARDING_PAYMENT_VERIFIED', placeholders: { applicationId: p.application?.applicationId || 'N/A', dealerName: p.application?.applicantName || 'Dealer', paymentType: p.paymentType || 'Security Deposit', amount: p.amount, link: `${portalBase}/applications/${p.applicationId}` } }).catch((e: any) => console.error('[Finance] Payment verification notify failed:', e)); } } } 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' }); } } // SRS §4.4.2.7 — F&F Completed: final alerts to DD-Lead, NBH, Legal const completionRoles = [ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN]; for (const role of completionRoles) { const roleUsers = await User.findAll({ where: { roleCode: role } }); for (const u of roleUsers) { NotificationService.notify(u.id, u.email, { title: `F&F Settlement Completed: ${fnf.fnfId || id}`, message: `Full & Final Settlement has been completed and closed. All departmental clearances confirmed.`, channels: ['system', 'email'], templateCode: 'FNF_SETTLEMENT_APPROVED', placeholders: { fnfId: fnf.fnfId || id, dealerName: 'Dealer', // Can be refined if dealer info is fetched settlementAmount: fnf.settlementAmount || fnf.netAmount, link: `${portalBase}/fnf/${id}` } }).catch((e: any) => console.error('[FnF] Completion notify failed:', e)); } } } // SRS §4.4.2.6 — Finance approval reached: notify DD-Admin + Legal if (normalizedStatus === FNF_STATUS.FINANCE_APPROVAL) { const financeApprovalRoles = [ROLES.DD_ADMIN, ROLES.LEGAL_ADMIN]; for (const role of financeApprovalRoles) { const roleUsers = await User.findAll({ where: { roleCode: role } }); for (const u of roleUsers) { NotificationService.notify(u.id, u.email, { title: `F&F Finance Approval Stage: ${fnf.fnfId || id}`, message: `All departments have submitted their clearances. F&F is now pending Finance approval.`, channels: ['system', 'email'], templateCode: 'FNF_INITIATED', placeholders: { dealerName: '', fnfId: fnf.fnfId || id, link: `${portalBase}/fnf/${id}`, ctaLabel: 'Review Settlement' } }).catch((e: any) => console.error('[FnF] Finance approval notify failed:', e)); } } } 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 page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 10; const offset = (page - 1) * limit; const { count, rows: settlements } = await FnF.findAndCountAll({ 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']], limit, offset, distinct: true }); res.json({ success: true, settlements, meta: { total: count, totalPages: Math.ceil(count / limit), currentPage: page, limit } }); } 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; // Resolve UUID if human-readable ID (FNF-*) is passed const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'fnf'); 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: Dealer, as: 'dealerProfile', include: [ { model: db.DealerCode, as: 'dealerCode' }, { model: db.DealerBankDetail, as: 'bankDetails' } ] }] }] }, { model: 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'] }] }, { model: RequestParticipant, as: 'participants', separate: true, include: [{ model: User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] } ]; const fnf = await FnF.findByPk(resolvedId, { include: includeConfig as any }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); // Ensure participants exist (fixes legacy / missing participants silently) if (!fnf.participants || (fnf as any).participants.length === 0) { const { ParticipantService } = await import('../../services/ParticipantService.js'); await ParticipantService.assignFnFParticipants(resolvedId); } await ensureFinanceDraftsFromDepartmentClaims(resolvedId, null); const fnfWithDrafts = await FnF.findByPk(resolvedId, { include: includeConfig as any }); if (!fnfWithDrafts) return res.status(404).json({ success: false, message: 'F&F not found after sync' }); res.json({ success: true, fnf: fnfWithDrafts }); } catch (error) { console.error('Error fetching F&F:', 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); } } // 3. F&F Dashboard Chat Trail (Worknotes for the unified stakeholder view) try { await writeWorkflowActivityWorknote({ requestId: id, requestType: 'fnf', userId: req.user?.id || '', noteText: `[Auto] Clearance updated for ${clearance.department}: Status: ${normalizedStatus}, Amount: ${enteredAmount} (${itemType})`, noteType: 'workflow' }); } catch (worknoteError) { console.error('[SettlementController] Worknote recording failed:', worknoteError); } 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' }); } };