diff --git a/config/deployment-omnl.production.env.example b/config/deployment-omnl.production.env.example index abd7763..75cbf32 100644 --- a/config/deployment-omnl.production.env.example +++ b/config/deployment-omnl.production.env.example @@ -38,10 +38,12 @@ CHAIN_8453_RPC_URL=https://mainnet.base.org CHAIN_10_RPC_URL=https://mainnet.optimism.io # === Fineract / Office 24 === -FINERACT_BASE_URL= -FINERACT_TENANT=omnl -OMNL_FINERACT_USERNAME= +OMNL_FINERACT_BASE_URL= +OMNL_FINERACT_TENANT=omnl +OMNL_FINERACT_USERNAME=ali_hospitallers_tenant OMNL_FINERACT_PASSWORD= +OMNL_PUBLIC_MONEY_SUPPLY=1 +OFFICE24_OPENING_M1_USD= # === Frontend === VITE_WALLETCONNECT_PROJECT_ID= diff --git a/config/offices/office-24-opening-journal.v1.json b/config/offices/office-24-opening-journal.v1.json new file mode 100644 index 0000000..6204f9a --- /dev/null +++ b/config/offices/office-24-opening-journal.v1.json @@ -0,0 +1,14 @@ +{ + "$schema": "Office 24 opening M1 allocation from Head Office (Fineract journal)", + "version": "1.0.0", + "officeId": 24, + "externalId": "HOSPITALLERS-ALI-IRAQ-IRAN", + "referenceNumber": "HOSPITALLERS-24-OPENING-M1", + "comments": "M1 allocation from Head Office — Dr 1410 Due From HO / Cr 2100 M1 Central Liabilities", + "debitGlCode": "1410", + "creditGlCode": "2100", + "currency": "USD", + "ipsasRef": "IPSAS 28", + "amountEnv": "OFFICE24_OPENING_M1_USD", + "notes": "Set OFFICE24_OPENING_M1_USD in .env before running scripts/deployment/seed-office-24-opening-journal.mjs" +} diff --git a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx index 423fac5..62f7e90 100644 --- a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx +++ b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx @@ -1,6 +1,7 @@ import { useCentralBank } from './useCentralBank'; import ChainMatrixPanel from './ChainMatrixPanel'; import { SETTLEMENT_MIDDLEWARE_URL, DBIS_EXCHANGE_URL } from '../../config/dex'; +import { formatUsd } from '../../utils/formatMoney'; function StatCard({ label, @@ -58,19 +59,19 @@ export default function CentralBankDashboard() {
@@ -82,10 +83,27 @@ export default function CentralBankDashboard() { />
+ {cb.moneySupply?.fineractLive === false && ( +

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

+ )} + + {cb.moneySupply?.asOf && ( +

+ Live Fineract · Office {cb.moneySupply.officeId ?? 24} · as of{' '} + {cb.moneySupply.asOf.slice(0, 19).replace('T', ' ')} UTC + {cb.moneySupply.interoffice?.dueFromHeadOffice + ? ` · Due from HO (GL 1410): ${formatUsd(cb.moneySupply.interoffice.dueFromHeadOffice)}` + : ''} +

+ )} + {!cb.moneySupply && (

- Set VITE_OMNL_API_KEY in .env.local to load live M0/M1/M2 - balances from Fineract. + Money supply loads from{' '} + /money-supply/public when online bank mode is enabled.

)} diff --git a/frontend-dapp/src/features/central-bank/useCentralBank.ts b/frontend-dapp/src/features/central-bank/useCentralBank.ts index 3ec084d..5e481bd 100644 --- a/frontend-dapp/src/features/central-bank/useCentralBank.ts +++ b/frontend-dapp/src/features/central-bank/useCentralBank.ts @@ -4,11 +4,26 @@ import { DBIS_EXCHANGE_URL, SETTLEMENT_MIDDLEWARE_URL } from '../../config/dex'; export type MoneySupply = { asOf?: string; officeId?: number; + fineractLive?: boolean; + interoffice?: { dueFromHeadOffice?: string; gl1410?: string }; M0?: { gl1050?: string; backingRatio?: number }; M1?: { circulating?: string; gl2100?: string }; M2?: { broadMoney?: string; gl2200?: string; metaFiatPending?: string }; }; +async function loadMoneySupply(settlement: string): Promise { + const publicRes = await fetch(`${settlement}/api/v1/settlement/money-supply/public`); + if (publicRes.ok) return publicRes.json(); + + const apiKey = import.meta.env.VITE_OMNL_API_KEY; + if (!apiKey) return null; + + const authRes = await fetch(`${settlement}/api/v1/settlement/money-supply`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + return authRes.ok ? authRes.json() : null; +} + export function useCentralBank() { const [health, setHealth] = useState | null>(null); const [office, setOffice] = useState | null>(null); @@ -25,24 +40,18 @@ export function useCentralBank() { setLoading(true); setError(null); try { - const [h, o, m2, mon] = await Promise.all([ + const [h, o, m2, mon, ms] = await Promise.all([ fetch(`${settlement}/api/v1/settlement/health`).then((r) => r.json()), fetch(`${settlement}/api/v1/settlement/office`).then((r) => r.json()), fetch(`${settlement}/api/v1/settlement/tokens/m2`).then((r) => r.json()), fetch(`${exchange}/api/v1/exchange/swap/monitor?limit=10`).then((r) => r.json()), + loadMoneySupply(settlement), ]); setHealth(h); setOffice(o); setTokenCount(Array.isArray(m2.tokens) ? m2.tokens.length : 0); setMonitor(mon); - - const apiKey = import.meta.env.VITE_OMNL_API_KEY; - if (apiKey) { - const ms = await fetch(`${settlement}/api/v1/settlement/money-supply`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - if (ms.ok) setMoneySupply(await ms.json()); - } + setMoneySupply(ms); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load central bank data'); } finally { diff --git a/frontend-dapp/src/features/office24/Office24Dashboard.tsx b/frontend-dapp/src/features/office24/Office24Dashboard.tsx index 1daea32..f3b3436 100644 --- a/frontend-dapp/src/features/office24/Office24Dashboard.tsx +++ b/frontend-dapp/src/features/office24/Office24Dashboard.tsx @@ -1,8 +1,9 @@ import { Link } from 'react-router-dom'; import { useOffice24 } from './useOffice24'; +import { formatUsd } from '../../utils/formatMoney'; export default function Office24Dashboard() { - const { office, tokens, health, loading, refresh } = useOffice24(); + const { office, tokens, health, moneySupply, loading, refresh } = useOffice24(); const profile = office as { officeId?: number; externalId?: string; @@ -34,14 +35,22 @@ export default function Office24Dashboard() { -
+

Office ID

{profile?.officeId ?? 24}

-

Default currency

-

{profile?.settlement?.defaultCurrency ?? 'USD'}

+

M1 circulating (GL 2100)

+

+ {formatUsd(moneySupply?.M1?.circulating)} +

+
+
+

Due from HO (GL 1410)

+

+ {formatUsd(moneySupply?.interoffice?.dueFromHeadOffice)} +

M2 tradable tokens

@@ -49,6 +58,12 @@ export default function Office24Dashboard() {
+ {moneySupply?.fineractLive === false && ( +

+ No Fineract journals for Office 24 yet. Post opening M1: Dr 1410 / Cr 2100 via{' '} + scripts/deployment/seed-office-24-opening-journal.mjs +

+ )}

Settlement rails

diff --git a/frontend-dapp/src/features/office24/useOffice24.ts b/frontend-dapp/src/features/office24/useOffice24.ts index 2011711..b99ee72 100644 --- a/frontend-dapp/src/features/office24/useOffice24.ts +++ b/frontend-dapp/src/features/office24/useOffice24.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { DBIS_EXCHANGE_URL, SETTLEMENT_MIDDLEWARE_URL, type DexToken } from '../../config/dex'; +import type { MoneySupply } from '../central-bank/useCentralBank'; export type M2Token = DexToken & { moneyLayer?: string; @@ -13,6 +14,7 @@ export function useOffice24() { const [office, setOffice] = useState | null>(null); const [tokens, setTokens] = useState([]); const [health, setHealth] = useState | null>(null); + const [moneySupply, setMoneySupply] = useState(null); const [loading, setLoading] = useState(true); const settlement = SETTLEMENT_MIDDLEWARE_URL.replace(/\/$/, ''); @@ -21,14 +23,18 @@ export function useOffice24() { const refresh = useCallback(async () => { setLoading(true); try { - const [o, t, h] = await Promise.all([ + const [o, t, h, ms] = await Promise.all([ fetch(`${settlement}/api/v1/settlement/office`).then((r) => r.json()), fetch(`${settlement}/api/v1/settlement/tokens/m2`).then((r) => r.json()), fetch(`${exchange}/api/v1/exchange/health`).then((r) => r.json()), + fetch(`${settlement}/api/v1/settlement/money-supply/public`).then((r) => + r.ok ? r.json() : null, + ), ]); setOffice(o); setTokens(Array.isArray(t.tokens) ? t.tokens : []); setHealth(h); + setMoneySupply(ms); } finally { setLoading(false); } @@ -38,5 +44,5 @@ export function useOffice24() { refresh(); }, [refresh]); - return { office, tokens, health, loading, refresh }; + return { office, tokens, health, moneySupply, loading, refresh }; } diff --git a/frontend-dapp/src/utils/formatMoney.ts b/frontend-dapp/src/utils/formatMoney.ts new file mode 100644 index 0000000..748133e --- /dev/null +++ b/frontend-dapp/src/utils/formatMoney.ts @@ -0,0 +1,11 @@ +/** Format Fineract USD balance strings for dashboard display */ +export function formatUsd(value: string | number | undefined | null): string { + if (value === undefined || value === null || value === '') return '—'; + const n = typeof value === 'number' ? value : parseFloat(String(value)); + if (!Number.isFinite(n)) return '—'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }).format(n); +} diff --git a/scripts/deployment/deploy-omnl-bank-production.sh b/scripts/deployment/deploy-omnl-bank-production.sh index ad1bf4f..8bd9c13 100644 --- a/scripts/deployment/deploy-omnl-bank-production.sh +++ b/scripts/deployment/deploy-omnl-bank-production.sh @@ -73,6 +73,12 @@ start_svc() { DBIS_EXCHANGE_CONFIG="$DBIS_EXCHANGE_CONFIG" \ OMNL_SUPPORTED_CHAINS_CONFIG="$OMNL_SUPPORTED_CHAINS_CONFIG" \ OMNL_M2_TOKEN_REGISTRY="$OMNL_M2_TOKEN_REGISTRY" \ + OMNL_API_KEY="${OMNL_API_KEY:-}" \ + OMNL_FINERACT_BASE_URL="${OMNL_FINERACT_BASE_URL:-${FINERACT_BASE_URL:-}}" \ + OMNL_FINERACT_TENANT="${OMNL_FINERACT_TENANT:-${FINERACT_TENANT:-omnl}}" \ + OMNL_FINERACT_USERNAME="${OMNL_FINERACT_USERNAME:-}" \ + OMNL_FINERACT_PASSWORD="${OMNL_FINERACT_PASSWORD:-}" \ + OMNL_PUBLIC_MONEY_SUPPLY="${OMNL_PUBLIC_MONEY_SUPPLY:-1}" \ bash -c "$cmd" >"$LOG_DIR/$name.log" 2>&1 & echo $! >"$LOG_DIR/$name.pid" } @@ -94,6 +100,8 @@ sleep 5 log "Health checks..." curl -sf "http://127.0.0.1:3011/api/v1/settlement/health" | head -c 200 || true echo +curl -sf "http://127.0.0.1:3011/api/v1/settlement/money-supply/public" | head -c 300 || true +echo curl -sf "http://127.0.0.1:3012/api/v1/exchange/health" | head -c 200 || true echo curl -sf -o /dev/null -w "frontend=%{http_code}\n" "http://127.0.0.1:$FRONTEND_PORT/central-bank" diff --git a/scripts/deployment/seed-office-24-opening-journal.mjs b/scripts/deployment/seed-office-24-opening-journal.mjs new file mode 100644 index 0000000..cd368f3 --- /dev/null +++ b/scripts/deployment/seed-office-24-opening-journal.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Post Office 24 opening M1 journal to Fineract: + * Dr 1410 Due From Head Office + * Cr 2100 M1 Central Liabilities + * + * Requires: OMNL_FINERACT_BASE_URL, OMNL_FINERACT_PASSWORD + * Amount: OFFICE24_OPENING_M1_USD (required unless --dry-run) + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../..'); +const configPath = path.join(repoRoot, 'config/offices/office-24-opening-journal.v1.json'); + +const dryRun = process.argv.includes('--dry-run'); +const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +const base = (process.env.OMNL_FINERACT_BASE_URL || process.env.FINERACT_BASE_URL || '').replace(/\/$/, ''); +const tenant = process.env.OMNL_FINERACT_TENANT || process.env.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 || ''; +const amountRaw = process.env[cfg.amountEnv] || process.env.OFFICE24_OPENING_M1_USD || ''; +const amount = parseFloat(amountRaw); + +if (!base || !pass) { + console.error('Set OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD'); + process.exit(1); +} +if (!Number.isFinite(amount) || amount <= 0) { + console.error(`Set ${cfg.amountEnv} to a positive USD amount (e.g. export OFFICE24_OPENING_M1_USD=1000000)`); + process.exit(1); +} + +const entry = { + officeId: cfg.officeId, + transactionDate: new Date().toISOString().slice(0, 10), + referenceNumber: cfg.referenceNumber, + comments: cfg.comments, + debits: [{ glAccountId: parseInt(cfg.debitGlCode, 10), amount }], + credits: [{ glAccountId: parseInt(cfg.creditGlCode, 10), amount }], +}; + +console.log(JSON.stringify({ dryRun, officeId: cfg.officeId, amount, entry }, null, 2)); + +if (dryRun) { + console.log('Dry run — no journal posted.'); + process.exit(0); +} + +const auth = Buffer.from(`${user}:${pass}`).toString('base64'); +const res = await fetch(`${base}/journalentries`, { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(entry), +}); + +const body = await res.text(); +if (!res.ok) { + console.error(`Fineract POST failed (${res.status}): ${body}`); + process.exit(1); +} + +console.log('Office 24 opening journal posted:', body); diff --git a/scripts/deployment/start-omnl-banking-portal.sh b/scripts/deployment/start-omnl-banking-portal.sh index fb0db45..9dd318d 100644 --- a/scripts/deployment/start-omnl-banking-portal.sh +++ b/scripts/deployment/start-omnl-banking-portal.sh @@ -20,6 +20,12 @@ start() { DBIS_EXCHANGE_CONFIG="$DBIS_EXCHANGE_CONFIG" \ OMNL_SUPPORTED_CHAINS_CONFIG="$OMNL_SUPPORTED_CHAINS_CONFIG" \ OMNL_M2_TOKEN_REGISTRY="$OMNL_M2_TOKEN_REGISTRY" \ + OMNL_API_KEY="${OMNL_API_KEY:-}" \ + OMNL_FINERACT_BASE_URL="${OMNL_FINERACT_BASE_URL:-}" \ + OMNL_FINERACT_TENANT="${OMNL_FINERACT_TENANT:-omnl}" \ + OMNL_FINERACT_USERNAME="${OMNL_FINERACT_USERNAME:-}" \ + OMNL_FINERACT_PASSWORD="${OMNL_FINERACT_PASSWORD:-}" \ + OMNL_PUBLIC_MONEY_SUPPLY="${OMNL_PUBLIC_MONEY_SUPPLY:-1}" \ bash -c "$cmd" >"${LOG_DIR}/${name}.log" 2>&1 & echo $! >"${LOG_DIR}/${name}.pid" } diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index afae8be..cb4a656 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -10,6 +10,7 @@ const transfer_1 = require("../../workflows/transfer"); const office_scope_1 = require("../../workflows/office-scope"); const settlement_store_1 = require("../../store/settlement-store"); const swift_inbound_1 = require("../../workflows/swift-inbound"); +const fineract_1 = require("../../adapters/fineract"); function requireApiKey(req, res) { const cfg = (0, config_1.loadConfig)(); if (!cfg.production.requireApiKey) @@ -54,6 +55,29 @@ function createSettlementRouter() { storePath: `data/settlement-store/office-${officeId}`, }); }); + router.get('/money-supply/public', async (_req, res) => { + const cfg = (0, config_1.loadConfig)(); + const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; + if (!allow) { + res.status(404).json({ error: 'Public money supply disabled' }); + return; + } + try { + const balances = await (0, fineract_1.fetchGlBalances)(officeId); + const snapshot = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances); + res.json({ + ...snapshot, + fineractLive: Object.keys(balances).length > 0, + interoffice: { + dueFromHeadOffice: balances['1410'] ?? '0', + gl1410: balances['1410'] ?? '0', + }, + }); + } + catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); router.get('/money-supply', async (req, res) => { if (!requireApiKey(req, res)) return; diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 3937ab2..336352e 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest } from '@dbis/settlement-core'; -import { loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core'; +import { buildMoneySupplySnapshot, loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core'; import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../../config'; import { getMoneySupply, @@ -8,9 +8,11 @@ import { processExternalTransfer, } from '../../workflows/external-transfer'; import { processTokenLoad } from '../../workflows/token-load'; -import { processTransfer } from '../../workflows/transfer';import { bindOffice24 } from '../../workflows/office-scope'; +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'; function requireApiKey(req: Request, res: Response): boolean { const cfg = loadConfig(); @@ -57,6 +59,30 @@ export function createSettlementRouter(): Router { }); }); + router.get('/money-supply/public', async (_req, res) => { + const cfg = loadConfig(); + const allow = + cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; + if (!allow) { + res.status(404).json({ error: 'Public money supply disabled' }); + return; + } + try { + const balances = await fetchGlBalances(officeId); + const snapshot = buildMoneySupplySnapshot(officeId, balances); + res.json({ + ...snapshot, + fineractLive: Object.keys(balances).length > 0, + interoffice: { + dueFromHeadOffice: balances['1410'] ?? '0', + gl1410: balances['1410'] ?? '0', + }, + }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + router.get('/money-supply', async (req, res) => { if (!requireApiKey(req, res)) return; try {