Dealer_Onboarding_Backend/src/modules/settlement/settlement.controller.ts

872 lines
38 KiB
TypeScript

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<string, any>;
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' });
}
};