/** * Flow Service API Client for rFunds.online * * Connects the visual TBFF editor to the flow-service backend. * Handles type conversion between frontend visual nodes and backend flow types. */ import type { FlowNode, FunnelNodeData, OutcomeNodeData } from '../types' // ─── Backend API Types ────────────────────────────────────── export interface BackendFlow { id: string name: string description?: string funnels: BackendFunnel[] outcomes: BackendOutcome[] token: string chainId: number ownerAddress: string adminAddresses: string[] automationEnabled: boolean checkIntervalMs: number status: 'draft' | 'active' | 'paused' | 'archived' createdAt: number updatedAt: number } export interface BackendFunnel { id: string flowId: string name: string description?: string minThreshold: string maxThreshold: string balance: string walletAddress: string overflowAllocations: BackendAllocation[] spendingAllocations: BackendAllocation[] inflowRate?: string spendingRate?: string createdAt: number updatedAt: number } export interface BackendOutcome { id: string flowId: string name: string description?: string targetAmount: string currentAmount: string walletAddress: string ownerAddress: string status: 'active' | 'funded' | 'completed' | 'cancelled' createdAt: number updatedAt: number } export interface BackendAllocation { targetId: string targetType: 'funnel' | 'outcome' | 'wallet' targetAddress: string percentage: number label: string } export interface BackendTransaction { id: string flowId: string type: 'deposit' | 'overflow' | 'spending' | 'withdrawal' amount: string status: 'pending' | 'committed' | 'settled' | 'failed' triggeredBy: 'user' | 'threshold' | 'schedule' createdAt: number } interface ApiResponse { success: boolean error?: string flow?: T flows?: T[] funnel?: BackendFunnel outcome?: BackendOutcome transaction?: BackendTransaction transactions?: BackendTransaction[] session?: { sessionId: string; widgetUrl?: string } } // ─── Configuration ────────────────────────────────────────── const USDC_DECIMALS = 6 const DEFAULT_TOKEN = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // USDC on Base const DEFAULT_CHAIN_ID = 84532 // Base Sepolia // Use relative URL — Traefik routes rfunds.online/api/flows to flow-service const API_BASE = '/api/flows' // ─── Unit Conversion ──────────────────────────────────────── /** Convert human-readable amount (e.g., 1000) to smallest unit string (e.g., "1000000000") */ export function toSmallestUnit(amount: number, decimals = USDC_DECIMALS): string { return Math.round(amount * Math.pow(10, decimals)).toString() } /** Convert smallest unit string (e.g., "1000000000") to human-readable number (e.g., 1000) */ export function fromSmallestUnit(amount: string, decimals = USDC_DECIMALS): number { return parseInt(amount, 10) / Math.pow(10, decimals) } // ─── API Helpers ──────────────────────────────────────────── async function api( path: string, options?: RequestInit & { ownerAddress?: string } ): Promise> { const { ownerAddress, ...fetchOpts } = options || {} const headers: Record = { 'Content-Type': 'application/json', ...(ownerAddress ? { 'X-Owner-Address': ownerAddress } : {}), ...((fetchOpts.headers as Record) || {}), } const res = await fetch(`${API_BASE}${path}`, { ...fetchOpts, headers, }) if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })) throw new Error(body.error || `API error ${res.status}`) } return res.json() } // ─── CRUD Operations ──────────────────────────────────────── export async function listFlows(ownerAddress: string): Promise { const res = await api('', { ownerAddress }) return (res.flows || []) as BackendFlow[] } export async function getFlow(flowId: string): Promise { const res = await api(`/${flowId}`) return res.flow as BackendFlow } export async function createFlow( name: string, ownerAddress: string, description?: string ): Promise { const res = await api('', { method: 'POST', ownerAddress, body: JSON.stringify({ name, description, token: DEFAULT_TOKEN, chainId: DEFAULT_CHAIN_ID, automationEnabled: true, checkIntervalMs: 30000, }), }) return res.flow as BackendFlow } export async function activateFlow(flowId: string): Promise { const res = await api(`/${flowId}/activate`, { method: 'POST' }) return res.flow as BackendFlow } export async function pauseFlow(flowId: string): Promise { const res = await api(`/${flowId}/pause`, { method: 'POST' }) return res.flow as BackendFlow } // ─── Funnel & Outcome Operations ──────────────────────────── export async function addFunnel( flowId: string, name: string, minThreshold: number, maxThreshold: number, overflowAllocations: BackendAllocation[], spendingAllocations: BackendAllocation[], description?: string ): Promise { const res = await api(`/${flowId}/funnels`, { method: 'POST', body: JSON.stringify({ name, description, minThreshold: toSmallestUnit(minThreshold), maxThreshold: toSmallestUnit(maxThreshold), overflowAllocations, spendingAllocations, }), }) return res.funnel as BackendFunnel } export async function addOutcome( flowId: string, name: string, targetAmount: number, ownerAddress: string, description?: string ): Promise { const res = await api(`/${flowId}/outcomes`, { method: 'POST', body: JSON.stringify({ name, description, targetAmount: toSmallestUnit(targetAmount), ownerAddress, }), }) return res.outcome as BackendOutcome } // ─── Deposit / Withdraw ───────────────────────────────────── export async function deposit( flowId: string, funnelId: string, amount: number, source: 'wallet' | 'card' | 'bank' = 'wallet' ): Promise { const res = await api(`/${flowId}/deposit`, { method: 'POST', body: JSON.stringify({ funnelId, amount: toSmallestUnit(amount), source, }), }) return res.transaction as BackendTransaction } export async function withdraw( flowId: string, sourceType: 'funnel' | 'outcome', sourceId: string, amount: number, externalAddress: string ): Promise { const res = await api(`/${flowId}/withdraw`, { method: 'POST', body: JSON.stringify({ sourceType, sourceId, amount: toSmallestUnit(amount), destination: 'external', externalAddress, }), }) return res.transaction as BackendTransaction } export async function getTransactions(flowId: string): Promise { const res = await api(`/${flowId}/transactions`) return (res.transactions || []) as BackendTransaction[] } // ─── On-Ramp ──────────────────────────────────────────────── export async function initiateOnRamp( flowId: string, funnelId: string, fiatAmount: number, fiatCurrency = 'USD' ): Promise<{ widgetUrl?: string; sessionId: string }> { const res = await api(`/${flowId}/onramp`, { method: 'POST', body: JSON.stringify({ funnelId, fiatAmount, fiatCurrency, provider: 'transak', paymentMethod: 'card', }), }) return res.session as { widgetUrl?: string; sessionId: string } } // ─── Deploy: Convert Visual Nodes → Backend Flow ──────────── /** Node ID → Backend ID mapping (returned after deploy) */ export interface DeployResult { flow: BackendFlow idMap: Record // frontend nodeId → backend id } /** * Deploy visual nodes to the backend as a live Flow. * * 1. Creates the Flow * 2. Creates all Outcomes first (so funnel allocations can reference them) * 3. Creates all Funnels with resolved allocation targets * 4. Activates the flow */ export async function deployNodes( name: string, ownerAddress: string, nodes: FlowNode[] ): Promise { const idMap: Record = {} // 1. Create flow const flow = await createFlow(name, ownerAddress, `Deployed from rFunds.online editor`) // 2. Create outcomes first const outcomeNodes = nodes.filter(n => n.type === 'outcome') for (const node of outcomeNodes) { const data = node.data as OutcomeNodeData const outcome = await addOutcome( flow.id, data.label, data.fundingTarget, ownerAddress, data.description ) idMap[node.id] = outcome.id } // 3. Create funnels (resolve allocation targetIds to backend IDs) const funnelNodes = nodes.filter(n => n.type === 'funnel') for (const node of funnelNodes) { const data = node.data as FunnelNodeData const resolveAllocations = ( allocs: { targetId: string; percentage: number }[], type: 'overflow' | 'spending' ): BackendAllocation[] => { return allocs .filter(a => idMap[a.targetId] || nodes.find(n => n.id === a.targetId)) .map((a, i) => { const targetNode = nodes.find(n => n.id === a.targetId) const isOutcome = targetNode?.type === 'outcome' return { targetId: idMap[a.targetId] || a.targetId, targetType: (isOutcome ? 'outcome' : 'funnel') as 'funnel' | 'outcome' | 'wallet', targetAddress: '0x0000000000000000000000000000000000000000', // backend assigns real addresses percentage: a.percentage, label: `${type} #${i + 1} (${a.percentage}%)`, } }) } const funnel = await addFunnel( flow.id, data.label, data.minThreshold, data.maxThreshold, resolveAllocations(data.overflowAllocations, 'overflow'), resolveAllocations(data.spendingAllocations, 'spending'), ) idMap[node.id] = funnel.id } // 4. Activate const activeFlow = await activateFlow(flow.id) return { flow: activeFlow, idMap } } // ─── Sync: Update Visual Nodes from Backend ───────────────── /** * Update visual node data with live balances from the backend. * Returns updated nodes with real balance/funding data. */ export function syncNodesToBackend( nodes: FlowNode[], flow: BackendFlow, idMap: Record ): FlowNode[] { // Build reverse map: backend id → frontend node id const reverseMap: Record = {} for (const [frontendId, backendId] of Object.entries(idMap)) { reverseMap[backendId] = frontendId } return nodes.map((node) => { if (node.type === 'funnel') { const backendId = idMap[node.id] const backendFunnel = flow.funnels.find(f => f.id === backendId) if (!backendFunnel) return node const data = node.data as FunnelNodeData return { ...node, data: { ...data, currentValue: fromSmallestUnit(backendFunnel.balance), minThreshold: fromSmallestUnit(backendFunnel.minThreshold), maxThreshold: fromSmallestUnit(backendFunnel.maxThreshold), }, } } if (node.type === 'outcome') { const backendId = idMap[node.id] const backendOutcome = flow.outcomes.find(o => o.id === backendId) if (!backendOutcome) return node const data = node.data as OutcomeNodeData return { ...node, data: { ...data, fundingReceived: fromSmallestUnit(backendOutcome.currentAmount), fundingTarget: fromSmallestUnit(backendOutcome.targetAmount), status: backendOutcome.status === 'completed' ? 'completed' : backendOutcome.status === 'funded' ? 'completed' : fromSmallestUnit(backendOutcome.currentAmount) > 0 ? 'in-progress' : 'not-started', }, } } return node }) }