rfunds-online/lib/api/flows-client.ts

426 lines
12 KiB
TypeScript

/**
* 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<T = unknown> {
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<T = BackendFlow>(
path: string,
options?: RequestInit & { ownerAddress?: string }
): Promise<ApiResponse<T>> {
const { ownerAddress, ...fetchOpts } = options || {}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(ownerAddress ? { 'X-Owner-Address': ownerAddress } : {}),
...((fetchOpts.headers as Record<string, string>) || {}),
}
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<BackendFlow[]> {
const res = await api<BackendFlow>('', { ownerAddress })
return (res.flows || []) as BackendFlow[]
}
export async function getFlow(flowId: string): Promise<BackendFlow> {
const res = await api<BackendFlow>(`/${flowId}`)
return res.flow as BackendFlow
}
export async function createFlow(
name: string,
ownerAddress: string,
description?: string
): Promise<BackendFlow> {
const res = await api<BackendFlow>('', {
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<BackendFlow> {
const res = await api<BackendFlow>(`/${flowId}/activate`, { method: 'POST' })
return res.flow as BackendFlow
}
export async function pauseFlow(flowId: string): Promise<BackendFlow> {
const res = await api<BackendFlow>(`/${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<BackendFunnel> {
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<BackendOutcome> {
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<BackendTransaction> {
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<BackendTransaction> {
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<BackendTransaction[]> {
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<string, string> // 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<DeployResult> {
const idMap: Record<string, string> = {}
// 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<string, string>
): FlowNode[] {
// Build reverse map: backend id → frontend node id
const reverseMap: Record<string, string> = {}
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
})
}