From b72b986a12218eba580e71faee8ae800abaff919 Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Sun, 28 Jun 2026 15:55:15 -0700 Subject: [PATCH] fix: Fineract GL balances via journal fallback when report missing Use journalentry replay when General Ledger Report is unavailable, and expose fineractConnected on the public money-supply endpoint. Co-authored-by: Cursor --- .../central-bank/CentralBankDashboard.tsx | 12 +- .../features/central-bank/useCentralBank.ts | 1 + .../features/office24/Office24Dashboard.tsx | 6 +- .../dist/adapters/fineract.js | 139 +++++++++++++++++- .../dist/api/routes/settlement.js | 9 +- .../src/adapters/fineract.ts | 139 +++++++++++++++++- .../src/api/routes/settlement.ts | 11 +- 7 files changed, 294 insertions(+), 23 deletions(-) diff --git a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx index 62f7e90..45603c1 100644 --- a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx +++ b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx @@ -83,10 +83,16 @@ export default function CentralBankDashboard() { /> - {cb.moneySupply?.fineractLive === false && ( + {cb.moneySupply?.fineractConnected && !cb.moneySupply?.fineractLive && ( +

+ Fineract connected for Office 24 — no opening journal yet. Post Dr 1410 / Cr 2100 via{' '} + seed-office-24-opening-journal.mjs. +

+ )} + + {cb.moneySupply?.fineractConnected === false && (

- Fineract GL balances unavailable — check OMNL_FINERACT_* on settlement middleware and seed - Office 24 opening journal (Dr 1410 / Cr 2100). + Fineract unreachable — check OMNL_FINERACT_* on settlement middleware.

)} diff --git a/frontend-dapp/src/features/central-bank/useCentralBank.ts b/frontend-dapp/src/features/central-bank/useCentralBank.ts index 5e481bd..ef72df1 100644 --- a/frontend-dapp/src/features/central-bank/useCentralBank.ts +++ b/frontend-dapp/src/features/central-bank/useCentralBank.ts @@ -5,6 +5,7 @@ export type MoneySupply = { asOf?: string; officeId?: number; fineractLive?: boolean; + fineractConnected?: boolean; interoffice?: { dueFromHeadOffice?: string; gl1410?: string }; M0?: { gl1050?: string; backingRatio?: number }; M1?: { circulating?: string; gl2100?: string }; diff --git a/frontend-dapp/src/features/office24/Office24Dashboard.tsx b/frontend-dapp/src/features/office24/Office24Dashboard.tsx index f3b3436..34f60ed 100644 --- a/frontend-dapp/src/features/office24/Office24Dashboard.tsx +++ b/frontend-dapp/src/features/office24/Office24Dashboard.tsx @@ -58,9 +58,9 @@ export default function Office24Dashboard() { - {moneySupply?.fineractLive === false && ( -

- No Fineract journals for Office 24 yet. Post opening M1: Dr 1410 / Cr 2100 via{' '} + {moneySupply?.fineractConnected && !moneySupply?.fineractLive && ( +

+ Fineract connected — post opening M1: Dr 1410 / Cr 2100 via{' '} scripts/deployment/seed-office-24-opening-journal.mjs

)} diff --git a/services/settlement-middleware/dist/adapters/fineract.js b/services/settlement-middleware/dist/adapters/fineract.js index 809efa1..d1e1191 100644 --- a/services/settlement-middleware/dist/adapters/fineract.js +++ b/services/settlement-middleware/dist/adapters/fineract.js @@ -3,35 +3,155 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.fineractOfficeReachable = fineractOfficeReachable; exports.postJournalEntry = postJournalEntry; exports.fetchGlBalances = fetchGlBalances; const axios_1 = __importDefault(require("axios")); +const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300']; +function fineractBase() { + return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); +} function authHeaders() { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; const user = process.env.OMNL_FINERACT_USER?.trim() || process.env.OMNL_FINERACT_USERNAME?.trim() || 'ali_hospitallers_tenant'; const pass = process.env.OMNL_FINERACT_PASSWORD || ''; - if (!base || !pass) + if (!fineractBase() || !pass) throw new Error('Fineract credentials required'); const auth = Buffer.from(`${user}:${pass}`).toString('base64'); return { Authorization: `Basic ${auth}`, 'Fineract-Platform-TenantId': tenant, 'Content-Type': 'application/json', + Accept: 'application/json', }; } +async function fineractOfficeReachable(officeId) { + try { + const { status } = await axios_1.default.get(`${fineractBase()}/offices/${officeId}`, { + headers: authHeaders(), + timeout: 20000, + }); + return status === 200; + } + catch { + return false; + } +} +async function fetchOfficeJournalLines(officeId) { + const headers = authHeaders(); + const out = []; + let offset = 0; + const limit = 200; + for (;;) { + const { data } = await axios_1.default.get(`${fineractBase()}/journalentries`, { + headers, + timeout: 60000, + params: { officeId, limit, offset }, + }); + const batch = Array.isArray(data) ? data : data?.pageItems ?? []; + if (!Array.isArray(batch) || batch.length === 0) + break; + for (const je of batch) { + const row = je; + const flat = typeof row.entryType === 'string' + ? row.entryType + : row.entryType?.value || row.entryType?.code || ''; + if (row.glAccountId != null && row.amount != null && flat) { + const normalized = flat.toUpperCase(); + out.push({ + glAccountId: row.glAccountId, + amount: Number(row.amount || 0), + entryType: normalized.startsWith('DEB') ? 'DEBIT' : 'CREDIT', + }); + continue; + } + for (const d of row.debits ?? []) { + if (d.glAccountId != null) { + out.push({ glAccountId: d.glAccountId, amount: Number(d.amount || 0), entryType: 'DEBIT' }); + } + } + for (const c of row.credits ?? []) { + if (c.glAccountId != null) { + out.push({ glAccountId: c.glAccountId, amount: Number(c.amount || 0), entryType: 'CREDIT' }); + } + } + } + if (batch.length < limit) + break; + offset += limit; + } + return out; +} +async function fetchGlAccountMap(ids) { + const headers = authHeaders(); + const map = new Map(); + for (const code of GL_HINTS) { + const { data } = await axios_1.default.get(`${fineractBase()}/glaccounts`, { + headers, + timeout: 30000, + params: { glCode: code }, + }); + const rows = Array.isArray(data) ? data : data?.pageItems ?? []; + for (const row of rows) { + if (row.id != null && row.glCode) + map.set(row.id, String(row.glCode)); + } + } + if ([...ids].some((id) => !map.has(id))) { + let offset = 0; + const limit = 200; + for (;;) { + const { data } = await axios_1.default.get(`${fineractBase()}/glaccounts`, { + headers, + timeout: 30000, + params: { limit, offset }, + }); + const rows = Array.isArray(data) ? data : data?.pageItems ?? []; + if (!rows.length) + break; + for (const row of rows) { + if (row.id != null && row.glCode && ids.has(row.id)) + map.set(row.id, String(row.glCode)); + } + if (rows.length < limit) + break; + offset += limit; + } + } + return map; +} +async function fetchGlBalancesFromJournals(officeId) { + const lines = await fetchOfficeJournalLines(officeId); + if (!lines.length) + return {}; + const ids = new Set(lines.map((l) => l.glAccountId)); + const glById = await fetchGlAccountMap(ids); + const net = new Map(); + for (const line of lines) { + net.set(line.glAccountId, (net.get(line.glAccountId) ?? 0) + (line.entryType === 'DEBIT' ? line.amount : -line.amount)); + } + const out = {}; + for (const [id, val] of net.entries()) { + if (Math.abs(val) < 0.005) + continue; + const code = glById.get(id) ?? String(id); + out[code] = Math.abs(val).toFixed(2); + } + return out; +} async function postJournalEntry(entry) { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); - const { data } = await axios_1.default.post(`${base}/journalentries`, entry, { + const { data } = await axios_1.default.post(`${fineractBase()}/journalentries`, entry, { headers: authHeaders(), timeout: 60000, }); return { resourceId: data.resourceId ?? data.entityId ?? 0 }; } async function fetchGlBalances(officeId) { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const base = fineractBase(); + if (!base || !process.env.OMNL_FINERACT_PASSWORD) + return {}; try { const { data } = await axios_1.default.get(`${base}/runreports/General Ledger Report`, { headers: authHeaders(), @@ -44,7 +164,14 @@ async function fetchGlBalances(officeId) { if (row.glCode) out[row.glCode] = String(row.balance ?? 0); } - return out; + if (Object.keys(out).length > 0) + return out; + } + catch { + /* report unavailable on this Fineract — fall through */ + } + try { + return await fetchGlBalancesFromJournals(officeId); } catch { return {}; diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index cb4a656..949e5be 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -63,11 +63,16 @@ function createSettlementRouter() { return; } try { - const balances = await (0, fineract_1.fetchGlBalances)(officeId); + const [balances, fineractConnected] = await Promise.all([ + (0, fineract_1.fetchGlBalances)(officeId), + (0, fineract_1.fineractOfficeReachable)(officeId), + ]); const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); + const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0); res.json({ ...snapshot, - fineractLive: Object.keys(balances).length > 0, + fineractConnected, + fineractLive: fineractConnected && hasActivity, interoffice: { dueFromHeadOffice: balances['1410'] ?? '0', gl1410: balances['1410'] ?? '0', diff --git a/services/settlement-middleware/src/adapters/fineract.ts b/services/settlement-middleware/src/adapters/fineract.ts index 75c55bb..e3847e0 100644 --- a/services/settlement-middleware/src/adapters/fineract.ts +++ b/services/settlement-middleware/src/adapters/fineract.ts @@ -1,22 +1,144 @@ import axios from 'axios'; +const GL_HINTS = ['1000', '1050', '1410', '2000', '2100', '2200', '2300']; + +function fineractBase(): string { + return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); +} + function authHeaders(): Record { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; const user = process.env.OMNL_FINERACT_USER?.trim() || process.env.OMNL_FINERACT_USERNAME?.trim() || 'ali_hospitallers_tenant'; const pass = process.env.OMNL_FINERACT_PASSWORD || ''; - if (!base || !pass) throw new Error('Fineract credentials required'); + if (!fineractBase() || !pass) throw new Error('Fineract credentials required'); const auth = Buffer.from(`${user}:${pass}`).toString('base64'); return { Authorization: `Basic ${auth}`, 'Fineract-Platform-TenantId': tenant, 'Content-Type': 'application/json', + Accept: 'application/json', }; } +export async function fineractOfficeReachable(officeId: number): Promise { + try { + const { status } = await axios.get(`${fineractBase()}/offices/${officeId}`, { + headers: authHeaders(), + timeout: 20000, + }); + return status === 200; + } catch { + return false; + } +} + +async function fetchOfficeJournalLines(officeId: number) { + const headers = authHeaders(); + const out: Array<{ glAccountId: number; amount: number; entryType: 'DEBIT' | 'CREDIT' }> = []; + let offset = 0; + const limit = 200; + for (;;) { + const { data } = await axios.get(`${fineractBase()}/journalentries`, { + headers, + timeout: 60000, + params: { officeId, limit, offset }, + }); + const batch = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const je of batch) { + const row = je as { + glAccountId?: number; + amount?: number; + entryType?: string | { value?: string; code?: string }; + debits?: Array<{ glAccountId?: number; amount?: number }>; + credits?: Array<{ glAccountId?: number; amount?: number }>; + }; + const flat = + typeof row.entryType === 'string' + ? row.entryType + : row.entryType?.value || row.entryType?.code || ''; + if (row.glAccountId != null && row.amount != null && flat) { + const normalized = flat.toUpperCase(); + out.push({ + glAccountId: row.glAccountId, + amount: Number(row.amount || 0), + entryType: normalized.startsWith('DEB') ? 'DEBIT' : 'CREDIT', + }); + continue; + } + for (const d of row.debits ?? []) { + if (d.glAccountId != null) { + out.push({ glAccountId: d.glAccountId, amount: Number(d.amount || 0), entryType: 'DEBIT' }); + } + } + for (const c of row.credits ?? []) { + if (c.glAccountId != null) { + out.push({ glAccountId: c.glAccountId, amount: Number(c.amount || 0), entryType: 'CREDIT' }); + } + } + } + if (batch.length < limit) break; + offset += limit; + } + return out; +} + +async function fetchGlAccountMap(ids: Set): Promise> { + const headers = authHeaders(); + const map = new Map(); + for (const code of GL_HINTS) { + const { data } = await axios.get(`${fineractBase()}/glaccounts`, { + headers, + timeout: 30000, + params: { glCode: code }, + }); + const rows = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + for (const row of rows as { id?: number; glCode?: string }[]) { + if (row.id != null && row.glCode) map.set(row.id, String(row.glCode)); + } + } + if ([...ids].some((id) => !map.has(id))) { + let offset = 0; + const limit = 200; + for (;;) { + const { data } = await axios.get(`${fineractBase()}/glaccounts`, { + headers, + timeout: 30000, + params: { limit, offset }, + }); + const rows = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + if (!rows.length) break; + for (const row of rows as { id?: number; glCode?: string }[]) { + if (row.id != null && row.glCode && ids.has(row.id)) map.set(row.id, String(row.glCode)); + } + if (rows.length < limit) break; + offset += limit; + } + } + return map; +} + +async function fetchGlBalancesFromJournals(officeId: number): Promise> { + const lines = await fetchOfficeJournalLines(officeId); + if (!lines.length) return {}; + const ids = new Set(lines.map((l) => l.glAccountId)); + const glById = await fetchGlAccountMap(ids); + const net = new Map(); + for (const line of lines) { + net.set(line.glAccountId, (net.get(line.glAccountId) ?? 0) + (line.entryType === 'DEBIT' ? line.amount : -line.amount)); + } + const out: Record = {}; + for (const [id, val] of net.entries()) { + if (Math.abs(val) < 0.005) continue; + const code = glById.get(id) ?? String(id); + out[code] = Math.abs(val).toFixed(2); + } + return out; +} + export async function postJournalEntry(entry: { officeId: number; transactionDate: string; @@ -25,8 +147,7 @@ export async function postJournalEntry(entry: { debits: { glAccountId: number; amount: number }[]; credits: { glAccountId: number; amount: number }[]; }): Promise<{ resourceId: number }> { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); - const { data } = await axios.post(`${base}/journalentries`, entry, { + const { data } = await axios.post(`${fineractBase()}/journalentries`, entry, { headers: authHeaders(), timeout: 60000, }); @@ -34,7 +155,8 @@ export async function postJournalEntry(entry: { } export async function fetchGlBalances(officeId: number): Promise> { - const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const base = fineractBase(); + if (!base || !process.env.OMNL_FINERACT_PASSWORD) return {}; try { const { data } = await axios.get(`${base}/runreports/General Ledger Report`, { headers: authHeaders(), @@ -46,7 +168,12 @@ export async function fetchGlBalances(officeId: number): Promise 0) return out; + } catch { + /* report unavailable on this Fineract — fall through */ + } + try { + return await fetchGlBalancesFromJournals(officeId); } catch { return {}; } diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 336352e..da4983f 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -12,7 +12,7 @@ import { processTransfer } from '../../workflows/transfer'; import { bindOffice24 } from '../../workflows/office-scope'; import { settlementStore } from '../../store/settlement-store'; import { processSwiftInbound } from '../../workflows/swift-inbound'; -import { fetchGlBalances } from '../../adapters/fineract'; +import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract'; function requireApiKey(req: Request, res: Response): boolean { const cfg = loadConfig(); @@ -68,11 +68,16 @@ export function createSettlementRouter(): Router { return; } try { - const balances = await fetchGlBalances(officeId); + const [balances, fineractConnected] = await Promise.all([ + fetchGlBalances(officeId), + fineractOfficeReachable(officeId), + ]); const snapshot = buildMoneySupplySnapshot(officeId, balances); + const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0); res.json({ ...snapshot, - fineractLive: Object.keys(balances).length > 0, + fineractConnected, + fineractLive: fineractConnected && hasActivity, interoffice: { dueFromHeadOffice: balances['1410'] ?? '0', gl1410: balances['1410'] ?? '0',