342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
|
|
import {
|
||
|
|
BalanceSheetItems,
|
||
|
|
CompositionBreakdown,
|
||
|
|
TrialBalanceResult,
|
||
|
|
TrialBalanceLine,
|
||
|
|
VarianceComment,
|
||
|
|
ASSET_ITEMS_CONFIG,
|
||
|
|
LIABILITY_ITEMS_CONFIG,
|
||
|
|
ExchangeRateRecord,
|
||
|
|
BRANCHES_LIST
|
||
|
|
} from '../types';
|
||
|
|
|
||
|
|
// Default FX Rates table relative to USD
|
||
|
|
export const DEFAULT_FX_RATES: Record<string, number> = {
|
||
|
|
USD: 1.0,
|
||
|
|
PKR: 278.16,
|
||
|
|
EUR: 0.92,
|
||
|
|
GBP: 0.78,
|
||
|
|
JPY: 155.40,
|
||
|
|
AED: 3.67,
|
||
|
|
SAR: 3.75,
|
||
|
|
BDT: 117.50,
|
||
|
|
CNY: 7.23,
|
||
|
|
HKD: 7.82,
|
||
|
|
CAD: 1.36,
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Server-Side Accounting Engine
|
||
|
|
* Centralizes all Balance Sheet, Trial Balance, FX, and Double-Entry Calculations
|
||
|
|
*/
|
||
|
|
export class AccountingEngine {
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute Total Assets from BalanceSheetItems
|
||
|
|
*/
|
||
|
|
static computeTotalAssets(items: BalanceSheetItems): number {
|
||
|
|
if (!items) return 0;
|
||
|
|
const sum =
|
||
|
|
(items.balancesWithBanks || 0) +
|
||
|
|
(items.balanceCentralBank || 0) +
|
||
|
|
(items.placementHO || 0) +
|
||
|
|
(items.placementNetwork || 0) +
|
||
|
|
(items.placementOutsideNetwork || 0) +
|
||
|
|
(items.tradeAsset || 0) +
|
||
|
|
(items.netAdvances || 0) +
|
||
|
|
(items.investments || 0) +
|
||
|
|
(items.otherAssets || 0);
|
||
|
|
return Number(sum.toFixed(2));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute Total Liabilities from BalanceSheetItems
|
||
|
|
*/
|
||
|
|
static computeTotalLiabilities(items: BalanceSheetItems): number {
|
||
|
|
if (!items) return 0;
|
||
|
|
const sum =
|
||
|
|
(items.fiDepositBanksOnly || 0) +
|
||
|
|
(items.customerDeposit || 0) +
|
||
|
|
(items.depositsNbpNetwork || 0) +
|
||
|
|
(items.borrowingNetwork || 0) +
|
||
|
|
(items.borrowingOutsideNetwork || 0) +
|
||
|
|
(items.borrowingHO || 0) +
|
||
|
|
(items.headOfficeSupportFund || 0) +
|
||
|
|
(items.otherLiabilities || 0);
|
||
|
|
return Number(sum.toFixed(2));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute Net Assets = Total Assets - Total Liabilities
|
||
|
|
*/
|
||
|
|
static computeNetAssets(items: BalanceSheetItems): number {
|
||
|
|
const assets = this.computeTotalAssets(items);
|
||
|
|
const liabilities = this.computeTotalLiabilities(items);
|
||
|
|
return Number((assets - liabilities).toFixed(2));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a formal Trial Balance with Double-Entry Validation (Debit = Credit)
|
||
|
|
* Chart of Accounts (COA) mapping:
|
||
|
|
* - Asset lines -> Debit balances
|
||
|
|
* - Liability lines -> Credit balances
|
||
|
|
* - Head Office Fund / Net Assets -> Balancing Equity (Credit balance)
|
||
|
|
*/
|
||
|
|
static generateTrialBalance(
|
||
|
|
branchId: string,
|
||
|
|
branchName: string,
|
||
|
|
period: string,
|
||
|
|
items: BalanceSheetItems
|
||
|
|
): TrialBalanceResult {
|
||
|
|
const lines: TrialBalanceLine[] = [];
|
||
|
|
|
||
|
|
// Chart of Accounts (COA) Codes
|
||
|
|
const assetCoaMap: Record<keyof BalanceSheetItems, { code: string; name: string }> = {
|
||
|
|
balancesWithBanks: { code: '1010', name: 'Balances with Banks (Onshore/Offshore)' },
|
||
|
|
balanceCentralBank: { code: '1020', name: 'Balance with Central Bank' },
|
||
|
|
placementHO: { code: '1110', name: 'Placements - Head Office' },
|
||
|
|
placementNetwork: { code: '1120', name: 'Placements - Overseas Network' },
|
||
|
|
placementOutsideNetwork: { code: '1130', name: 'Placements - Inter-Bank Outside Network' },
|
||
|
|
tradeAsset: { code: '1210', name: 'Trade Finance Assets' },
|
||
|
|
netAdvances: { code: '1310', name: 'Net Advances & Loans' },
|
||
|
|
investments: { code: '1410', name: 'Investment Portfolio (HTM/AFS/HFT)' },
|
||
|
|
otherAssets: { code: '1510', name: 'Other Assets & Sundry Receivables' },
|
||
|
|
// dummy mappings for liability keys to avoid TS error
|
||
|
|
fiDepositBanksOnly: { code: '', name: '' },
|
||
|
|
customerDeposit: { code: '', name: '' },
|
||
|
|
depositsNbpNetwork: { code: '', name: '' },
|
||
|
|
borrowingNetwork: { code: '', name: '' },
|
||
|
|
borrowingOutsideNetwork: { code: '', name: '' },
|
||
|
|
borrowingHO: { code: '', name: '' },
|
||
|
|
headOfficeSupportFund: { code: '', name: '' },
|
||
|
|
otherLiabilities: { code: '', name: '' },
|
||
|
|
fullPledged: { code: '', name: '' },
|
||
|
|
partialPledged: { code: '', name: '' },
|
||
|
|
};
|
||
|
|
|
||
|
|
const liabilityCoaMap: Record<keyof BalanceSheetItems, { code: string; name: string }> = {
|
||
|
|
fiDepositBanksOnly: { code: '2010', name: 'FI Deposits (Banks Only)' },
|
||
|
|
customerDeposit: { code: '2020', name: 'Customer Deposits' },
|
||
|
|
depositsNbpNetwork: { code: '2110', name: 'Deposits from NBP Overseas Network' },
|
||
|
|
borrowingNetwork: { code: '2210', name: 'Borrowings - NBP Network' },
|
||
|
|
borrowingOutsideNetwork: { code: '2220', name: 'Borrowings - Inter-Bank Outside Network' },
|
||
|
|
borrowingHO: { code: '2230', name: 'Borrowings - Head Office' },
|
||
|
|
headOfficeSupportFund: { code: '2310', name: 'Head Office Capital Support Fund' },
|
||
|
|
otherLiabilities: { code: '2410', name: 'Other Liabilities & Accrued Provisions' },
|
||
|
|
// dummy mappings
|
||
|
|
balancesWithBanks: { code: '', name: '' },
|
||
|
|
balanceCentralBank: { code: '', name: '' },
|
||
|
|
placementHO: { code: '', name: '' },
|
||
|
|
placementNetwork: { code: '', name: '' },
|
||
|
|
placementOutsideNetwork: { code: '', name: '' },
|
||
|
|
tradeAsset: { code: '', name: '' },
|
||
|
|
netAdvances: { code: '', name: '' },
|
||
|
|
investments: { code: '', name: '' },
|
||
|
|
otherAssets: { code: '', name: '' },
|
||
|
|
fullPledged: { code: '', name: '' },
|
||
|
|
partialPledged: { code: '', name: '' },
|
||
|
|
};
|
||
|
|
|
||
|
|
let totalDebit = 0;
|
||
|
|
let totalCredit = 0;
|
||
|
|
|
||
|
|
// Process Assets (Debit Balance)
|
||
|
|
ASSET_ITEMS_CONFIG.forEach((cfg) => {
|
||
|
|
const val = items[cfg.key] || 0;
|
||
|
|
if (val > 0) {
|
||
|
|
const coa = assetCoaMap[cfg.key];
|
||
|
|
lines.push({
|
||
|
|
accountCode: coa.code,
|
||
|
|
accountName: coa.name,
|
||
|
|
type: 'Asset',
|
||
|
|
debitUsdMn: val,
|
||
|
|
creditUsdMn: 0,
|
||
|
|
netBalanceUsdMn: val,
|
||
|
|
});
|
||
|
|
totalDebit += val;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Process Liabilities (Credit Balance)
|
||
|
|
LIABILITY_ITEMS_CONFIG.forEach((cfg) => {
|
||
|
|
const val = items[cfg.key] || 0;
|
||
|
|
if (val > 0) {
|
||
|
|
const coa = liabilityCoaMap[cfg.key];
|
||
|
|
lines.push({
|
||
|
|
accountCode: coa.code,
|
||
|
|
accountName: coa.name,
|
||
|
|
type: 'Liability',
|
||
|
|
debitUsdMn: 0,
|
||
|
|
creditUsdMn: val,
|
||
|
|
netBalanceUsdMn: -val,
|
||
|
|
});
|
||
|
|
totalCredit += val;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Equity / Retained Earnings Balancing Line (Double Entry Principle)
|
||
|
|
const netAssets = totalDebit - totalCredit;
|
||
|
|
if (Math.abs(netAssets) > 0.0001) {
|
||
|
|
lines.push({
|
||
|
|
accountCode: '3010',
|
||
|
|
accountName: 'Branch Retained Capital & Reserves Equity',
|
||
|
|
type: 'Equity',
|
||
|
|
debitUsdMn: netAssets < 0 ? Math.abs(netAssets) : 0,
|
||
|
|
creditUsdMn: netAssets > 0 ? netAssets : 0,
|
||
|
|
netBalanceUsdMn: netAssets,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (netAssets > 0) {
|
||
|
|
totalCredit += netAssets;
|
||
|
|
} else {
|
||
|
|
totalDebit += Math.abs(netAssets);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
totalDebit = Number(totalDebit.toFixed(2));
|
||
|
|
totalCredit = Number(totalCredit.toFixed(2));
|
||
|
|
const diff = Number((totalDebit - totalCredit).toFixed(2));
|
||
|
|
|
||
|
|
return {
|
||
|
|
period,
|
||
|
|
branchId,
|
||
|
|
branchName,
|
||
|
|
lines,
|
||
|
|
totalDebitUsdMn: totalDebit,
|
||
|
|
totalCreditUsdMn: totalCredit,
|
||
|
|
differenceUsdMn: diff,
|
||
|
|
isDoubleEntryBalanced: Math.abs(diff) < 0.01,
|
||
|
|
computedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate Variances with Materiality Threshold
|
||
|
|
* Standard Banking Threshold: Default $1.00 Mn or $2.00 Mn
|
||
|
|
*/
|
||
|
|
static calculateVariancesWithMateriality(
|
||
|
|
prevItems: BalanceSheetItems,
|
||
|
|
newItems: BalanceSheetItems,
|
||
|
|
existingComments: VarianceComment[] = [],
|
||
|
|
materialityThresholdUsdMn: number = 1.0
|
||
|
|
): {
|
||
|
|
commentsRequired: { lineItemKey: keyof BalanceSheetItems; lineItemLabel: string; previousValue: number; newValue: number; variance: number }[];
|
||
|
|
autoGeneratedComments: VarianceComment[];
|
||
|
|
} {
|
||
|
|
const commentsRequired: { lineItemKey: keyof BalanceSheetItems; lineItemLabel: string; previousValue: number; newValue: number; variance: number }[] = [];
|
||
|
|
const autoGeneratedComments: VarianceComment[] = [];
|
||
|
|
|
||
|
|
const allConfigs = [...ASSET_ITEMS_CONFIG, ...LIABILITY_ITEMS_CONFIG];
|
||
|
|
|
||
|
|
allConfigs.forEach((cfg) => {
|
||
|
|
const pVal = prevItems ? prevItems[cfg.key] || 0 : 0;
|
||
|
|
const nVal = newItems ? newItems[cfg.key] || 0 : 0;
|
||
|
|
const variance = nVal - pVal;
|
||
|
|
|
||
|
|
if (Math.abs(variance) >= materialityThresholdUsdMn) {
|
||
|
|
commentsRequired.push({
|
||
|
|
lineItemKey: cfg.key,
|
||
|
|
lineItemLabel: cfg.label,
|
||
|
|
previousValue: pVal,
|
||
|
|
newValue: nVal,
|
||
|
|
variance: Number(variance.toFixed(2)),
|
||
|
|
});
|
||
|
|
|
||
|
|
const existing = existingComments.find((c) => c.lineItemKey === cfg.key);
|
||
|
|
autoGeneratedComments.push({
|
||
|
|
lineItemKey: cfg.key,
|
||
|
|
lineItemLabel: cfg.label,
|
||
|
|
previousValue: pVal,
|
||
|
|
newValue: nVal,
|
||
|
|
variance: Number(variance.toFixed(2)),
|
||
|
|
comment: existing?.comment || `Material variance of ${variance > 0 ? '+' : ''}${variance.toFixed(2)} USD Mn observed vs prior period.`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return { commentsRequired, autoGeneratedComments };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* FX Currency Conversion
|
||
|
|
* Convert amount from fromCurrency to toCurrency using current rates
|
||
|
|
*/
|
||
|
|
static convertCurrency(
|
||
|
|
amount: number,
|
||
|
|
fromCurrency: string = 'USD',
|
||
|
|
toCurrency: string = 'USD',
|
||
|
|
customRates?: Record<string, number>
|
||
|
|
): { amountConverted: number; rateUsed: number } {
|
||
|
|
const rates = { ...DEFAULT_FX_RATES, ...(customRates || {}) };
|
||
|
|
|
||
|
|
const fromRateToUsd = rates[fromCurrency.toUpperCase()] || 1.0;
|
||
|
|
const toRateToUsd = rates[toCurrency.toUpperCase()] || 1.0;
|
||
|
|
|
||
|
|
// First convert to USD
|
||
|
|
const usdAmount = amount / fromRateToUsd;
|
||
|
|
// Then convert to target currency
|
||
|
|
const targetAmount = usdAmount * toRateToUsd;
|
||
|
|
const effectiveCrossRate = toRateToUsd / fromRateToUsd;
|
||
|
|
|
||
|
|
return {
|
||
|
|
amountConverted: Number(targetAmount.toFixed(4)),
|
||
|
|
rateUsed: Number(effectiveCrossRate.toFixed(4)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Server-Side Financial Input Validator
|
||
|
|
*/
|
||
|
|
static validateSubmissionInputs(
|
||
|
|
items: BalanceSheetItems,
|
||
|
|
compositions?: CompositionBreakdown
|
||
|
|
): { valid: boolean; errors: string[] } {
|
||
|
|
const errors: string[] = [];
|
||
|
|
|
||
|
|
if (!items) {
|
||
|
|
return { valid: false, errors: ['Missing balance sheet items object.'] };
|
||
|
|
}
|
||
|
|
|
||
|
|
const allConfigs = [...ASSET_ITEMS_CONFIG, ...LIABILITY_ITEMS_CONFIG];
|
||
|
|
|
||
|
|
allConfigs.forEach((cfg) => {
|
||
|
|
const val = items[cfg.key];
|
||
|
|
if (val === undefined || val === null || isNaN(val)) {
|
||
|
|
errors.push(`Field '${cfg.label}' (${cfg.key}) must be a valid number.`);
|
||
|
|
} else if (val < 0) {
|
||
|
|
errors.push(`Field '${cfg.label}' (${cfg.key}) cannot be negative (${val}).`);
|
||
|
|
} else if (val > 100000) {
|
||
|
|
errors.push(`Field '${cfg.label}' (${cfg.key}) exceeds maximum allowable threshold ($100,000 Mn).`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Validate Compositions sum matching line items
|
||
|
|
if (compositions) {
|
||
|
|
if (compositions.otherAssets && compositions.otherAssets.length > 0) {
|
||
|
|
const compSum = compositions.otherAssets.reduce((s, c) => s + (c.amount || 0), 0);
|
||
|
|
const lineVal = items.otherAssets || 0;
|
||
|
|
if (Math.abs(compSum - lineVal) > 0.05) {
|
||
|
|
errors.push(
|
||
|
|
`Other Assets breakdown total ($${compSum.toFixed(2)} Mn) does not match main line item ($${lineVal.toFixed(2)} Mn).`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (compositions.otherLiabilities && compositions.otherLiabilities.length > 0) {
|
||
|
|
const compSum = compositions.otherLiabilities.reduce((s, c) => s + (c.amount || 0), 0);
|
||
|
|
const lineVal = items.otherLiabilities || 0;
|
||
|
|
if (Math.abs(compSum - lineVal) > 0.05) {
|
||
|
|
errors.push(
|
||
|
|
`Other Liabilities breakdown total ($${compSum.toFixed(2)} Mn) does not match main line item ($${lineVal.toFixed(2)} Mn).`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
valid: errors.length === 0,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|