feat: wire live Fineract M1 to Central Bank UI and Office 24 journal seed
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m21s
CI/CD Pipeline / Security Scanning (push) Successful in 2m42s
CI/CD Pipeline / Terraform Validation (push) Has been cancelled
CI/CD Pipeline / Kubernetes Validation (push) Has been cancelled
CI/CD Pipeline / Lint and Format (push) Has been cancelled
Deploy ChainID 138 / Deploy ChainID 138 (push) Has been cancelled
Validation / validate-genesis (push) Has been cancelled
Validation / validate-terraform (push) Has been cancelled
Validation / validate-kubernetes (push) Has been cancelled
Validation / validate-smart-contracts (push) Has been cancelled
Validation / validate-security (push) Has been cancelled
Validation / validate-documentation (push) Has been cancelled
Verify Deployment / Verify Deployment (push) Has been cancelled

Add a read-only money-supply endpoint for Office 24 dashboards, pass Fineract env through deploy scripts, and provide a seed script for the opening Dr 1410 / Cr 2100 journal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 15:41:23 -07:00
parent 37e280f372
commit 1fe57901e5
12 changed files with 237 additions and 25 deletions

View File

@@ -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=

View File

@@ -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"
}

View File

@@ -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() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
label="M0 Reserve (GL 1050)"
value={cb.moneySupply?.M0?.gl1050 ?? '—'}
value={formatUsd(cb.moneySupply?.M0?.gl1050)}
sub={`Backing ${cb.moneySupply?.M0?.backingRatio ?? 1.2}×`}
accent="blue"
/>
<StatCard
label="M1 Circulating (GL 2100)"
value={cb.moneySupply?.M1?.circulating ?? '—'}
value={formatUsd(cb.moneySupply?.M1?.circulating)}
sub="Narrow money · eMoney"
accent="green"
/>
<StatCard
label="M2 Broad (GL 2200)"
value={cb.moneySupply?.M2?.broadMoney ?? '—'}
value={formatUsd(cb.moneySupply?.M2?.broadMoney)}
sub="Token load source"
accent="gold"
/>
@@ -82,10 +83,27 @@ export default function CentralBankDashboard() {
/>
</div>
{cb.moneySupply?.fineractLive === false && (
<p className="text-xs text-[#f6465d] mb-6">
Fineract GL balances unavailable check OMNL_FINERACT_* on settlement middleware and seed
Office 24 opening journal (Dr 1410 / Cr 2100).
</p>
)}
{cb.moneySupply?.asOf && (
<p className="text-xs text-[#848e9c] mb-6">
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)}`
: ''}
</p>
)}
{!cb.moneySupply && (
<p className="text-xs text-[#848e9c] mb-6">
Set <code className="text-[#f0b90b]">VITE_OMNL_API_KEY</code> in .env.local to load live M0/M1/M2
balances from Fineract.
Money supply loads from{' '}
<code className="text-[#f0b90b]">/money-supply/public</code> when online bank mode is enabled.
</p>
)}

View File

@@ -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<MoneySupply | null> {
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<Record<string, unknown> | null>(null);
const [office, setOffice] = useState<Record<string, unknown> | 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 {

View File

@@ -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() {
</Link>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">Office ID</p>
<p className="text-2xl font-bold text-[#0ecb81]">{profile?.officeId ?? 24}</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">Default currency</p>
<p className="text-2xl font-bold text-[#eaecef]">{profile?.settlement?.defaultCurrency ?? 'USD'}</p>
<p className="text-xs text-[#848e9c] uppercase">M1 circulating (GL 2100)</p>
<p className="text-2xl font-bold text-[#eaecef]">
{formatUsd(moneySupply?.M1?.circulating)}
</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">Due from HO (GL 1410)</p>
<p className="text-2xl font-bold text-[#3861fb]">
{formatUsd(moneySupply?.interoffice?.dueFromHeadOffice)}
</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">M2 tradable tokens</p>
@@ -49,6 +58,12 @@ export default function Office24Dashboard() {
</div>
</div>
{moneySupply?.fineractLive === false && (
<p className="text-sm text-[#f6465d] mb-6">
No Fineract journals for Office 24 yet. Post opening M1: Dr 1410 / Cr 2100 via{' '}
<code className="text-[#f0b90b]">scripts/deployment/seed-office-24-opening-journal.mjs</code>
</p>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<section className="o24-card lg:col-span-1">
<h2 className="font-semibold text-[#eaecef] mb-4">Settlement rails</h2>

View File

@@ -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<Record<string, unknown> | null>(null);
const [tokens, setTokens] = useState<M2Token[]>([]);
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
const [moneySupply, setMoneySupply] = useState<MoneySupply | null>(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 };
}

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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);

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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 {