/**
* Funds module — budget flows, river visualization, and treasury management.
*
* Proxies flow-service API calls and serves the BudgetRiver visualization.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Funds] DB schema initialized");
} catch (e) {
console.error("[Funds] DB init error:", e);
}
}
initDB();
const routes = new Hono();
// ─── Flow Service API proxy ─────────────────────────────
// These proxy to the payment-flow backend so the frontend
// can call them from the same origin.
routes.get("/api/flows", async (c) => {
const owner = c.req.header("X-Owner-Address") || "";
const space = c.req.query("space") || "";
// If space filter provided, get flow IDs from space_flows table
if (space) {
try {
const rows = await sql.unsafe(
"SELECT flow_id FROM rfunds.space_flows WHERE space_slug = $1",
[space],
);
if (rows.length === 0) return c.json([]);
// Fetch each flow from flow-service
const flows = await Promise.all(
rows.map(async (r: any) => {
try {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${r.flow_id}`);
if (res.ok) return await res.json();
} catch {}
return null;
}),
);
return c.json(flows.filter(Boolean));
} catch {
// Fall through to unfiltered fetch
}
}
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`);
return c.json(await res.json(), res.status as any);
});
routes.get("/api/flows/:flowId", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`);
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows", async (c) => {
// Auth-gated: require EncryptID token
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Owner-Address": claims.sub,
},
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/deposit", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/withdraw", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/activate", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" });
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/pause", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" });
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/funnels", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/outcomes", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.get("/api/flows/:flowId/transactions", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`);
return c.json(await res.json(), res.status as any);
});
// ─── Space-flow association endpoints ────────────────────
routes.post("/api/space-flows", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const { space, flowId } = await c.req.json();
if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400);
await sql.unsafe(
`INSERT INTO rfunds.space_flows (space_slug, flow_id, added_by)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[space, flowId, claims.sub],
);
return c.json({ ok: true });
});
routes.delete("/api/space-flows/:flowId", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const flowId = c.req.param("flowId");
const space = c.req.query("space") || "";
if (!space) return c.json({ error: "space query param required" }, 400);
await sql.unsafe(
"DELETE FROM rfunds.space_flows WHERE space_slug = $1 AND flow_id = $2",
[space, flowId],
);
return c.json({ ok: true });
});
// ─── Page routes ────────────────────────────────────────
const fundsScripts = `
`;
const fundsStyles = ``;
// Landing page — TBFF info + flow list
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `rFunds — TBFF Flow Funding | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: fundsStyles,
body: ``,
scripts: fundsScripts,
}));
});
// Demo mode — hardcoded demo data, no API needed
routes.get("/demo", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `TBFF Demo — rFunds | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: fundsStyles,
body: ``,
scripts: fundsScripts,
}));
});
// Flow detail — specific flow from API
routes.get("/flow/:flowId", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const flowId = c.req.param("flowId");
return c.html(renderShell({
title: `Flow — rFunds | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: fundsStyles,
body: ``,
scripts: fundsScripts,
}));
});
export const fundsModule: RSpaceModule = {
id: "funds",
name: "rFunds",
icon: "\uD83C\uDF0A",
description: "Budget flows, river visualization, and treasury management",
routes,
standaloneDomain: "rfunds.online",
};