704 lines
30 KiB
TypeScript
704 lines
30 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 } = 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<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);
|
|
}
|
|
}
|
|
|
|
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' });
|
|
}
|
|
};
|