Dominion/server.ts

1596 lines
59 KiB
TypeScript
Raw Normal View History

2026-08-07 08:06:39 +00:00
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,
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';
2026-08-07 09:38:37 +00:00
import { initPgDatabase, loadStateFromPg, saveStateToPg } from './src/db/postgres.js';
2026-08-07 08:06:39 +00:00
const app = express();
2026-08-07 09:28:35 +00:00
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
2026-08-07 08:06:39 +00:00
app.use(express.json());
// In-Memory Database / Persistent File Storage
2026-08-07 09:38:37 +00:00
export interface DB {
2026-08-07 08:06:39 +00:00
users: User[];
submissions: Record<string, Record<BranchId, BranchSubmission>>; // period -> branchId -> submission
smtpConfig: SmtpConfig;
emailLogs: EmailLog[];
activePeriod: string;
periodsList: string[];
investments: InvestmentSecurity[];
placements: PlacementRecord[];
borrowings: BorrowingRecord[];
liquidityGapOverrides?: Record<string, Record<string, any>>;
auditLogs?: AuditLogEntry[];
exchangeRates?: ExchangeRateRecord[];
periodLocks?: Record<string, Record<string, PeriodLockRecord>>;
pkrRates?: Record<string, number>;
}
2026-08-07 09:28:35 +00:00
const dataDir = process.env.DATA_DIR || process.cwd();
const dbPath = process.env.DATA_PATH || path.join(dataDir, 'portal-data.json');
2026-08-07 08:06:39 +00:00
const DEFAULT_PKR_RATES: Record<string, number> = {
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 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);
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<string, Record<string, BranchSubmission>> = {
'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: { ...INITIAL_SMTP_CONFIG },
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 {
2026-08-07 09:28:35 +00:00
const parentDir = path.dirname(dbPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
2026-08-07 08:06:39 +00:00
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2));
} catch (err) {
console.error('Error saving portal-data.json:', err);
}
2026-08-07 09:38:37 +00:00
// Asynchronously push state to PostgreSQL if configured
saveStateToPg(data).catch((err) => {
console.error('Error syncing state to PostgreSQL:', err);
});
2026-08-07 08:06:39 +00:00
}
let db = loadDB();
2026-08-07 09:38:37 +00:00
// 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);
}
}
})();
2026-08-07 08:06:39 +00:00
// -------------------------------------------------------------
// 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<BranchId, BranchSubmission>;
}
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<BranchId, BranchSubmission>;
}
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<string, { mtmPrice: number; mtmYield?: number }>();
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<BranchId, BranchSubmission>;
}
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<string, number> = {
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: db.smtpConfig, logs: db.emailLogs });
});
app.post('/api/smtp/config', (req, res) => {
const { host, port, username, fromEmail, useTls, autoRemindersEnabled } = req.body;
db.smtpConfig = {
host: host || db.smtpConfig.host,
port: Number(port) || db.smtpConfig.port,
username: username || db.smtpConfig.username,
fromEmail: fromEmail || db.smtpConfig.fromEmail,
useTls: useTls !== undefined ? useTls : db.smtpConfig.useTls,
autoRemindersEnabled: autoRemindersEnabled !== undefined ? autoRemindersEnabled : db.smtpConfig.autoRemindersEnabled,
reminderFrequencyDays: 7,
};
saveDB(db);
res.json({ config: db.smtpConfig });
});
// Send reminders to branches
app.post('/api/smtp/send-reminders', async (req, res) => {
const period = db.activePeriod;
const submissions = db.submissions[period] || {};
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 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: 'sent',
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();