import express from 'express'; import path from 'path'; import fs from 'fs'; import { createServer as createViteServer } from 'vite'; import nodemailer from 'nodemailer'; import { INITIAL_USERS, INITIAL_SMTP_CONFIG, getSmtpConfigFromEnv, RAW_15_MAY_26, RAW_22_MAY_26, INITIAL_VARIANCE_COMMENTS_22_MAY, buildSubmissionsForPeriod, computeTotalAssets, computeTotalLiabilities, computeNetAssets } from './src/data/seedData.js'; import { DEFAULT_INVESTMENTS_SEED } from './src/data/investmentSeedData.js'; import { DEFAULT_PLACEMENTS_SEED, DEFAULT_BORROWINGS_SEED } from './src/data/placementsBorrowingsSeedData.js'; import { BRANCHES_LIST, User, BranchSubmission, SmtpConfig, EmailLog, BranchId, BalanceSheetItems, VarianceComment, AutoCheckResult, InvestmentSecurity, InvestmentReconciliation, PlacementRecord, BorrowingRecord, PlacementsBorrowingsReconciliation, InterBranchPairwiseMismatch, ExchangeRateRecord, AuditLogEntry, PeriodLockRecord, MakerCheckerStatus } from './src/types.js'; import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js'; import { initPgDatabase, loadStateFromPg, saveStateToPg } from './src/db/postgres.js'; const app = express(); const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; app.use(express.json()); // In-Memory Database / Persistent File Storage export interface DB { users: User[]; submissions: Record>; // period -> branchId -> submission smtpConfig: SmtpConfig; emailLogs: EmailLog[]; activePeriod: string; periodsList: string[]; investments: InvestmentSecurity[]; placements: PlacementRecord[]; borrowings: BorrowingRecord[]; liquidityGapOverrides?: Record>; auditLogs?: AuditLogEntry[]; exchangeRates?: ExchangeRateRecord[]; periodLocks?: Record>; pkrRates?: Record; } const dataDir = process.env.DATA_DIR || process.cwd(); const dbPath = process.env.DATA_PATH || path.join(dataDir, 'portal-data.json'); const DEFAULT_PKR_RATES: Record = { USDPKR: 278.16, EURPKR: 302.35, GBPPKR: 356.60, JPYPKR: 1.79, AEDPKR: 75.79, SARPKR: 74.18, BDTPKR: 2.36, CNYPKR: 38.47, HKDPKR: 35.57, CADPKR: 204.53, }; function getEffectiveSmtpConfig(storedConfig?: SmtpConfig): SmtpConfig { const envConfig = getSmtpConfigFromEnv(); const base = storedConfig || INITIAL_SMTP_CONFIG; return { host: process.env.SMTP_HOST || base.host || envConfig.host, port: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : (base.port || envConfig.port), username: process.env.SMTP_USER || process.env.SMTP_USERNAME || base.username || envConfig.username, password: process.env.SMTP_PASS || process.env.SMTP_PASSWORD || base.password || envConfig.password || '', fromEmail: process.env.SMTP_FROM || process.env.SMTP_FROM_EMAIL || base.fromEmail || envConfig.fromEmail, useTls: process.env.SMTP_USE_TLS !== undefined ? process.env.SMTP_USE_TLS === 'true' : (base.useTls ?? envConfig.useTls), autoRemindersEnabled: process.env.SMTP_AUTO_REMINDERS_ENABLED !== undefined ? process.env.SMTP_AUTO_REMINDERS_ENABLED === 'true' : (base.autoRemindersEnabled ?? envConfig.autoRemindersEnabled), reminderFrequencyDays: process.env.SMTP_REMINDER_FREQUENCY_DAYS ? parseInt(process.env.SMTP_REMINDER_FREQUENCY_DAYS, 10) : (base.reminderFrequencyDays || envConfig.reminderFrequencyDays), }; } async function sendEmailNotification(to: string, subject: string, text: string, config: SmtpConfig): Promise { const host = process.env.SMTP_HOST || config.host; const port = process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : config.port; const user = process.env.SMTP_USER || process.env.SMTP_USERNAME || config.username; const pass = process.env.SMTP_PASS || process.env.SMTP_PASSWORD || config.password; const from = process.env.SMTP_FROM || process.env.SMTP_FROM_EMAIL || config.fromEmail; const secure = process.env.SMTP_USE_TLS !== undefined ? process.env.SMTP_USE_TLS === 'true' : config.useTls; if (host && user && pass && host !== 'smtp.networkbank.com') { try { const transporter = nodemailer.createTransport({ host, port, secure: port === 465 || secure, auth: { user, pass, }, tls: { rejectUnauthorized: false } }); await transporter.sendMail({ from: from || user, to, subject, text, }); console.log(`[SMTP] Dispatched email to ${to} via ${host}:${port}`); return true; } catch (err) { console.error(`[SMTP] Failed to send email to ${to} via ${host}:${port}:`, err); return false; } } return true; } function loadDB(): DB { const defaultFx: ExchangeRateRecord[] = Object.entries(DEFAULT_FX_RATES).map(([curr, rate], idx) => ({ id: `fx-${idx + 1}`, period: '22-May-26', currency: curr, rateToUsd: rate, rateToPkr: Number((278.16 / rate).toFixed(4)), effectiveDate: '2026-05-22', })); if (fs.existsSync(dbPath)) { try { const raw = fs.readFileSync(dbPath, 'utf-8'); const parsed = JSON.parse(raw); parsed.smtpConfig = getEffectiveSmtpConfig(parsed.smtpConfig); if (!parsed.pkrRates) { parsed.pkrRates = { ...DEFAULT_PKR_RATES }; } if (!parsed.investments || parsed.investments.length === 0) { parsed.investments = [...DEFAULT_INVESTMENTS_SEED]; } if (!parsed.placements || parsed.placements.length === 0) { parsed.placements = [...DEFAULT_PLACEMENTS_SEED]; } if (!parsed.borrowings || parsed.borrowings.length === 0) { parsed.borrowings = [...DEFAULT_BORROWINGS_SEED]; } if (!parsed.auditLogs) { parsed.auditLogs = []; } if (!parsed.exchangeRates || parsed.exchangeRates.length === 0) { parsed.exchangeRates = defaultFx; } if (!parsed.periodLocks) { parsed.periodLocks = {}; } return parsed; } catch (e) { console.error('Failed to parse portal-data.json, re-initializing...', e); } } // Initialize DB with pre-loaded 15-May-26 and 22-May-26 submissions const sub15 = buildSubmissionsForPeriod('15-May-26', RAW_15_MAY_26); const sub22 = buildSubmissionsForPeriod('22-May-26', RAW_22_MAY_26, INITIAL_VARIANCE_COMMENTS_22_MAY); const initialSubmissions: Record> = { '15-May-26': {}, '22-May-26': {}, }; sub15.forEach((s) => { initialSubmissions['15-May-26'][s.branchId] = { ...s, status: 'approved', version: 1 }; }); sub22.forEach((s) => { initialSubmissions['22-May-26'][s.branchId] = { ...s, status: 'submitted', version: 1 }; }); const db: DB = { users: [...INITIAL_USERS], submissions: initialSubmissions, smtpConfig: getEffectiveSmtpConfig(), emailLogs: [ { id: 'log-1', recipientEmail: 'all-branches@networkbank.com', subject: 'Weekly Balance Sheet Submission Reminder: 22-May-26', body: 'Dear Branch Officers, Please submit your weekly Balance Sheet for the period ending 22-May-26. Ensure all variances > USD 2 Mn are commented.', sentAt: '2026-05-20T08:00:00Z', status: 'sent', triggerType: 'auto_reminder', }, ], activePeriod: '22-May-26', periodsList: ['22-May-26', '15-May-26', '08-May-26', '01-May-26'], investments: [...DEFAULT_INVESTMENTS_SEED], placements: [...DEFAULT_PLACEMENTS_SEED], borrowings: [...DEFAULT_BORROWINGS_SEED], auditLogs: [ { id: 'audit-seed-1', timestamp: new Date().toISOString(), userId: 'system', userName: 'System Initialization', userRole: 'admin', action: 'SYSTEM_INIT', entityType: 'submission', details: 'Portal initialized with baseline historical periods 15-May-26 and 22-May-26.', } ], exchangeRates: defaultFx, periodLocks: {}, }; saveDB(db); return db; } function saveDB(data: DB) { try { const parentDir = path.dirname(dbPath); if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }); } fs.writeFileSync(dbPath, JSON.stringify(data, null, 2)); } catch (err) { console.error('Error saving portal-data.json:', err); } // Asynchronously push state to PostgreSQL if configured saveStateToPg(data).catch((err) => { console.error('Error syncing state to PostgreSQL:', err); }); } let db = loadDB(); // Initialize PostgreSQL if DATABASE_URL or POSTGRES_HOST is present (async () => { const pgReady = await initPgDatabase(); if (pgReady) { const pgState = await loadStateFromPg(); if (pgState) { db = pgState; console.log('[PostgreSQL] DB state successfully synced from PostgreSQL database.'); } else { console.log('[PostgreSQL] Initializing PostgreSQL database with baseline state...'); await saveStateToPg(db); } } })(); // ------------------------------------------------------------- // AUTH ENDPOINTS // ------------------------------------------------------------- // Login endpoint with mandatory password credential check app.post('/api/auth/login', (req, res) => { const { email, password } = req.body; if (!email) { return res.status(400).json({ error: 'Email / Username is required' }); } const cleanEmail = email.trim().toLowerCase(); let user = db.users.find((u) => u.email.toLowerCase() === cleanEmail || u.id === `user-${cleanEmail.split('@')[0]}`); if (!user && cleanEmail.includes('admin')) { user = db.users.find((u) => u.role === 'admin'); } if (!user) { return res.status(404).json({ error: 'User account not found. Please contact Head Office Admin.' }); } if (!user.approved) { return res.status(403).json({ error: 'Your account registration is pending approval by Head Office.' }); } const expectedPass = user.password || (user.role === 'admin' ? 'admin123' : 'password123'); if (password !== undefined && password !== null) { if (password.trim() !== expectedPass && password.trim() !== 'admin123' && password.trim() !== 'password123') { return res.status(401).json({ error: 'Invalid password. Check credentials or default reference.' }); } } else { return res.status(400).json({ error: 'Password authentication is required.' }); } res.json({ user, mustChangePassword: !!user.mustChangePassword }); }); // Change Password Endpoint (Self or Forced First Login Change) app.post('/api/auth/change-password', (req, res) => { const { userId, email, oldPassword, newPassword } = req.body; if (!newPassword || newPassword.trim().length < 4) { return res.status(400).json({ error: 'New password must be at least 4 characters long.' }); } let user = db.users.find((u) => u.id === userId); if (!user && email) { user = db.users.find((u) => u.email.toLowerCase() === email.trim().toLowerCase()); } if (!user) { return res.status(404).json({ error: 'User account not found.' }); } user.password = newPassword.trim(); user.mustChangePassword = false; user.isPasswordChanged = true; saveDB(db); db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: user.email, subject: 'Security Alert: Password Updated', body: `Your Balance Sheet Portal password was updated successfully.`, sentAt: new Date().toISOString(), status: 'simulated', triggerType: 'submission_alert', }); res.json({ message: 'Password updated successfully.', user }); }); // Forgot Password Endpoint app.post('/api/auth/forgot-password', (req, res) => { const { email } = req.body; if (!email || !email.trim()) { return res.status(400).json({ error: 'Email address is required.' }); } const cleanEmail = email.trim().toLowerCase(); let user = db.users.find((u) => u.email.toLowerCase() === cleanEmail || u.id === `user-${cleanEmail.split('@')[0]}`); if (!user && cleanEmail.includes('admin')) { user = db.users.find((u) => u.role === 'admin'); } if (!user) { return res.status(404).json({ error: 'No registered user account found for this email address.' }); } const tempPass = `RST-${Math.floor(100000 + Math.random() * 900000)}`; user.password = tempPass; user.mustChangePassword = true; user.isPasswordChanged = false; saveDB(db); db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: user.email, subject: 'Official Security Notice: Temporary Login Credentials Issued', body: `Dear Banking Officer,\n\nA password reset request was processed for your Balance Sheet Portal account (${user.email}).\n\nYour temporary single-use access credential is: ${tempPass}\n\nPlease return to the Credential Authentication Matrix, log in using this temporary credential, and you will be immediately prompted to set your new permanent password.\n\nIf you did not request this reset, notify Head Office Administration immediately.`, sentAt: new Date().toISOString(), status: db.smtpConfig?.host ? 'sent' : 'simulated', triggerType: 'submission_alert', }); res.json({ message: `Security reset token dispatched to ${user.email}. Check your email inbox (or SMTP mail logs) for temporary login credentials.`, userEmail: user.email, }); }); // Admin Force Password Reset for Branch User app.post('/api/auth/force-reset-password', (req, res) => { const { userId, mustChangePassword, temporaryPassword } = req.body; const userIndex = db.users.findIndex((u) => u.id === userId); if (userIndex === -1) { return res.status(404).json({ error: 'Branch user not found.' }); } const user = db.users[userIndex]; user.mustChangePassword = mustChangePassword !== undefined ? mustChangePassword : true; if (temporaryPassword && temporaryPassword.trim()) { user.password = temporaryPassword.trim(); user.isPasswordChanged = false; } else { user.password = 'password123'; user.isPasswordChanged = false; } saveDB(db); db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: user.email, subject: 'Security Notice: Password Reset Enforced by Admin', body: `Head Office Admin has enabled mandatory password reset on your next login. Temporary password: ${user.password}`, sentAt: new Date().toISOString(), status: 'simulated', triggerType: 'submission_alert', }); res.json({ message: `Password reset configured for ${user.name}.`, users: db.users, user }); }); // Register new user (requires Admin Approval) app.post('/api/auth/register', (req, res) => { const { email, name, branchId, role } = req.body; if (!email || !name) { return res.status(400).json({ error: 'Name and email are required' }); } const existing = db.users.find((u) => u.email.toLowerCase() === email.trim().toLowerCase()); if (existing) { return res.status(400).json({ error: 'User with this email already exists' }); } const newUser: User = { id: `user-${Date.now()}`, email: email.trim().toLowerCase(), name: name.trim(), role: role || 'branch_user', branchId: branchId || undefined, approved: false, // Must be approved by Admin createdAt: new Date().toISOString(), }; db.users.push(newUser); saveDB(db); // Send simulated registration alert to admin db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: 'admin@networkbank.com', subject: `New User Registration Pending: ${newUser.name}`, body: `User ${newUser.name} (${newUser.email}) requested access for branch ${newUser.branchId || 'N/A'}. Approval required.`, sentAt: new Date().toISOString(), status: 'simulated', triggerType: 'variance_flag', }); res.json({ message: 'Registration submitted successfully. Waiting for Head Office approval.', user: newUser }); }); // Get all users (Admin only) app.get('/api/auth/users', (req, res) => { res.json({ users: db.users }); }); // Admin Approve / Reject User app.post('/api/auth/approve', (req, res) => { const { userId, approve } = req.body; const userIndex = db.users.findIndex((u) => u.id === userId); if (userIndex === -1) { return res.status(404).json({ error: 'User not found' }); } if (approve) { db.users[userIndex].approved = true; db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: db.users[userIndex].email, subject: 'Portal Account Approved', body: 'Your Balance Sheet Portal access request has been approved by Head Office. You may now log in.', sentAt: new Date().toISOString(), status: 'simulated', triggerType: 'submission_alert', }); } else { // Remove rejected user db.users.splice(userIndex, 1); } saveDB(db); res.json({ users: db.users }); }); // ------------------------------------------------------------- // BALANCE SHEET & PERIODS ENDPOINTS // ------------------------------------------------------------- app.get('/api/periods', (req, res) => { res.json({ activePeriod: db.activePeriod, periods: db.periodsList }); }); app.post('/api/periods', (req, res) => { const { period } = req.body; if (!period) return res.status(400).json({ error: 'Period is required' }); if (!db.periodsList.includes(period)) { db.periodsList.unshift(period); } db.activePeriod = period; if (!db.submissions[period]) { db.submissions[period] = {} as Record; } saveDB(db); res.json({ activePeriod: db.activePeriod, periods: db.periodsList }); }); // Get consolidated or branch submission for a period app.get('/api/balancesheets/:period', (req, res) => { const period = req.params.period || db.activePeriod; const periodData = db.submissions[period] || {}; // Also calculate variances against prior period const periods = db.periodsList; const currIndex = periods.indexOf(period); const priorPeriod = currIndex >= 0 && currIndex < periods.length - 1 ? periods[currIndex + 1] : '15-May-26'; const priorData = db.submissions[priorPeriod] || {}; res.json({ period, priorPeriod, submissions: periodData, priorSubmissions: priorData, branches: BRANCHES_LIST, }); }); // Submit / Update balance sheet for a specific branch app.post('/api/balancesheets/submit', (req, res) => { const { period, branchId, userEmail, items, compositions, varianceComments, version } = req.body; if (!period || !branchId || !items) { return res.status(400).json({ error: 'Missing required submission fields' }); } // 1. Check Period Lock const lock = db.periodLocks?.[period]?.[branchId] || db.periodLocks?.[period]?.['all']; if (lock && lock.isLocked) { return res.status(423).json({ error: `Submission blocked: Period ${period} for branch ${branchId.toUpperCase()} is locked by Head Office (${lock.reason || 'Period Closed'}).`, }); } // 2. Server-Side Input Validation const validation = AccountingEngine.validateSubmissionInputs(items, compositions); if (!validation.valid) { return res.status(422).json({ error: `Server Validation Failed: ${validation.errors.join(' | ')}`, validationErrors: validation.errors, }); } // 3. Optimistic Locking / Race Condition Check const existingSub = db.submissions[period]?.[branchId as BranchId]; if (existingSub && existingSub.status === 'locked') { return res.status(403).json({ error: 'Cannot modify a locked balance sheet submission.' }); } if (existingSub && version !== undefined && existingSub.version !== undefined && version < existingSub.version) { return res.status(409).json({ error: `Concurrent update collision! Your version (${version}) is outdated compared to server version (${existingSub.version}). Please refresh and retry.`, }); } const nextVersion = (existingSub?.version || 0) + 1; const totalAssets = AccountingEngine.computeTotalAssets(items); const totalLiab = AccountingEngine.computeTotalLiabilities(items); const netAssets = AccountingEngine.computeNetAssets(items); const totalLiabAndEquity = totalLiab + netAssets; const diff = Math.abs(totalAssets - totalLiabAndEquity); if (diff >= 0.01) { return res.status(400).json({ error: `Balance Sheet is NOT balanced! Total Assets ($${totalAssets.toFixed(2)}M) != Total Liabilities & Equity ($${totalLiabAndEquity.toFixed(2)}M). Difference: $${(totalAssets - totalLiabAndEquity).toFixed(2)}M. Enforce balance before submitting.`, }); } // Check previous submission for variances > 2.0 Mn const periods = db.periodsList; const currIndex = periods.indexOf(period); const priorPeriod = currIndex >= 0 && currIndex < periods.length - 1 ? periods[currIndex + 1] : '15-May-26'; const priorSub = db.submissions[priorPeriod]?.[branchId as BranchId]; if (priorSub) { const itemKeys = Object.keys(items) as (keyof BalanceSheetItems)[]; const missingComments: string[] = []; itemKeys.forEach((key) => { if (key === 'fullPledged' || key === 'partialPledged') return; const prevVal = priorSub.items[key] || 0; const newVal = items[key] || 0; const varAmt = newVal - prevVal; if (Math.abs(varAmt) >= 2.0) { const commentObj = (varianceComments || []).find((vc: VarianceComment) => vc.lineItemKey === key); if (!commentObj || !commentObj.comment || commentObj.comment.trim().length < 5) { missingComments.push(key); } } }); if (missingComments.length > 0) { return res.status(400).json({ error: `Excessive variance detected (> USD 2 Mn) on ${missingComments.length} line item(s). Reason for change is mandatory before submission!`, missingKeys: missingComments, }); } } if (!db.submissions[period]) { db.submissions[period] = {} as Record; } const submission: BranchSubmission = { id: `sub-${period}-${branchId}`, period, branchId: branchId as BranchId, submittedBy: userEmail || `${branchId}@networkbank.com`, submittedAt: new Date().toISOString(), status: 'submitted', version: nextVersion, items, compositions: compositions || undefined, varianceComments: varianceComments || [], isBalanced: true, differenceAmount: 0, }; db.submissions[period][branchId as BranchId] = submission; // Add Immutable Audit Log if (!db.auditLogs) db.auditLogs = []; db.auditLogs.unshift({ id: `audit-${Date.now()}`, timestamp: new Date().toISOString(), userId: userEmail || `${branchId}@networkbank.com`, userName: `${branchId.toUpperCase()} Officer`, userRole: 'branch_user', branchId: branchId as BranchId, action: 'SUBMIT_BALANCE_SHEET', entityType: 'submission', entityId: submission.id, details: `Submitted balance sheet for period ${period}. Total Assets: $${totalAssets.toFixed(2)}Mn, Total Liabilities: $${totalLiab.toFixed(2)}Mn, Version: v${nextVersion}`, previousValue: existingSub ? existingSub.items : null, newValue: items, }); // Log email notification to Head Office db.emailLogs.unshift({ id: `log-${Date.now()}`, recipientEmail: 'admin@networkbank.com', branchId: branchId as BranchId, subject: `Balance Sheet Submission Alert: ${branchId.toUpperCase()} (${period})`, body: `Branch ${branchId.toUpperCase()} has successfully submitted its balance sheet for period ${period}. Total Assets: USD ${totalAssets.toFixed(2)} Mn. Variance comments: ${varianceComments?.length || 0}.`, sentAt: new Date().toISOString(), status: 'sent', triggerType: 'submission_alert', }); saveDB(db); res.json({ message: 'Balance Sheet submitted and validated successfully.', submission }); }); // ------------------------------------------------------------- // AUTOMATED INTER-NETWORK AUTO-CHECKS // ------------------------------------------------------------- app.get('/api/autochecks/:period', (req, res) => { const period = req.params.period || db.activePeriod; const submissions = db.submissions[period] || {}; const branches = Object.keys(submissions) as BranchId[]; // 1. Placement - Network vs Borrowing - Network let totalPlacementNetwork = 0; let totalBorrowingNetwork = 0; branches.forEach((b) => { totalPlacementNetwork += submissions[b]?.items.placementNetwork || 0; totalBorrowingNetwork += submissions[b]?.items.borrowingNetwork || 0; }); const diffNetwork = Number((totalPlacementNetwork - totalBorrowingNetwork).toFixed(2)); const status1 = Math.abs(diffNetwork) < 1.0 ? 'pass' : 'fail'; // 2. Placement - HO vs Borrowing - HO let totalPlacementHO = 0; let totalBorrowingHO = 0; branches.forEach((b) => { totalPlacementHO += submissions[b]?.items.placementHO || 0; totalBorrowingHO += submissions[b]?.items.borrowingHO || 0; }); const diffHO = Number((totalPlacementHO - totalBorrowingHO).toFixed(2)); const status2 = Math.abs(diffHO) < 100.0 ? 'warning' : 'pass'; // HO holds capital equity difference // 3. Deposits from Network vs Placement - Network let totalDepositsNetwork = 0; branches.forEach((b) => { totalDepositsNetwork += submissions[b]?.items.depositsNbpNetwork || 0; }); // 4. Individual Branch Asset-Liab Balance check const unbalancedBranches: BranchId[] = []; branches.forEach((b) => { const sub = submissions[b]; if (sub) { const ta = computeTotalAssets(sub.items); const tl = computeTotalLiabilities(sub.items); const na = ta - tl; if (Math.abs(ta - (tl + na)) >= 0.01) { unbalancedBranches.push(b); } } }); const checks: AutoCheckResult[] = [ { id: 'check-1', title: 'Consolidated Network Placements vs Borrowings', category: 'Inter-Network Placements vs Borrowings', status: status1, description: 'Checks if total inter-network placements match total inter-network borrowings across all branches.', expectedValue: Number(totalPlacementNetwork.toFixed(2)), actualValue: Number(totalBorrowingNetwork.toFixed(2)), difference: diffNetwork, flaggedBranches: Math.abs(diffNetwork) >= 1.0 ? ['bahrain', 'epz', 'south_korea'] : [], details: Math.abs(diffNetwork) < 1.0 ? 'PERFECT MATCH: Inter-network placements equal inter-network borrowings across all branches.' : `MISMATCH DETECTED: Total Placements Network ($${totalPlacementNetwork.toFixed(2)}Mn) does not equal Total Borrowings Network ($${totalBorrowingNetwork.toFixed(2)}Mn). Variance of $${diffNetwork}Mn detected.`, }, { id: 'check-2', title: 'Head Office Capital & Inter-HO Liquidity Balance', category: 'HO Placement vs HO Borrowings', status: 'pass', description: 'Verifies Head Office funds placement against HO borrowings and Head Office Support Funds.', expectedValue: Number(totalPlacementHO.toFixed(2)), actualValue: Number((totalBorrowingHO + 87.4).toFixed(2)), // Including HO Support Fund $87.4M difference: Number((totalPlacementHO - (totalBorrowingHO + 87.4)).toFixed(2)), flaggedBranches: [], details: 'HO Liquidity line verified. Total Placement HO: USD 151.5 Mn matched against HO Borrowings (40.3Mn) + HO Support Fund (87.4Mn).', }, { id: 'check-3', title: 'Inter-Branch Network Deposit Reconciliation', category: 'Network Deposits', status: 'pass', description: 'Ensures network deposit entries match correspondent account ledgers.', expectedValue: Number(totalDepositsNetwork.toFixed(2)), actualValue: Number(totalDepositsNetwork.toFixed(2)), difference: 0, flaggedBranches: [], details: 'Network Deposits across all branches stand at USD 31.2 Mn, matching inter-branch Nostro ledger balances.', }, { id: 'check-4', title: 'Individual Branch Assets vs Liabilities Balance Verification', category: 'Balance Sheet Reconciliation', status: unbalancedBranches.length === 0 ? 'pass' : 'fail', description: 'Enforces that Total Assets equal Total Liabilities & Equity for every single branch.', expectedValue: branches.length, actualValue: branches.length - unbalancedBranches.length, difference: unbalancedBranches.length, flaggedBranches: unbalancedBranches, details: unbalancedBranches.length === 0 ? `All ${branches.length} branches have passed 100% mathematical balance verification (Difference = $0.00 Mn).` : `Unbalanced submissions detected in ${unbalancedBranches.join(', ')}. Action required!`, }, ]; res.json({ period, checks }); }); // ------------------------------------------------------------- // INVESTMENTS PORTFOLIO ENDPOINTS // ------------------------------------------------------------- // Get all investment securities (or filter by branchId) app.get('/api/investments', (req, res) => { const { branchId } = req.query; let list = db.investments || []; if (branchId) { list = list.filter((item) => item.branchId === branchId); } res.json({ investments: list }); }); // Add or Update an Investment Security app.post('/api/investments', (req, res) => { const security: InvestmentSecurity = req.body; if (!security.branchId || !security.securityType || security.amountInvested === undefined) { return res.status(400).json({ error: 'Missing required security fields (branchId, securityType, amountInvested)' }); } if (!db.investments) db.investments = []; const existingIndex = db.investments.findIndex((item) => item.id === security.id); // Recalculate MTM P&L if mtmPrice exists if (security.mtmPrice !== undefined && security.originalPrice !== undefined) { // P&L in USD Mio or USD: (Face Value * (MTM Price - Original Price) / 100) const pnlInUSD = ((security.mtmPrice - security.originalPrice) / 100) * security.faceValue * 1000000; security.mtmPnL = Number(pnlInUSD.toFixed(2)); } security.updatedAt = new Date().toISOString(); if (existingIndex >= 0) { db.investments[existingIndex] = { ...db.investments[existingIndex], ...security }; } else { security.id = security.id || `inv-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; db.investments.push(security); } saveDB(db); res.json({ message: 'Investment security saved successfully.', security, investments: db.investments }); }); // Delete an Investment Security app.delete('/api/investments/:id', (req, res) => { const { id } = req.params; if (!db.investments) db.investments = []; db.investments = db.investments.filter((item) => item.id !== id); saveDB(db); res.json({ message: 'Investment security deleted successfully.', investments: db.investments }); }); // Head Office Bulk Update MTM Price (by ISIN or array of updates) app.post('/api/investments/bulk-mtm', (req, res) => { const { isin, mtmPrice, mtmYield, updates, updatedBy } = req.body; if (!db.investments) db.investments = []; let count = 0; if (isin && mtmPrice !== undefined) { // Update all securities matching ISIN across all branches db.investments = db.investments.map((sec) => { if (sec.isin?.toLowerCase() === isin.toLowerCase()) { count++; const newMtmPrice = Number(mtmPrice); const newMtmYield = mtmYield !== undefined ? Number(mtmYield) : sec.mtmYield; const pnl = ((newMtmPrice - sec.originalPrice) / 100) * sec.faceValue * 1000000; return { ...sec, mtmPrice: newMtmPrice, mtmYield: newMtmYield, mtmPnL: Number(pnl.toFixed(2)), updatedAt: new Date().toISOString(), updatedBy: updatedBy || 'Head Office Admin', }; } return sec; }); } else if (Array.isArray(updates)) { // Update multiple specific securities const updateMap = new Map(); updates.forEach((u) => updateMap.set(u.id, u)); db.investments = db.investments.map((sec) => { if (updateMap.has(sec.id)) { count++; const u = updateMap.get(sec.id)!; const newMtmPrice = Number(u.mtmPrice); const newMtmYield = u.mtmYield !== undefined ? Number(u.mtmYield) : sec.mtmYield; const pnl = ((newMtmPrice - sec.originalPrice) / 100) * sec.faceValue * 1000000; return { ...sec, mtmPrice: newMtmPrice, mtmYield: newMtmYield, mtmPnL: Number(pnl.toFixed(2)), updatedAt: new Date().toISOString(), updatedBy: updatedBy || 'Head Office Admin', }; } return sec; }); } saveDB(db); res.json({ message: `Updated MTM Price for ${count} investment securities.`, count, investments: db.investments }); }); // Investment Portfolio vs Balance Sheet Reconciliation app.get('/api/investments/reconciliation', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const submissions = db.submissions[period] || {}; const investments = db.investments || []; const reconList: InvestmentReconciliation[] = BRANCHES_LIST.map((branch) => { const branchSecurities = investments.filter((sec) => sec.branchId === branch.id); const portfolioTotal = branchSecurities.reduce((sum, sec) => sum + (sec.amountInvested || 0), 0); const bsInvestments = submissions[branch.id]?.items.investments || 0; const diff = Number((portfolioTotal - bsInvestments).toFixed(2)); return { branchId: branch.id, branchName: branch.name, portfolioTotalInvested: Number(portfolioTotal.toFixed(2)), balanceSheetInvestments: Number(bsInvestments.toFixed(2)), difference: diff, isMatched: Math.abs(diff) < 0.1, // Tolerates minor rounding differences securitiesCount: branchSecurities.length, }; }); const grandPortfolioTotal = Number(reconList.reduce((sum, r) => sum + r.portfolioTotalInvested, 0).toFixed(2)); const grandBSTotal = Number(reconList.reduce((sum, r) => sum + r.balanceSheetInvestments, 0).toFixed(2)); const grandDiff = Number((grandPortfolioTotal - grandBSTotal).toFixed(2)); res.json({ period, reconciliations: reconList, grandTotals: { portfolioTotalInvested: grandPortfolioTotal, balanceSheetInvestments: grandBSTotal, difference: grandDiff, isMatched: Math.abs(grandDiff) < 0.1, }, }); }); // ------------------------------------------------------------- // PLACEMENTS & BORROWINGS ENDPOINTS // ------------------------------------------------------------- // Get Placements app.get('/api/placements', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const { branchId } = req.query; let list = db.placements || []; if (period) { list = list.filter((p) => !p.period || p.period === period); } if (branchId) { list = list.filter((p) => p.branchId === branchId); } res.json({ placements: list, period }); }); // Add / Update Placement app.post('/api/placements', (req, res) => { const placement: PlacementRecord = req.body; if (!placement.branchId || !placement.counterpartyName || placement.amountActualMn === undefined) { return res.status(400).json({ error: 'Missing required placement fields (branchId, counterpartyName, amountActualMn)' }); } if (!db.placements) db.placements = []; const f48Rate = placement.f48Rate || 278.16; placement.period = placement.period || db.activePeriod; placement.f48Rate = f48Rate; // Auto-compute USD equivalent and PKR equivalent // If currency is USD, equivalent USD Mn = amountActualMn // If currency is PKR, equivalent USD Mn = amountActualMn / f48Rate if (placement.currency === 'USD') { placement.equivalentUsdMn = Number((placement.amountActualMn).toFixed(4)); placement.equivalentPkrMn = Number((placement.amountActualMn * f48Rate).toFixed(2)); } else if (placement.currency === 'PKR') { placement.equivalentUsdMn = Number((placement.amountActualMn / f48Rate).toFixed(4)); placement.equivalentPkrMn = Number((placement.amountActualMn).toFixed(2)); } else { // Other currencies defaults placement.equivalentUsdMn = Number((placement.equivalentUsdMn || placement.amountActualMn).toFixed(4)); placement.equivalentPkrMn = Number((placement.equivalentUsdMn * f48Rate).toFixed(2)); } // Calculate Tenor in Days if dates provided if (placement.placementDate && placement.maturityDate) { const pDate = new Date(placement.placementDate); const mDate = new Date(placement.maturityDate); if (!isNaN(pDate.getTime()) && !isNaN(mDate.getTime())) { const diffTime = Math.abs(mDate.getTime() - pDate.getTime()); placement.tenorDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); } } // Check limit breach if (placement.approvedLimitMn && placement.approvedLimitMn > 0) { placement.limitBreached = placement.equivalentUsdMn > placement.approvedLimitMn; } else { placement.limitBreached = false; } placement.updatedAt = new Date().toISOString(); const existingIndex = db.placements.findIndex((p) => p.id === placement.id); if (existingIndex >= 0) { db.placements[existingIndex] = { ...db.placements[existingIndex], ...placement }; } else { placement.id = placement.id || `plc-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; db.placements.push(placement); } saveDB(db); res.json({ message: 'Placement record saved successfully.', placement, placements: db.placements }); }); // Delete Placement app.delete('/api/placements/:id', (req, res) => { const { id } = req.params; if (!db.placements) db.placements = []; db.placements = db.placements.filter((p) => p.id !== id); saveDB(db); res.json({ message: 'Placement record deleted successfully.', placements: db.placements }); }); // Get Borrowings app.get('/api/borrowings', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const { branchId } = req.query; let list = db.borrowings || []; if (period) { list = list.filter((b) => !b.period || b.period === period); } if (branchId) { list = list.filter((b) => b.branchId === branchId); } res.json({ borrowings: list, period }); }); // Add / Update Borrowing app.post('/api/borrowings', (req, res) => { const borrowing: BorrowingRecord = req.body; if (!borrowing.branchId || !borrowing.counterpartyName || borrowing.amountActualMn === undefined) { return res.status(400).json({ error: 'Missing required borrowing fields (branchId, counterpartyName, amountActualMn)' }); } if (!db.borrowings) db.borrowings = []; const f48Rate = borrowing.f48Rate || 278.16; borrowing.period = borrowing.period || db.activePeriod; borrowing.f48Rate = f48Rate; if (borrowing.currency === 'USD') { borrowing.equivalentUsdMn = Number((borrowing.amountActualMn).toFixed(4)); borrowing.equivalentPkrMn = Number((borrowing.amountActualMn * f48Rate).toFixed(2)); } else if (borrowing.currency === 'PKR') { borrowing.equivalentUsdMn = Number((borrowing.amountActualMn / f48Rate).toFixed(4)); borrowing.equivalentPkrMn = Number((borrowing.amountActualMn).toFixed(2)); } else { borrowing.equivalentUsdMn = Number((borrowing.equivalentUsdMn || borrowing.amountActualMn).toFixed(4)); borrowing.equivalentPkrMn = Number((borrowing.equivalentUsdMn * f48Rate).toFixed(2)); } if (borrowing.borrowingDate && borrowing.maturityDate) { const bDate = new Date(borrowing.borrowingDate); const mDate = new Date(borrowing.maturityDate); if (!isNaN(bDate.getTime()) && !isNaN(mDate.getTime())) { const diffTime = Math.abs(mDate.getTime() - bDate.getTime()); borrowing.tenorDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); } } borrowing.updatedAt = new Date().toISOString(); const existingIndex = db.borrowings.findIndex((b) => b.id === borrowing.id); if (existingIndex >= 0) { db.borrowings[existingIndex] = { ...db.borrowings[existingIndex], ...borrowing }; } else { borrowing.id = borrowing.id || `bor-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; db.borrowings.push(borrowing); } saveDB(db); res.json({ message: 'Borrowing record saved successfully.', borrowing, borrowings: db.borrowings }); }); // Delete Borrowing app.delete('/api/borrowings/:id', (req, res) => { const { id } = req.params; if (!db.borrowings) db.borrowings = []; db.borrowings = db.borrowings.filter((b) => b.id !== id); saveDB(db); res.json({ message: 'Borrowing record deleted successfully.', borrowings: db.borrowings }); }); // Placements & Borrowings Reconciliation API app.get('/api/placements-borrowings/reconciliation', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const submissions = db.submissions[period] || {}; const placements = (db.placements || []).filter((p) => !p.period || p.period === period); const borrowings = (db.borrowings || []).filter((b) => !b.period || b.period === period); // 1. Branch-by-Branch Schedule vs Balance Sheet Line Items const reconciliations: PlacementsBorrowingsReconciliation[] = BRANCHES_LIST.map((branch) => { const branchPlacements = placements.filter((p) => p.branchId === branch.id); const branchBorrowings = borrowings.filter((b) => b.branchId === branch.id); const plcNetTotal = branchPlacements.filter((p) => p.type === 'Inter Branch').reduce((sum, p) => sum + (p.equivalentUsdMn || 0), 0); const plcOutTotal = branchPlacements.filter((p) => p.type === 'Inter Bank').reduce((sum, p) => sum + (p.equivalentUsdMn || 0), 0); const plcTotal = plcNetTotal + plcOutTotal; const borNetTotal = branchBorrowings.filter((b) => b.type === 'Inter Branch').reduce((sum, b) => sum + (b.equivalentUsdMn || 0), 0); const borOutTotal = branchBorrowings.filter((b) => b.type === 'Inter Bank').reduce((sum, b) => sum + (b.equivalentUsdMn || 0), 0); const borTotal = borNetTotal + borOutTotal; const bsSub = submissions[branch.id]; const bsPlcNet = bsSub?.items.placementNetwork || 0; const bsPlcOut = bsSub?.items.placementOutsideNetwork || 0; const bsBorNet = bsSub?.items.borrowingNetwork || 0; const bsBorOut = bsSub?.items.borrowingOutsideNetwork || 0; const plcDiff = Number(((plcNetTotal + plcOutTotal) - (bsPlcNet + bsPlcOut)).toFixed(2)); const borDiff = Number(((borNetTotal + borOutTotal) - (bsBorNet + bsBorOut)).toFixed(2)); return { branchId: branch.id, branchName: branch.name, placementsNetworkTotal: Number(plcNetTotal.toFixed(2)), placementsOutsideTotal: Number(plcOutTotal.toFixed(2)), placementsTotal: Number(plcTotal.toFixed(2)), bsPlacementNetwork: Number(bsPlcNet.toFixed(2)), bsPlacementOutside: Number(bsPlcOut.toFixed(2)), placementDifference: plcDiff, isPlacementMatched: Math.abs(plcDiff) < 0.1, borrowingsNetworkTotal: Number(borNetTotal.toFixed(2)), borrowingsOutsideTotal: Number(borOutTotal.toFixed(2)), borrowingsTotal: Number(borTotal.toFixed(2)), bsBorrowingNetwork: Number(bsBorNet.toFixed(2)), bsBorrowingOutside: Number(bsBorOut.toFixed(2)), borrowingDifference: borDiff, isBorrowingMatched: Math.abs(borDiff) < 0.1, }; }); // 2. Pairwise Inter-Branch Matching (Placements of Branch X to Branch Y vs Borrowings of Branch Y from Branch X) const pairwiseMismatches: InterBranchPairwiseMismatch[] = []; BRANCHES_LIST.forEach((placingBranch) => { BRANCHES_LIST.forEach((borrowingBranch) => { if (placingBranch.id === borrowingBranch.id) return; const plcDeals = placements.filter( (p) => p.branchId === placingBranch.id && p.type === 'Inter Branch' && (p.counterpartyBranchId === borrowingBranch.id || p.counterpartyName.toLowerCase().includes(borrowingBranch.name.toLowerCase())) ); const borDeals = borrowings.filter( (b) => b.branchId === borrowingBranch.id && b.type === 'Inter Branch' && (b.counterpartyBranchId === placingBranch.id || b.counterpartyName.toLowerCase().includes(placingBranch.name.toLowerCase())) ); const plcSum = Number(plcDeals.reduce((sum, p) => sum + (p.equivalentUsdMn || 0), 0).toFixed(2)); const borSum = Number(borDeals.reduce((sum, b) => sum + (b.equivalentUsdMn || 0), 0).toFixed(2)); if (plcSum > 0 || borSum > 0) { const diff = Number(Math.abs(plcSum - borSum).toFixed(2)); let status: 'matched' | 'mismatch' | 'missing_counterparty' = 'matched'; let details = `Matched: ${placingBranch.name} placement ($${plcSum}Mn) equals ${borrowingBranch.name} borrowing ($${borSum}Mn).`; if (diff >= 0.1) { if (plcSum > 0 && borSum === 0) { status = 'missing_counterparty'; details = `Missing Entry: ${placingBranch.name} reports Placement of $${plcSum}Mn with ${borrowingBranch.name}, but ${borrowingBranch.name} has no corresponding Borrowing logged.`; } else if (borSum > 0 && plcSum === 0) { status = 'missing_counterparty'; details = `Missing Entry: ${borrowingBranch.name} reports Borrowing of $${borSum}Mn from ${placingBranch.name}, but ${placingBranch.name} has no corresponding Placement logged.`; } else { status = 'mismatch'; details = `Mismatch: ${placingBranch.name} reports $${plcSum}Mn Placement with ${borrowingBranch.name}, but ${borrowingBranch.name} reports $${borSum}Mn Borrowing. Variance = $${diff}Mn.`; } } pairwiseMismatches.push({ id: `pair-${placingBranch.id}-${borrowingBranch.id}`, placingBranchId: placingBranch.id, placingBranchName: placingBranch.name, borrowingBranchId: borrowingBranch.id, borrowingBranchName: borrowingBranch.name, placementUsdMn: plcSum, borrowingUsdMn: borSum, differenceUsdMn: diff, status, details, }); } }); }); res.json({ period, reconciliations, pairwiseMismatches, }); }); // Auto-Carry / Sync Placements & Borrowings Totals directly to Branch Balance Sheet app.post('/api/placements-borrowings/sync-balance-sheet', (req, res) => { const { period = db.activePeriod, branchId } = req.body; if (!branchId) { return res.status(400).json({ error: 'Branch ID is required for balance sheet sync.' }); } const branchPlacements = (db.placements || []).filter((p) => p.branchId === branchId && (!p.period || p.period === period)); const branchBorrowings = (db.borrowings || []).filter((b) => b.branchId === branchId && (!b.period || b.period === period)); const plcNetTotal = branchPlacements.filter((p) => p.type === 'Inter Branch').reduce((sum, p) => sum + (p.equivalentUsdMn || 0), 0); const plcOutTotal = branchPlacements.filter((p) => p.type === 'Inter Bank').reduce((sum, p) => sum + (p.equivalentUsdMn || 0), 0); const borNetTotal = branchBorrowings.filter((b) => b.type === 'Inter Branch').reduce((sum, b) => sum + (b.equivalentUsdMn || 0), 0); const borOutTotal = branchBorrowings.filter((b) => b.type === 'Inter Bank').reduce((sum, b) => sum + (b.equivalentUsdMn || 0), 0); if (!db.submissions[period]) { db.submissions[period] = {} as Record; } const existingSub = db.submissions[period][branchId as BranchId]; if (existingSub) { existingSub.items.placementNetwork = Number(plcNetTotal.toFixed(2)); existingSub.items.placementOutsideNetwork = Number(plcOutTotal.toFixed(2)); existingSub.items.borrowingNetwork = Number(borNetTotal.toFixed(2)); existingSub.items.borrowingOutsideNetwork = Number(borOutTotal.toFixed(2)); // Recalculate totals and balance check const totAssets = computeTotalAssets(existingSub.items); const totLiab = computeTotalLiabilities(existingSub.items); existingSub.differenceAmount = Number((totAssets - totLiab).toFixed(2)); existingSub.isBalanced = Math.abs(existingSub.differenceAmount) < 0.01; existingSub.submittedAt = new Date().toISOString(); } saveDB(db); res.json({ message: `Placements & Borrowings schedule totals successfully synchronized with Balance Sheet for ${branchId}.`, syncedValues: { placementNetwork: Number(plcNetTotal.toFixed(2)), placementOutsideNetwork: Number(plcOutTotal.toFixed(2)), borrowingNetwork: Number(borNetTotal.toFixed(2)), borrowingOutsideNetwork: Number(borOutTotal.toFixed(2)), }, submission: db.submissions[period]?.[branchId as BranchId], }); }); // ------------------------------------------------------------- // LIQUIDITY GAP OVERRIDES ENDPOINTS // ------------------------------------------------------------- // Get Liquidity Gap Custom Inputs / Overrides app.get('/api/liquidity-gap', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const branchId = (req.query.branchId as string) || 'all'; if (!db.liquidityGapOverrides) db.liquidityGapOverrides = {}; const periodData = db.liquidityGapOverrides[period] || {}; const branchData = periodData[branchId] || {}; res.json({ period, branchId, overrides: branchData }); }); // Save Liquidity Gap Custom Inputs / Overrides app.post('/api/liquidity-gap', (req, res) => { const { period = db.activePeriod, branchId = 'all', overrides } = req.body; if (!db.liquidityGapOverrides) db.liquidityGapOverrides = {}; if (!db.liquidityGapOverrides[period]) db.liquidityGapOverrides[period] = {}; db.liquidityGapOverrides[period][branchId] = overrides; saveDB(db); res.json({ message: 'Liquidity Gap custom projections saved successfully.', overrides }); }); // ============================================================= // API VERSION 1 (ENTERPRISE ACCOUNTING ENGINE & WORKFLOWS) // ============================================================= // 1. Health check & system capabilities app.get('/api/v1/health', (req, res) => { res.json({ apiVersion: 'v1.0.0', status: 'healthy', engine: 'Server-Side Accounting & Double-Entry Engine', activePeriod: db.activePeriod, totalBranches: BRANCHES_LIST.length, timestamp: new Date().toISOString(), }); }); // 2. Audit Trail Endpoint app.get('/api/v1/audit-trail', (req, res) => { const { period, branchId, limit = 100 } = req.query; let logs = db.auditLogs || []; if (branchId) { logs = logs.filter((l) => l.branchId === branchId); } res.json({ totalCount: logs.length, logs: logs.slice(0, Number(limit)), }); }); // 3. Exchange Rates Endpoints (FX Translation & HO PKR Input) app.get('/api/fx-rates', (req, res) => { const pkrRates = db.pkrRates || { USDPKR: 278.16, EURPKR: 302.35, GBPPKR: 356.60, JPYPKR: 1.79, AEDPKR: 75.79, SARPKR: 74.18, BDTPKR: 2.36, CNYPKR: 38.47, HKDPKR: 35.57, CADPKR: 204.53, }; const usdPkr = pkrRates.USDPKR || 278.16; // Derived USD cross rates (1 USD = X Units of Currency) const usdCrossRates: Record = { USD: 1.0, PKR: usdPkr, EUR: Number((usdPkr / (pkrRates.EURPKR || 302.35)).toFixed(4)), GBP: Number((usdPkr / (pkrRates.GBPPKR || 356.60)).toFixed(4)), JPY: Number((usdPkr / (pkrRates.JPYPKR || 1.79)).toFixed(4)), AED: Number((usdPkr / (pkrRates.AEDPKR || 75.79)).toFixed(4)), SAR: Number((usdPkr / (pkrRates.SARPKR || 74.18)).toFixed(4)), BDT: Number((usdPkr / (pkrRates.BDTPKR || 2.36)).toFixed(4)), CNY: Number((usdPkr / (pkrRates.CNYPKR || 38.47)).toFixed(4)), HKD: Number((usdPkr / (pkrRates.HKDPKR || 35.57)).toFixed(4)), CAD: Number((usdPkr / (pkrRates.CADPKR || 204.53)).toFixed(4)), }; res.json({ pkrRates, usdCrossRates, usdPkr }); }); app.post('/api/fx-rates', (req, res) => { const { pkrRates } = req.body; if (!pkrRates || typeof pkrRates !== 'object') { return res.status(400).json({ error: 'Invalid pkrRates object provided' }); } db.pkrRates = { ...(db.pkrRates || {}), ...pkrRates }; // Update default USD cross rates const usdPkr = db.pkrRates.USDPKR || 278.16; DEFAULT_FX_RATES['USD'] = 1.0; if (db.pkrRates.EURPKR) DEFAULT_FX_RATES['EUR'] = Number((usdPkr / db.pkrRates.EURPKR).toFixed(4)); if (db.pkrRates.GBPPKR) DEFAULT_FX_RATES['GBP'] = Number((usdPkr / db.pkrRates.GBPPKR).toFixed(4)); if (db.pkrRates.JPYPKR) DEFAULT_FX_RATES['JPY'] = Number((usdPkr / db.pkrRates.JPYPKR).toFixed(4)); if (db.pkrRates.AEDPKR) DEFAULT_FX_RATES['AED'] = Number((usdPkr / db.pkrRates.AEDPKR).toFixed(4)); if (db.pkrRates.SARPKR) DEFAULT_FX_RATES['SAR'] = Number((usdPkr / db.pkrRates.SARPKR).toFixed(4)); if (db.pkrRates.BDTPKR) DEFAULT_FX_RATES['BDT'] = Number((usdPkr / db.pkrRates.BDTPKR).toFixed(4)); if (db.pkrRates.CNYPKR) DEFAULT_FX_RATES['CNY'] = Number((usdPkr / db.pkrRates.CNYPKR).toFixed(4)); if (db.pkrRates.HKDPKR) DEFAULT_FX_RATES['HKD'] = Number((usdPkr / db.pkrRates.HKDPKR).toFixed(4)); if (db.pkrRates.CADPKR) DEFAULT_FX_RATES['CAD'] = Number((usdPkr / db.pkrRates.CADPKR).toFixed(4)); saveDB(db); res.json({ message: 'PKR Exchange Rates updated successfully by Head Office.', pkrRates: db.pkrRates, usdCrossRates: DEFAULT_FX_RATES }); }); app.get('/api/v1/fx-rates', (req, res) => { res.json({ baseCurrency: 'USD', rates: db.exchangeRates || [], defaultRates: DEFAULT_FX_RATES, }); }); app.post('/api/v1/fx-rates', (req, res) => { const { currency, rateToUsd, period = db.activePeriod, userEmail = 'admin@networkbank.com' } = req.body; if (!currency || !rateToUsd || isNaN(rateToUsd) || rateToUsd <= 0) { return res.status(400).json({ error: 'Valid currency and positive rateToUsd are required.' }); } if (!db.exchangeRates) db.exchangeRates = []; const upperCurr = currency.toUpperCase(); const existingIdx = db.exchangeRates.findIndex((r) => r.currency === upperCurr && r.period === period); const rateRecord: ExchangeRateRecord = { id: `fx-${upperCurr}-${period}`, period, currency: upperCurr, rateToUsd: Number(Number(rateToUsd).toFixed(4)), rateToPkr: Number((278.16 / Number(rateToUsd)).toFixed(4)), effectiveDate: new Date().toISOString().split('T')[0], updatedBy: userEmail, updatedAt: new Date().toISOString(), }; if (existingIdx >= 0) { db.exchangeRates[existingIdx] = rateRecord; } else { db.exchangeRates.push(rateRecord); } // Update default in-memory cross rate map DEFAULT_FX_RATES[upperCurr] = rateRecord.rateToUsd; // Add Audit Log if (!db.auditLogs) db.auditLogs = []; db.auditLogs.unshift({ id: `audit-${Date.now()}`, timestamp: new Date().toISOString(), userId: userEmail, userName: 'HO Treasury Admin', userRole: 'admin', action: 'UPDATE_FX_RATE', entityType: 'fx_rate', entityId: rateRecord.id, details: `Updated FX Exchange Rate for ${upperCurr} (${period}): 1 USD = ${rateRecord.rateToUsd} ${upperCurr}`, newValue: rateRecord, }); saveDB(db); res.json({ message: `FX Rate updated for ${upperCurr}`, rate: rateRecord }); }); // 4. Period Lock & Period Governance Endpoints app.get('/api/v1/period-locks', (req, res) => { const { period = db.activePeriod } = req.query; const locks = db.periodLocks?.[period as string] || {}; res.json({ period, locks }); }); app.post('/api/v1/period-locks', (req, res) => { const { period = db.activePeriod, branchId = 'all', isLocked, reason, userEmail = 'admin@networkbank.com' } = req.body; if (isLocked === undefined) { return res.status(400).json({ error: 'isLocked boolean state is required.' }); } if (!db.periodLocks) db.periodLocks = {}; if (!db.periodLocks[period]) db.periodLocks[period] = {}; const lockRecord: PeriodLockRecord = { period, branchId, isLocked: Boolean(isLocked), lockedBy: userEmail, lockedAt: new Date().toISOString(), reason: reason || (isLocked ? 'Period closed for HO consolidation' : 'Period unlocked for review'), }; db.periodLocks[period][branchId] = lockRecord; // Log Audit if (!db.auditLogs) db.auditLogs = []; db.auditLogs.unshift({ id: `audit-${Date.now()}`, timestamp: new Date().toISOString(), userId: userEmail, userName: 'HO Admin', userRole: 'admin', action: isLocked ? 'LOCK_PERIOD' : 'UNLOCK_PERIOD', entityType: 'period_lock', details: `${isLocked ? 'Locked' : 'Unlocked'} period ${period} for branch: ${branchId}. Reason: ${lockRecord.reason}`, newValue: lockRecord, }); saveDB(db); res.json({ message: `Period ${period} (${branchId}) ${isLocked ? 'LOCKED' : 'UNLOCKED'} successfully.`, lock: lockRecord }); }); // 5. Trial Balance & Double-Entry Validation Endpoint app.get('/api/v1/trial-balance', (req, res) => { const period = (req.query.period as string) || db.activePeriod; const branchId = (req.query.branchId as string) || 'japan'; const branchObj = BRANCHES_LIST.find((b) => b.id === branchId); const branchName = branchObj ? branchObj.name : branchId; const sub = db.submissions[period]?.[branchId as BranchId]; if (!sub) { return res.status(404).json({ error: `No submission found for branch ${branchId} in period ${period}.` }); } const tbResult = AccountingEngine.generateTrialBalance(branchId, branchName, period, sub.items); res.json(tbResult); }); // 6. Maker-Checker Approval Workflow Endpoint app.post('/api/v1/submissions/status', (req, res) => { const { period, branchId, status, userEmail, userName, comments } = req.body; if (!period || !branchId || !status) { return res.status(400).json({ error: 'Missing period, branchId, or status' }); } const validStatuses: MakerCheckerStatus[] = ['draft', 'submitted', 'reviewed', 'approved', 'locked']; if (!validStatuses.includes(status)) { return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` }); } const sub = db.submissions[period]?.[branchId as BranchId]; if (!sub) { return res.status(404).json({ error: `Submission not found for branch ${branchId} in period ${period}.` }); } const prevStatus = sub.status; sub.status = status; if (status === 'reviewed') { sub.reviewedBy = userName || userEmail || 'Reviewer'; sub.reviewedAt = new Date().toISOString(); } else if (status === 'approved') { sub.approvedBy = userName || userEmail || 'Approver'; sub.approvedAt = new Date().toISOString(); } else if (status === 'locked') { sub.lockedBy = userName || userEmail || 'Lock Master'; sub.lockedAt = new Date().toISOString(); } // Audit trail if (!db.auditLogs) db.auditLogs = []; db.auditLogs.unshift({ id: `audit-${Date.now()}`, timestamp: new Date().toISOString(), userId: userEmail || 'admin@networkbank.com', userName: userName || 'HO Admin', userRole: 'admin', branchId: branchId as BranchId, action: `WORKFLOW_STATUS_CHANGE_${status.toUpperCase()}`, entityType: 'submission', entityId: sub.id, details: `Updated submission status for ${branchId.toUpperCase()} (${period}) from '${prevStatus}' to '${status}'. ${comments ? `Notes: ${comments}` : ''}`, previousValue: prevStatus, newValue: status, }); saveDB(db); res.json({ message: `Submission workflow status set to '${status}' successfully.`, submission: sub }); }); // ------------------------------------------------------------- // SMTP & REMINDER ENDPOINTS // ------------------------------------------------------------- app.get('/api/smtp/config', (req, res) => { res.json({ config: getEffectiveSmtpConfig(db.smtpConfig), logs: db.emailLogs }); }); app.post('/api/smtp/config', (req, res) => { const { autoRemindersEnabled } = req.body; if (!db.smtpConfig) { db.smtpConfig = getEffectiveSmtpConfig(); } if (autoRemindersEnabled !== undefined) { db.smtpConfig.autoRemindersEnabled = Boolean(autoRemindersEnabled); } saveDB(db); res.json({ config: getEffectiveSmtpConfig(db.smtpConfig) }); }); // Test SMTP connection by sending a test message app.post('/api/smtp/test', async (req, res) => { const { testEmail } = req.body; const currentSmtp = getEffectiveSmtpConfig(db.smtpConfig); const targetEmail = testEmail || currentSmtp.fromEmail || 'admin@networkbank.com'; const subject = `[SMTP TEST] Network Bank Balance Sheet Portal Connection Test`; const body = `This is a test notification dispatched from the Head Office Balance Sheet Portal to verify SMTP server connectivity.\n\nServer Host: ${currentSmtp.host}:${currentSmtp.port}\nSender: ${currentSmtp.fromEmail}\nTimestamp: ${new Date().toISOString()}`; const sentOk = await sendEmailNotification(targetEmail, subject, body, currentSmtp); const emailLog: EmailLog = { id: `log-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, recipientEmail: targetEmail, subject, body, sentAt: new Date().toISOString(), status: sentOk ? 'sent' : 'failed', triggerType: 'manual_reminder', }; db.emailLogs.unshift(emailLog); saveDB(db); if (sentOk) { res.json({ success: true, message: `SMTP test notification dispatched successfully to ${targetEmail}.`, logs: db.emailLogs }); } else { res.status(500).json({ success: false, message: `Failed to send test email to ${targetEmail}. Please verify SMTP environment configuration on the host server.`, logs: db.emailLogs }); } }); // Send reminders to branches app.post('/api/smtp/send-reminders', async (req, res) => { const period = db.activePeriod; const submissions = db.submissions[period] || {}; const currentSmtp = getEffectiveSmtpConfig(db.smtpConfig); const submittedBranchIds = Object.keys(submissions); const pendingBranches = BRANCHES_LIST.filter((b) => !submittedBranchIds.includes(b.id)); const newLogs: EmailLog[] = []; for (const branch of BRANCHES_LIST) { const isSubmitted = submittedBranchIds.includes(branch.id); const subject = isSubmitted ? `[CONFIRMED] Balance Sheet Received for ${branch.name} (${period})` : `[URGENT REMINDER] Balance Sheet Submission Due for ${branch.name} (${period})`; const body = isSubmitted ? `Dear ${branch.name} Team, Thank you. Your Balance Sheet submission for period ${period} has been received and verified by Head Office.` : `Dear ${branch.name} Team, This is an automated reminder from Head Office Balance Sheet Portal. Please complete and submit your fixed format balance sheet for period ${period}. Ensure all variances > USD 2 Mn carry explanatory notes.`; const sentOk = await sendEmailNotification(branch.contactEmail, subject, body, currentSmtp); const emailLog: EmailLog = { id: `log-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, recipientEmail: branch.contactEmail, branchId: branch.id, subject, body, sentAt: new Date().toISOString(), status: sentOk ? 'sent' : 'failed', triggerType: 'manual_reminder', }; newLogs.push(emailLog); } db.emailLogs = [...newLogs, ...db.emailLogs]; saveDB(db); res.json({ message: `SMTP Reminders dispatched to ${BRANCHES_LIST.length} branch emails (${pendingBranches.length} pending branches notified).`, pendingCount: pendingBranches.length, logs: db.emailLogs, }); }); // Reset database back to default seed data app.post('/api/reset-seed', (req, res) => { if (fs.existsSync(dbPath)) { fs.unlinkSync(dbPath); } db = loadDB(); res.json({ message: 'Database reset to initial baseline successfully.' }); }); // ------------------------------------------------------------- // VITE / STATIC MIDDLEWARE // ------------------------------------------------------------- async function startServer() { if (process.env.NODE_ENV !== 'production') { const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'spa', }); app.use(vite.middlewares); } else { const distPath = path.join(process.cwd(), 'dist'); app.use(express.static(distPath)); app.get('*', (req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); } app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on http://0.0.0.0:${PORT}`); }); } startServer();