diff --git a/backend/src/disputes/ArbitratorService.ts b/backend/src/disputes/ArbitratorService.ts new file mode 100644 index 00000000..a70219fc --- /dev/null +++ b/backend/src/disputes/ArbitratorService.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; + +export interface Arbitrator { + id: string; + name: string; + address: string; + email?: string; + specializations: string[]; + activeDisputes: number; + totalResolved: number; + rating: number; + isAvailable: boolean; + joinedAt: number; +} + +export class ArbitratorService { + private arbitrators = new Map(); + private disputeAssignments = new Map(); + + constructor() { + this.registerDefaults(); + } + + private registerDefaults(): void { + const now = Date.now(); + this.arbitrators.set('arb-1', { id: 'arb-1', name: 'Alice Johnson', address: 'GA7...', specializations: ['escrow', 'payment'], activeDisputes: 2, totalResolved: 45, rating: 4.8, isAvailable: true, joinedAt: now - 365 * 86400000 }); + this.arbitrators.set('arb-2', { id: 'arb-2', name: 'Bob Chen', address: 'GB8...', specializations: ['smart-contract', 'defi'], activeDisputes: 1, totalResolved: 32, rating: 4.6, isAvailable: true, joinedAt: now - 180 * 86400000 }); + this.arbitrators.set('arb-3', { id: 'arb-3', name: 'Carol Martinez', address: 'GC9...', specializations: ['payment', 'identity'], activeDisputes: 3, totalResolved: 28, rating: 4.9, isAvailable: true, joinedAt: now - 90 * 86400000 }); + } + + registerArbitrator(params: Omit): Arbitrator { + const arbitrator: Arbitrator = { + id: `arb-${randomUUID().slice(0, 8)}`, + activeDisputes: 0, + totalResolved: 0, + rating: 5.0, + isAvailable: true, + joinedAt: Date.now(), + ...params, + }; + this.arbitrators.set(arbitrator.id, arbitrator); + return arbitrator; + } + + assignArbitrator(disputeId: string): Arbitrator | null { + const available = Array.from(this.arbitrators.values()) + .filter(a => a.isAvailable) + .sort((a, b) => a.activeDisputes - b.activeDisputes || b.rating - a.rating); + + if (available.length === 0) return null; + + const chosen = available[0]; + chosen.activeDisputes++; + this.arbitrators.set(chosen.id, chosen); + this.disputeAssignments.set(disputeId, chosen.id); + return chosen; + } + + releaseArbitrator(disputeId: string): void { + const arbId = this.disputeAssignments.get(disputeId); + if (!arbId) return; + const arb = this.arbitrators.get(arbId); + if (arb) { + arb.activeDisputes = Math.max(0, arb.activeDisputes - 1); + arb.totalResolved++; + this.arbitrators.set(arbId, arb); + } + this.disputeAssignments.delete(disputeId); + } + + getArbitrator(id: string): Arbitrator | undefined { + return this.arbitrators.get(id); + } + + listArbitrators(availableOnly?: boolean): Arbitrator[] { + const all = Array.from(this.arbitrators.values()); + return availableOnly ? all.filter(a => a.isAvailable) : all; + } + + getWorkloadStats(): { total: number; available: number; avgActiveDisputes: number } { + const all = Array.from(this.arbitrators.values()); + return { + total: all.length, + available: all.filter(a => a.isAvailable).length, + avgActiveDisputes: all.reduce((s, a) => s + a.activeDisputes, 0) / all.length, + }; + } +} diff --git a/backend/src/disputes/DisputeService.ts b/backend/src/disputes/DisputeService.ts new file mode 100644 index 00000000..7a49671b --- /dev/null +++ b/backend/src/disputes/DisputeService.ts @@ -0,0 +1,162 @@ +import { randomUUID } from 'node:crypto'; +import { auditService } from '../services/auditService.js'; +import { ArbitratorService } from './ArbitratorService.js'; + +export type DisputeStatus = 'opened' | 'evidence_gathering' | 'under_review' | 'resolved' | 'appealed' | 'closed'; + +export type ResolutionType = 'refund' | 'release' | 'split'; + +export interface EvidenceItem { + id: string; + type: 'document' | 'image' | 'message' | 'other'; + title: string; + description?: string; + url: string; + uploadedBy: string; + uploadedAt: number; +} + +export interface DisputeRecord { + id: string; + projectId: string; + escrowId: string; + raisedBy: string; + raisedAgainst: string; + reason: string; + status: DisputeStatus; + evidence: EvidenceItem[]; + arbitratorId?: string; + resolution?: { + type: ResolutionType; + description: string; + approvedBy: string; + approvedAt: number; + refundAmount?: string; + releaseAmount?: string; + splitRatio?: { partyA: number; partyB: number }; + }; + appealTarget?: string; + appealDeadline?: number; + createdAt: number; + updatedAt: number; + resolvedAt?: number; + auditTimeline: Array<{ action: string; by: string; at: number; detail?: string }>; +} + +export class DisputeService { + private disputes = new Map(); + private arbitratorService: ArbitratorService; + + constructor() { + this.arbitratorService = new ArbitratorService(); + } + + async createDispute(params: { + projectId: string; + escrowId: string; + raisedBy: string; + raisedAgainst: string; + reason: string; + }): Promise { + const dispute: DisputeRecord = { + id: randomUUID(), + status: 'opened', + evidence: [], + createdAt: Date.now(), + updatedAt: Date.now(), + auditTimeline: [{ action: 'dispute.created', by: params.raisedBy, at: Date.now() }], + ...params, + }; + + this.disputes.set(dispute.id, dispute); + + const arbitrator = this.arbitratorService.assignArbitrator(dispute.id); + if (arbitrator) { + dispute.arbitratorId = arbitrator.id; + dispute.status = 'under_review'; + dispute.auditTimeline.push({ action: 'arbitrator.assigned', by: 'system', at: Date.now(), detail: arbitrator.id }); + this.disputes.set(dispute.id, dispute); + } + + await auditService.logAction({ action: 'dispute.created', resource: 'dispute', resourceId: dispute.id, details: { projectId: params.projectId, raisedBy: params.raisedBy, reason: params.reason } }); + return dispute; + } + + async addEvidence(disputeId: string, evidence: Omit): Promise { + const dispute = this.disputes.get(disputeId); + if (!dispute || dispute.status === 'closed' || dispute.status === 'resolved') return null; + + const item: EvidenceItem = { ...evidence, id: randomUUID(), uploadedAt: Date.now() }; + dispute.evidence.push(item); + if (dispute.status === 'opened') dispute.status = 'evidence_gathering'; + dispute.updatedAt = Date.now(); + dispute.auditTimeline.push({ action: 'evidence.added', by: evidence.uploadedBy, at: Date.now(), detail: evidence.title }); + this.disputes.set(disputeId, dispute); + return dispute; + } + + async resolveDispute(disputeId: string, resolution: { + type: ResolutionType; + description: string; + approvedBy: string; + refundAmount?: string; + releaseAmount?: string; + splitRatio?: { partyA: number; partyB: number }; + }): Promise { + const dispute = this.disputes.get(disputeId); + if (!dispute || dispute.status === 'closed') return null; + + dispute.status = 'resolved'; + dispute.resolution = { ...resolution, approvedAt: Date.now() }; + dispute.resolvedAt = Date.now(); + dispute.updatedAt = Date.now(); + dispute.auditTimeline.push({ action: 'dispute.resolved', by: resolution.approvedBy, at: Date.now(), detail: resolution.type }); + this.disputes.set(disputeId, dispute); + + await auditService.logAction({ action: 'dispute.resolved', resource: 'dispute', resourceId: disputeId, details: { type: resolution.type, approvedBy: resolution.approvedBy } }); + return dispute; + } + + async appealDispute(disputeId: string, appealTarget: string): Promise { + const dispute = this.disputes.get(disputeId); + if (!dispute || dispute.status !== 'resolved') return null; + + dispute.status = 'appealed'; + dispute.appealTarget = appealTarget; + dispute.appealDeadline = Date.now() + 14 * 24 * 60 * 60 * 1000; + dispute.updatedAt = Date.now(); + dispute.auditTimeline.push({ action: 'dispute.appealed', by: 'system', at: Date.now(), detail: `Appealed to ${appealTarget}` }); + this.disputes.set(disputeId, dispute); + return dispute; + } + + async closeDispute(disputeId: string, closedBy: string): Promise { + const dispute = this.disputes.get(disputeId); + if (!dispute) return null; + + dispute.status = 'closed'; + dispute.updatedAt = Date.now(); + dispute.auditTimeline.push({ action: 'dispute.closed', by: closedBy, at: Date.now() }); + this.disputes.set(disputeId, dispute); + return dispute; + } + + getDispute(disputeId: string): DisputeRecord | undefined { + return this.disputes.get(disputeId); + } + + listDisputes(status?: DisputeStatus): DisputeRecord[] { + const all = Array.from(this.disputes.values()); + return status ? all.filter(d => d.status === status) : all; + } + + getDisputesByUser(userId: string): DisputeRecord[] { + return Array.from(this.disputes.values()).filter(d => d.raisedBy === userId || d.raisedAgainst === userId); + } + + getArbitratorService(): ArbitratorService { + return this.arbitratorService; + } +} + +export const disputeService = new DisputeService(); diff --git a/backend/src/disputes/index.ts b/backend/src/disputes/index.ts new file mode 100644 index 00000000..e9714fa8 --- /dev/null +++ b/backend/src/disputes/index.ts @@ -0,0 +1,4 @@ +export { disputeService, DisputeService } from './DisputeService.js'; +export type { DisputeRecord, DisputeStatus, ResolutionType, EvidenceItem } from './DisputeService.js'; +export { ArbitratorService } from './ArbitratorService.js'; +export type { Arbitrator } from './ArbitratorService.js'; diff --git a/backend/src/index.ts b/backend/src/index.ts index aa72b7ab..5f2d8e70 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -72,6 +72,8 @@ import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js'; import { fraudDetectionRouter } from './routes/fraud-detection.js'; import { bridgeRouter } from './routes/bridge.js'; import { tokenizationRouter } from './routes/tokenization.js'; +import { routingRouter } from './routes/routing.js'; +import { disputesRouter } from './routes/disputes.js'; import { startWebhookWorker, stopWebhookWorker } from './services/webhooks.js'; import { analyticsService } from './services/analytics.js'; import { createAnalyticsRouter } from './routes/analytics.js'; @@ -234,19 +236,21 @@ apiV1Router.use('/allowances', allowancesRouter); apiV1Router.use('/forms', formsRouter); // Webhook management and verification apiV1Router.use('/webhooks', webhooksRouter); -// Email delivery system apiV1Router.use('/disputes', disputeRoutes); apiV1Router.use('/emails', emailRouter); apiV1Router.use('/portfolio', portfolioRouter); apiV1Router.use('/backup', backupRouter); apiV1Router.use('/ip-allowlist', ipAllowlistRouter); apiV1Router.use('/push', pushRouter); -// NFC / QR payment requests apiV1Router.use('/nfc', nfcRouter); -// Cache management apiV1Router.use('/cache', cacheRouter); - apiV1Router.use('/circuit-breaker', circuitBreakerRouter); +apiV1Router.use('/fraud-detection', fraudDetectionRouter); +apiV1Router.use('/bridge', bridgeRouter); +apiV1Router.use('/tokenization', tokenizationRouter); +apiV1Router.use('/routing', routingRouter); +apiV1Router.use('/escrow', escrowRouter); +apiV1Router.use('/disputes', disputesRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); diff --git a/backend/src/payments/AtomicSwapBridge.ts b/backend/src/payments/AtomicSwapBridge.ts new file mode 100644 index 00000000..65989161 --- /dev/null +++ b/backend/src/payments/AtomicSwapBridge.ts @@ -0,0 +1,80 @@ +import type { NetworkId } from './NetworkRegistry.js'; + +export interface SwapIntent { + fromNetwork: NetworkId; + toNetwork: NetworkId; + fromAsset: string; + toAsset: string; + amount: string; + recipientAddress: string; + refundAddress: string; + timeoutMinutes: number; + price: string; + expiresAt: number; +} + +export interface SwapState { + intentId: string; + status: 'pending' | 'locked' | 'claimed' | 'refunded' | 'expired'; + fromTxHash?: string; + toTxHash?: string; + createdAt: number; + updatedAt: number; + secretHash: string; +} + +export class AtomicSwapBridge { + private swaps = new Map(); + + async createSwap(intent: Omit): Promise<{ intentId: string; secretHash: string }> { + const intentId = `swap_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const secret = Array.from({ length: 32 }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0')).join(''); + const secretHash = Array.from({ length: 32 }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0')).join(''); + + this.swaps.set(intentId, { + intentId, status: 'pending', createdAt: Date.now(), updatedAt: Date.now(), secretHash, + }); + + return { intentId, secretHash }; + } + + async lockSwap(intentId: string, fromTxHash: string): Promise { + const swap = this.swaps.get(intentId); + if (!swap || swap.status !== 'pending') return null; + swap.status = 'locked'; + swap.fromTxHash = fromTxHash; + swap.updatedAt = Date.now(); + this.swaps.set(intentId, swap); + return swap; + } + + async claimSwap(intentId: string, secret: string, toTxHash: string): Promise { + const swap = this.swaps.get(intentId); + if (!swap || swap.status !== 'locked') return null; + swap.status = 'claimed'; + swap.toTxHash = toTxHash; + swap.updatedAt = Date.now(); + this.swaps.set(intentId, swap); + return swap; + } + + async refundSwap(intentId: string): Promise { + const swap = this.swaps.get(intentId); + if (!swap || swap.status !== 'locked') return null; + swap.status = 'refunded'; + swap.updatedAt = Date.now(); + this.swaps.set(intentId, swap); + return swap; + } + + getSwap(intentId: string): SwapState | undefined { + return this.swaps.get(intentId); + } + + listSwaps(status?: SwapState['status']): SwapState[] { + const all = Array.from(this.swaps.values()); + return status ? all.filter(s => s.status === status) : all; + } +} + +export const atomicSwapBridge = new AtomicSwapBridge(); diff --git a/backend/src/payments/NetworkRegistry.ts b/backend/src/payments/NetworkRegistry.ts new file mode 100644 index 00000000..7f95bd2f --- /dev/null +++ b/backend/src/payments/NetworkRegistry.ts @@ -0,0 +1,88 @@ +export type NetworkId = 'stellar' | 'ethereum' | 'polygon' | 'arbitrum' | 'optimism'; + +export interface NetworkHealth { + status: 'healthy' | 'degraded' | 'down'; + lastChecked: number; + blockHeight: number; + avgLatencyMs: number; + errorRate24h: number; + uptime7d: number; +} + +export interface NetworkRoute { + network: NetworkId; + cost: number; + speedMs: number; + reliability: number; + minAmount: number; + maxAmount: number; + supportedAssets: string[]; + features: string[]; +} + +export class NetworkRegistry { + private health = new Map(); + private routes = new Map(); + + constructor() { + this.registerDefaults(); + } + + private registerDefaults(): void { + const now = Date.now(); + this.health.set('stellar', { status: 'healthy', lastChecked: now, blockHeight: 0, avgLatencyMs: 45, errorRate24h: 0.001, uptime7d: 99.99 }); + this.health.set('ethereum', { status: 'healthy', lastChecked: now, blockHeight: 0, avgLatencyMs: 120, errorRate24h: 0.005, uptime7d: 99.95 }); + this.health.set('polygon', { status: 'healthy', lastChecked: now, blockHeight: 0, avgLatencyMs: 25, errorRate24h: 0.003, uptime7d: 99.9 }); + this.health.set('arbitrum', { status: 'healthy', lastChecked: now, blockHeight: 0, avgLatencyMs: 15, errorRate24h: 0.002, uptime7d: 99.97 }); + this.health.set('optimism', { status: 'healthy', lastChecked: now, blockHeight: 0, avgLatencyMs: 18, errorRate24h: 0.004, uptime7d: 99.92 }); + + this.routes.set('stellar', { network: 'stellar', cost: 0.00001, speedMs: 45, reliability: 0.9999, minAmount: 0.00001, maxAmount: 1_000_000, supportedAssets: ['XLM', 'USDC'], features: ['escrow', 'x402', 'soroban'] }); + this.routes.set('ethereum', { network: 'ethereum', cost: 0.5, speedMs: 12000, reliability: 0.9995, minAmount: 0.001, maxAmount: 100_000, supportedAssets: ['ETH', 'USDC', 'DAI'], features: ['escrow', 'htlc', 'evm'] }); + this.routes.set('polygon', { network: 'polygon', cost: 0.001, speedMs: 2000, reliability: 0.999, minAmount: 0.001, maxAmount: 500_000, supportedAssets: ['MATIC', 'USDC', 'DAI'], features: ['escrow', 'evm'] }); + this.routes.set('arbitrum', { network: 'arbitrum', cost: 0.002, speedMs: 250, reliability: 0.9997, minAmount: 0.001, maxAmount: 200_000, supportedAssets: ['ETH', 'USDC'], features: ['escrow', 'evm'] }); + this.routes.set('optimism', { network: 'optimism', cost: 0.003, speedMs: 300, reliability: 0.9992, minAmount: 0.001, maxAmount: 200_000, supportedAssets: ['ETH', 'USDC'], features: ['escrow', 'evm'] }); + } + + getHealth(network: NetworkId): NetworkHealth | undefined { + return this.health.get(network); + } + + getRoute(network: NetworkId): NetworkRoute | undefined { + return this.routes.get(network); + } + + listNetworks(): NetworkId[] { + return Array.from(this.health.keys()); + } + + listHealthyNetworks(): NetworkId[] { + return Array.from(this.health.entries()) + .filter(([, h]) => h.status === 'healthy') + .map(([n]) => n); + } + + updateHealth(network: NetworkId, health: Partial): void { + const existing = this.health.get(network); + if (existing) { + this.health.set(network, { ...existing, ...health, lastChecked: Date.now() }); + } + } + + registerRoute(route: NetworkRoute): void { + this.routes.set(route.network, route); + if (!this.health.has(route.network)) { + this.health.set(route.network, { status: 'healthy', lastChecked: Date.now(), blockHeight: 0, avgLatencyMs: 0, errorRate24h: 0, uptime7d: 100 }); + } + } + + getRouteStats(): Record { + const stats: Record = {}; + for (const [network, route] of this.routes) { + const health = this.health.get(network); + if (health) stats[network] = { route, health }; + } + return stats; + } +} + +export const networkRegistry = new NetworkRegistry(); diff --git a/backend/src/payments/RouteScorer.ts b/backend/src/payments/RouteScorer.ts new file mode 100644 index 00000000..0970cf3f --- /dev/null +++ b/backend/src/payments/RouteScorer.ts @@ -0,0 +1,64 @@ +import { networkRegistry, type NetworkId } from './NetworkRegistry.js'; + +export interface RoutingPreference { + prioritize: 'cost' | 'speed' | 'reliability' | 'balanced'; + maxCost?: number; + maxLatencyMs?: number; + minReliability?: number; +} + +export interface ScoredRoute { + network: NetworkId; + score: number; + cost: number; + estimatedTimeMs: number; + reliability: number; +} + +export class RouteScorer { + scoreAll(preference: RoutingPreference): ScoredRoute[] { + const healthy = networkRegistry.listHealthyNetworks(); + const scored: ScoredRoute[] = []; + + for (const network of healthy) { + const route = networkRegistry.getRoute(network); + const health = networkRegistry.getHealth(network); + if (!route || !health) continue; + + if (preference.maxCost && route.cost > preference.maxCost) continue; + if (preference.maxLatencyMs && health.avgLatencyMs > preference.maxLatencyMs) continue; + if (preference.minReliability && route.reliability < preference.minReliability) continue; + + const score = this.computeScore(route.cost, health.avgLatencyMs, route.reliability, preference); + scored.push({ network, score, cost: route.cost, estimatedTimeMs: health.avgLatencyMs, reliability: route.reliability }); + } + + scored.sort((a, b) => b.score - a.score); + return scored; + } + + private computeScore(cost: number, latencyMs: number, reliability: number, preference: RoutingPreference): number { + const costNorm = 1 / (1 + cost); + const speedNorm = 1 / (1 + latencyMs / 1000); + const reliabilityNorm = reliability; + + switch (preference.prioritize) { + case 'cost': + return costNorm * 0.7 + speedNorm * 0.15 + reliabilityNorm * 0.15; + case 'speed': + return costNorm * 0.15 + speedNorm * 0.7 + reliabilityNorm * 0.15; + case 'reliability': + return costNorm * 0.15 + speedNorm * 0.15 + reliabilityNorm * 0.7; + case 'balanced': + default: + return costNorm * 0.33 + speedNorm * 0.33 + reliabilityNorm * 0.34; + } + } + + pickBest(preference: RoutingPreference): ScoredRoute | null { + const scored = this.scoreAll(preference); + return scored[0] ?? null; + } +} + +export const routeScorer = new RouteScorer(); diff --git a/backend/src/payments/Router.ts b/backend/src/payments/Router.ts new file mode 100644 index 00000000..4f8999f8 --- /dev/null +++ b/backend/src/payments/Router.ts @@ -0,0 +1,72 @@ +import { routeScorer, type RoutingPreference, type ScoredRoute } from './RouteScorer.js'; +import { networkRegistry, type NetworkId } from './NetworkRegistry.js'; + +export interface RouteRequest { + amount: number; + fromAsset: string; + toAsset?: string; + fromNetwork?: NetworkId; + toNetwork?: NetworkId; + preference?: RoutingPreference; + merchantId?: string; +} + +export interface RouteResult { + primary: ScoredRoute; + fallbacks: ScoredRoute[]; + estimatedTotalCost: number; + estimatedTotalTimeMs: number; +} + +export class PaymentRouter { + route(request: RouteRequest): RouteResult { + const preference = request.preference ?? { prioritize: 'balanced' }; + const scored = routeScorer.scoreAll(preference); + + if (scored.length === 0) { + throw new Error('No available route found for the given preferences'); + } + + const primary = scored[0]; + const fallbacks = scored.slice(1, 4); + + return { + primary, + fallbacks, + estimatedTotalCost: primary.cost, + estimatedTotalTimeMs: primary.estimatedTimeMs, + }; + } + + async executeWithFallback(request: RouteRequest): Promise<{ route: ScoredRoute; txHash: string }> { + const result = this.route(request); + const routes = [result.primary, ...result.fallbacks]; + + for (const route of routes) { + try { + const txHash = await this.submitViaRoute(route, request); + return { route, txHash }; + } catch (err) { + console.warn(`[Router] Route ${route.network} failed:`, err); + } + } + + throw new Error('All routes failed'); + } + + private async submitViaRoute(route: ScoredRoute, request: RouteRequest): Promise { + const simulated = `tx_${route.network}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + return simulated; + } + + getRouteAnalytics(): { totalRoutes: number; healthyCount: number; networks: string[] } { + const healthy = networkRegistry.listHealthyNetworks(); + return { + totalRoutes: networkRegistry.listNetworks().length, + healthyCount: healthy.length, + networks: networkRegistry.listNetworks(), + }; + } +} + +export const paymentRouter = new PaymentRouter(); diff --git a/backend/src/payments/index.ts b/backend/src/payments/index.ts new file mode 100644 index 00000000..41f73f4e --- /dev/null +++ b/backend/src/payments/index.ts @@ -0,0 +1,8 @@ +export { networkRegistry, NetworkRegistry } from './NetworkRegistry.js'; +export type { NetworkId, NetworkHealth, NetworkRoute } from './NetworkRegistry.js'; +export { routeScorer, RouteScorer } from './RouteScorer.js'; +export type { RoutingPreference, ScoredRoute } from './RouteScorer.js'; +export { atomicSwapBridge, AtomicSwapBridge } from './AtomicSwapBridge.js'; +export type { SwapIntent, SwapState } from './AtomicSwapBridge.js'; +export { paymentRouter, PaymentRouter } from './Router.js'; +export type { RouteRequest, RouteResult } from './Router.js'; diff --git a/backend/src/routes/disputes.ts b/backend/src/routes/disputes.ts new file mode 100644 index 00000000..3ae8dc53 --- /dev/null +++ b/backend/src/routes/disputes.ts @@ -0,0 +1,102 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { disputeService } from '../disputes/index.js'; + +export const disputesRouter = Router(); + +const createDisputeSchema = z.object({ + projectId: z.string().min(1), + escrowId: z.string().min(1), + raisedBy: z.string().min(1), + raisedAgainst: z.string().min(1), + reason: z.string().min(10), +}); + +const evidenceSchema = z.object({ + type: z.enum(['document', 'image', 'message', 'other']), + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + url: z.string().url(), + uploadedBy: z.string().min(1), +}); + +const resolveSchema = z.object({ + type: z.enum(['refund', 'release', 'split']), + description: z.string().min(1), + approvedBy: z.string().min(1), + refundAmount: z.string().optional(), + releaseAmount: z.string().optional(), + splitRatio: z.object({ partyA: z.number(), partyB: z.number() }).optional(), +}); + +disputesRouter.post('/', validate(createDisputeSchema), asyncHandler(async (req: Request, res: Response) => { + const dispute = await disputeService.createDispute(req.body); + res.status(201).json(dispute); +})); + +disputesRouter.post('/:id/evidence', validate(evidenceSchema), asyncHandler(async (req: Request, res: Response) => { + const dispute = await disputeService.addEvidence(req.params.id, req.body); + if (!dispute) return res.status(404).json({ error: 'Dispute not found or already closed' }); + res.json(dispute); +})); + +disputesRouter.post('/:id/resolve', validate(resolveSchema), asyncHandler(async (req: Request, res: Response) => { + const dispute = await disputeService.resolveDispute(req.params.id, req.body); + if (!dispute) return res.status(404).json({ error: 'Dispute not found' }); + res.json(dispute); +})); + +disputesRouter.post('/:id/appeal', asyncHandler(async (req: Request, res: Response) => { + const { appealTarget } = req.body; + if (!appealTarget) return res.status(400).json({ error: 'appealTarget required' }); + const dispute = await disputeService.appealDispute(req.params.id, appealTarget); + if (!dispute) return res.status(404).json({ error: 'Dispute not found or not in resolvable state' }); + res.json(dispute); +})); + +disputesRouter.post('/:id/close', asyncHandler(async (req: Request, res: Response) => { + const { closedBy } = req.body; + if (!closedBy) return res.status(400).json({ error: 'closedBy required' }); + const dispute = await disputeService.closeDispute(req.params.id, closedBy); + if (!dispute) return res.status(404).json({ error: 'Dispute not found' }); + res.json(dispute); +})); + +disputesRouter.get('/', asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as string | undefined; + const disputes = disputeService.listDisputes(status as any); + res.json({ disputes, total: disputes.length }); +})); + +disputesRouter.get('/user/:userId', asyncHandler(async (req: Request, res: Response) => { + const disputes = disputeService.getDisputesByUser(req.params.userId); + res.json({ disputes, total: disputes.length }); +})); + +disputesRouter.get('/arbitrators', asyncHandler(async (_req: Request, res: Response) => { + const availableOnly = _req.query.available === 'true'; + const arbitrators = disputeService.getArbitratorService().listArbitrators(availableOnly); + res.json({ arbitrators, workload: disputeService.getArbitratorService().getWorkloadStats() }); +})); + +disputesRouter.get('/arbitrators/:id', asyncHandler(async (req: Request, res: Response) => { + const arbitrator = disputeService.getArbitratorService().getArbitrator(req.params.id); + if (!arbitrator) return res.status(404).json({ error: 'Arbitrator not found' }); + res.json(arbitrator); +})); + +disputesRouter.get('/:id', asyncHandler(async (req: Request, res: Response) => { + const arbitratorId = req.query.arbitratorId as string | undefined; + const arbitrator = arbitratorId ? disputeService.getArbitratorService().getArbitrator(arbitratorId) : undefined; + const dispute = disputeService.getDispute(req.params.id); + if (!dispute) return res.status(404).json({ error: 'Dispute not found' }); + res.json({ dispute, arbitrator }); +})); + +disputesRouter.get('/:id/timeline', asyncHandler(async (req: Request, res: Response) => { + const dispute = disputeService.getDispute(req.params.id); + if (!dispute) return res.status(404).json({ error: 'Dispute not found' }); + res.json({ timeline: dispute.auditTimeline }); +})); diff --git a/backend/src/routes/escrow.ts b/backend/src/routes/escrow.ts index 1b57402b..0c0a42c3 100644 --- a/backend/src/routes/escrow.ts +++ b/backend/src/routes/escrow.ts @@ -1,110 +1,78 @@ -import { Router } from 'express'; -import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; import { validate } from '../middleware/validate.js'; import { escrowService } from '../services/escrow.js'; -import { - createEscrowSchema, - fundEscrowSchema, - escrowMilestoneActionSchema, - escrowSubmissionSchema, -} from '../schemas/index.js'; export const escrowRouter = Router(); -escrowRouter.post( - '/', - validate(createEscrowSchema), - asyncHandler(async (req, res) => { - const { projectId, payerId, payeeId, currency, totalAmount, milestones, metadata } = req.body; +const createEscrowSchema = z.object({ + projectId: z.string().min(1), + clientAddress: z.string().min(1), + freelancerAddress: z.string().min(1), + arbitratorAddresses: z.array(z.string().min(1)).min(1), + amount: z.string().min(1), + asset: z.string().min(1), + network: z.string().min(1), + deadline: z.number().int().positive(), +}); - const escrow = escrowService.createEscrow({ - projectId, - payerId, - payeeId, - currency, - totalAmount, - milestones, - metadata, - }); +const resolveDisputeSchema = z.object({ + type: z.enum(['release_to_freelancer', 'refund_to_client', 'split']), + freelancerPercent: z.number().min(0).max(100).optional(), + clientPercent: z.number().min(0).max(100).optional(), + approvedBy: z.array(z.string().min(1)).min(1), +}); - res.status(201).json(escrow); - }) -); +escrowRouter.post('/', validate(createEscrowSchema), asyncHandler(async (req: Request, res: Response) => { + const escrow = await escrowService.createEscrow(req.body); + res.status(201).json(escrow); +})); -escrowRouter.get( - '/', - asyncHandler(async (req, res) => { - res.json(escrowService.listEscrows()); - }) -); +escrowRouter.post('/:id/fund', asyncHandler(async (req: Request, res: Response) => { + const { txHash } = req.body; + if (!txHash) return res.status(400).json({ error: 'txHash required' }); + const escrow = await escrowService.fundEscrow(req.params.id, txHash); + if (!escrow) return res.status(404).json({ error: 'Escrow not found or not in pending state' }); + res.json(escrow); +})); -escrowRouter.get( - '/:id', - asyncHandler(async (req, res) => { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const escrow = escrowService.getEscrow(id); - if (!escrow) { - throw new AppError(404, 'Escrow agreement not found', 'NOT_FOUND'); - } - res.json(escrow); - }) -); +escrowRouter.post('/:id/dispute', asyncHandler(async (req: Request, res: Response) => { + const { raisedBy } = req.body; + if (!raisedBy) return res.status(400).json({ error: 'raisedBy required' }); + const escrow = await escrowService.raiseDispute(req.params.id, raisedBy); + if (!escrow) return res.status(404).json({ error: 'Escrow not found or not in fundable state' }); + res.json(escrow); +})); -escrowRouter.post( - '/:id/fund', - validate(fundEscrowSchema), - asyncHandler(async (req, res) => { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const { amount } = req.body; - const escrow = escrowService.fundEscrow(id, amount); - if (!escrow) { - throw new AppError(404, 'Escrow agreement not found', 'NOT_FOUND'); - } - res.json(escrow); - }) -); +escrowRouter.post('/:id/resolve', validate(resolveDisputeSchema), asyncHandler(async (req: Request, res: Response) => { + const escrow = await escrowService.resolveDispute(req.params.id, req.body); + if (!escrow) return res.status(404).json({ error: 'Escrow not found or not in disputed state' }); + res.json(escrow); +})); -escrowRouter.post( - '/:id/milestone/:milestoneId/submit', - validate(escrowSubmissionSchema), - asyncHandler(async (req, res) => { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const milestoneId = Array.isArray(req.params.milestoneId) ? req.params.milestoneId[0] : req.params.milestoneId; - const { submissionUrl, notes } = req.body; - const milestone = escrowService.submitMilestone(id, milestoneId, submissionUrl, notes); - if (!milestone) { - throw new AppError(404, 'Escrow milestone not found or invalid state', 'NOT_FOUND'); - } - res.json(milestone); - }) -); +escrowRouter.post('/:id/appeal', asyncHandler(async (req: Request, res: Response) => { + const { appealTarget } = req.body; + if (!appealTarget) return res.status(400).json({ error: 'appealTarget required' }); + const escrow = await escrowService.appealDispute(req.params.id, appealTarget); + if (!escrow) return res.status(404).json({ error: 'Escrow not found or not in disputed state' }); + res.json(escrow); +})); -escrowRouter.post( - '/:id/milestone/:milestoneId/approve', - validate(escrowMilestoneActionSchema), - asyncHandler(async (req, res) => { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const milestoneId = Array.isArray(req.params.milestoneId) ? req.params.milestoneId[0] : req.params.milestoneId; - const { approvedBy } = req.body; - const result = escrowService.approveMilestone(id, milestoneId, approvedBy); - if (!result) { - throw new AppError(404, 'Escrow milestone not found or not ready for approval', 'NOT_FOUND'); - } - res.json(result); - }) -); +escrowRouter.post('/:id/timeout-release', asyncHandler(async (req: Request, res: Response) => { + const escrow = await escrowService.timeoutRelease(req.params.id); + if (!escrow) return res.status(404).json({ error: 'Escrow not found or not eligible for timeout release' }); + res.json(escrow); +})); -escrowRouter.post( - '/:id/milestone/:milestoneId/dispute', - validate(escrowMilestoneActionSchema), - asyncHandler(async (req, res) => { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const milestoneId = Array.isArray(req.params.milestoneId) ? req.params.milestoneId[0] : req.params.milestoneId; - const { reason } = req.body; - const milestone = escrowService.disputeMilestone(id, milestoneId, reason); - if (!milestone) { - throw new AppError(404, 'Escrow milestone not found or cannot be disputed', 'NOT_FOUND'); - } - res.json(milestone); - }) -); +escrowRouter.get('/', asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as string | undefined; + const escrows = await escrowService.listEscrows(status as any); + res.json({ escrows }); +})); + +escrowRouter.get('/:id', asyncHandler(async (req: Request, res: Response) => { + const escrow = await escrowService.getEscrow(req.params.id); + if (!escrow) return res.status(404).json({ error: 'Escrow not found' }); + res.json(escrow); +})); diff --git a/backend/src/routes/routing.ts b/backend/src/routes/routing.ts new file mode 100644 index 00000000..0c18a390 --- /dev/null +++ b/backend/src/routes/routing.ts @@ -0,0 +1,68 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import * as routingService from '../services/routing.js'; + +export const routingRouter = Router(); + +const routeRequestSchema = z.object({ + amount: z.number().positive(), + fromAsset: z.string().min(1), + toAsset: z.string().optional(), + fromNetwork: z.string().optional(), + toNetwork: z.string().optional(), + preference: z.object({ + prioritize: z.enum(['cost', 'speed', 'reliability', 'balanced']).optional(), + maxCost: z.number().positive().optional(), + maxLatencyMs: z.number().positive().optional(), + minReliability: z.number().min(0).max(1).optional(), + }).optional(), + merchantId: z.string().optional(), +}); + +const healthUpdateSchema = z.object({ + network: z.string().min(1), + status: z.enum(['healthy', 'degraded', 'down']).optional(), + avgLatencyMs: z.number().min(0).optional(), + errorRate24h: z.number().min(0).optional(), + uptime7d: z.number().min(0).max(100).optional(), + blockHeight: z.number().int().min(0).optional(), +}); + +routingRouter.get('/networks', asyncHandler(async (_req: Request, res: Response) => { + const routes = routingService.getNetworkRoutes(); + res.json({ networks: routes }); +})); + +routingRouter.get('/strategies', asyncHandler(async (_req: Request, res: Response) => { + res.json({ strategies: routingService.getRouteStrategies() }); +})); + +routingRouter.post('/route', validate(routeRequestSchema), asyncHandler(async (req: Request, res: Response) => { + const result = await routingService.findRoute(req.body); + res.json(result); +})); + +routingRouter.post('/execute', validate(routeRequestSchema), asyncHandler(async (req: Request, res: Response) => { + const result = await routingService.executeRoute(req.body); + res.json(result); +})); + +routingRouter.get('/scored', asyncHandler(async (req: Request, res: Response) => { + const prioritize = (req.query.prioritize as string) || 'balanced'; + const preference = { prioritize: prioritize as 'cost' | 'speed' | 'reliability' | 'balanced' }; + const scored = routingService.getScoredRoutes(preference); + res.json({ routes: scored }); +})); + +routingRouter.get('/analytics', asyncHandler(async (_req: Request, res: Response) => { + const analytics = routingService.getRouteAnalytics(); + res.json(analytics); +})); + +routingRouter.post('/networks/health', validate(healthUpdateSchema), asyncHandler(async (req: Request, res: Response) => { + const { network, ...health } = req.body; + routingService.updateNetworkHealth(network, health); + res.json({ success: true }); +})); diff --git a/backend/src/routes/stripe.ts b/backend/src/routes/stripe.ts index 7d9b8960..5b54d415 100644 --- a/backend/src/routes/stripe.ts +++ b/backend/src/routes/stripe.ts @@ -20,6 +20,21 @@ import { listFeeRecords, estimateStripeFee, } from '../services/stripe.js'; +import { + createConnectedAccount, + getOnboardingStatus, + handleConnectWebhook, + createPaymentIntentWithConnect, +} from '../services/stripe-connect.js'; +import { + lockRate, + confirmConversion, + settleToStellar, + processFiatRefund, + getConversion, + listConversions, + getRate, +} from '../services/fiat-crypto.js'; export const stripeRouter = Router(); @@ -245,6 +260,83 @@ stripeRouter.get( }) ); +// ── Stripe Connect ─────────────────────────────────────────────────────────── + +const connectAccountSchema = z.object({ + merchantId: z.string().min(1), + email: z.string().email(), +}); + +stripeRouter.post('/connect/account', validate(connectAccountSchema), asyncHandler(async (req, res) => { + const { merchantId, email } = req.body; + const result = await createConnectedAccount(merchantId, email); + res.status(201).json(result); +})); + +stripeRouter.get('/connect/account/:merchantId', asyncHandler(async (req, res) => { + const status = await getOnboardingStatus(req.params.merchantId); + if (!status) return res.status(404).json({ error: 'Account not found' }); + res.json(status); +})); + +stripeRouter.post('/connect/payment-intents', asyncHandler(async (req, res) => { + const { amount, currency, merchantStripeAccountId } = req.body; + if (!amount || !currency || !merchantStripeAccountId) { + return res.status(400).json({ error: 'amount, currency, and merchantStripeAccountId required' }); + } + const intent = await createPaymentIntentWithConnect(amount, currency, merchantStripeAccountId); + res.status(201).json({ id: intent.id, clientSecret: intent.client_secret, status: intent.status }); +})); + +// ── Fiat-to-Crypto Conversion ──────────────────────────────────────────────── + +stripeRouter.get('/rates', asyncHandler(async (req, res) => { + const { from = 'USD', to = 'USDC' } = req.query; + const rate = getRate(from as string, to as string); + res.json({ from, to, rate, updatedAt: new Date().toISOString() }); +})); + +stripeRouter.post('/convert/lock-rate', asyncHandler(async (req, res) => { + const { fromCurrency = 'USD', toAsset = 'USDC', amount } = req.body; + if (!amount) return res.status(400).json({ error: 'amount required' }); + const lock = await lockRate(fromCurrency, toAsset, amount); + res.json(lock); +})); + +stripeRouter.post('/convert/confirm', asyncHandler(async (req, res) => { + const { rateLockId, stripePaymentIntentId } = req.body; + if (!rateLockId || !stripePaymentIntentId) return res.status(400).json({ error: 'rateLockId and stripePaymentIntentId required' }); + const conversion = await confirmConversion(rateLockId, stripePaymentIntentId); + res.json(conversion); +})); + +stripeRouter.post('/convert/settle', asyncHandler(async (req, res) => { + const { conversionId, destinationAddress } = req.body; + if (!conversionId || !destinationAddress) return res.status(400).json({ error: 'conversionId and destinationAddress required' }); + const conversion = await getConversion(conversionId); + if (!conversion) return res.status(404).json({ error: 'Conversion not found' }); + const txHash = await settleToStellar(destinationAddress, conversion.cryptoAmount, conversion.toAsset); + res.json({ txHash, status: 'settled' }); +})); + +stripeRouter.post('/convert/:id/refund', asyncHandler(async (req, res) => { + const ok = await processFiatRefund(req.params.id); + if (!ok) return res.status(404).json({ error: 'Conversion not found or not refundable' }); + res.json({ success: true }); +})); + +stripeRouter.get('/conversions', asyncHandler(async (req, res) => { + const userId = req.query.userId as string | undefined; + const conversions = await listConversions(userId); + res.json({ conversions, total: conversions.length }); +})); + +stripeRouter.get('/conversions/:id', asyncHandler(async (req, res) => { + const conversion = await getConversion(req.params.id); + if (!conversion) return res.status(404).json({ error: 'Conversion not found' }); + res.json(conversion); +})); + // ── Webhooks ───────────────────────────────────────────────────────────────── /** @@ -292,6 +384,10 @@ stripeRouter.post( console.log(`[Stripe] Charge refunded: ${charge.id} amount: ${charge.amount_refunded}`); break; } + case 'account.updated': { + await handleConnectWebhook(event); + break; + } default: console.log(`[Stripe] Unhandled event type: ${event.type}`); } diff --git a/backend/src/services/escrow.ts b/backend/src/services/escrow.ts index 7c6dba25..c5d1cccb 100644 --- a/backend/src/services/escrow.ts +++ b/backend/src/services/escrow.ts @@ -1,172 +1,135 @@ import { randomUUID } from 'node:crypto'; +import { auditService } from './auditService.js'; -export type EscrowMilestoneStatus = 'pending' | 'submitted' | 'approved' | 'released' | 'disputed' | 'refunded'; -export type EscrowStatus = 'draft' | 'funded' | 'in_progress' | 'completed' | 'disputed' | 'refunded'; +export type EscrowStatus = 'pending' | 'funded' | 'disputed' | 'released' | 'refunded' | 'expired'; -export type EscrowMilestoneInput = { - title: string; - description?: string; - amount: number; - completionCriteria: string; -}; +export interface EscrowRelease { + type: 'release_to_freelancer' | 'refund_to_client' | 'split'; + freelancerPercent?: number; + clientPercent?: number; + approvedBy: string[]; +} -export type EscrowMilestoneRecord = { - id: string; - title: string; - description?: string; - amount: number; - completionCriteria: string; - status: EscrowMilestoneStatus; - submissionUrl: string | null; - submissionNotes: string | null; - approvedAt: string | null; - disputedAt: string | null; - disputeReason: string | null; - createdAt: string; - updatedAt: string; -}; - -export type EscrowAgreement = { +export interface EscrowRecord { id: string; projectId: string; - payerId: string; - payeeId: string; - currency: string; - totalAmount: number; - fundedAmount: number; + clientAddress: string; + freelancerAddress: string; + arbitratorAddresses: string[]; + amount: string; + asset: string; + network: string; status: EscrowStatus; - milestones: EscrowMilestoneRecord[]; - createdAt: string; - updatedAt: string; - metadata: Record; -}; + createdAt: number; + fundedAt?: number; + disputedAt?: number; + releasedAt?: number; + deadline: number; + release?: EscrowRelease; + appealDeadline?: number; + appealTarget?: string; + signatures: string[]; +} class EscrowService { - private escrows = new Map(); - - private nowIso(): string { - return new Date().toISOString(); - } + private escrows = new Map(); - createEscrow(input: { + async createEscrow(params: { projectId: string; - payerId: string; - payeeId: string; - currency: string; - totalAmount: number; - milestones: EscrowMilestoneInput[]; - metadata?: Record; - }): EscrowAgreement { - const now = this.nowIso(); - const milestones = input.milestones.map((item) => ({ - id: randomUUID(), - title: item.title, - description: item.description, - amount: Number(item.amount.toFixed(2)), - completionCriteria: item.completionCriteria, - status: 'pending' as EscrowMilestoneStatus, - submissionUrl: null, - submissionNotes: null, - approvedAt: null, - disputedAt: null, - disputeReason: null, - createdAt: now, - updatedAt: now, - })); - - const escrow: EscrowAgreement = { + clientAddress: string; + freelancerAddress: string; + arbitratorAddresses: string[]; + amount: string; + asset: string; + network: string; + deadline: number; + }): Promise { + const escrow: EscrowRecord = { id: randomUUID(), - projectId: input.projectId, - payerId: input.payerId, - payeeId: input.payeeId, - currency: input.currency.toUpperCase(), - totalAmount: Number(input.totalAmount.toFixed(2)), - fundedAmount: 0, - status: 'draft', - milestones, - createdAt: now, - updatedAt: now, - metadata: input.metadata ?? {}, + status: 'pending', + createdAt: Date.now(), + deadline: params.deadline, + signatures: [], + ...params, }; - this.escrows.set(escrow.id, escrow); + + await auditService.logAction({ action: 'escrow.created', resource: 'escrow', resourceId: escrow.id, details: { projectId: params.projectId, amount: params.amount, asset: params.asset } }); return escrow; } - listEscrows(): EscrowAgreement[] { - return [...this.escrows.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + async fundEscrow(escrowId: string, txHash: string): Promise { + const escrow = this.escrows.get(escrowId); + if (!escrow || escrow.status !== 'pending') return null; + escrow.status = 'funded'; + escrow.fundedAt = Date.now(); + this.escrows.set(escrowId, escrow); + await auditService.logAction({ action: 'escrow.funded', resource: 'escrow', resourceId: escrowId, details: { txHash } }); + return escrow; } - getEscrow(escrowId: string): EscrowAgreement | undefined { - return this.escrows.get(escrowId); + async raiseDispute(escrowId: string, raisedBy: string): Promise { + const escrow = this.escrows.get(escrowId); + if (!escrow || escrow.status !== 'funded') return null; + escrow.status = 'disputed'; + escrow.disputedAt = Date.now(); + escrow.appealDeadline = Date.now() + 7 * 24 * 60 * 60 * 1000; + this.escrows.set(escrowId, escrow); + await auditService.logAction({ action: 'escrow.disputed', resource: 'escrow', resourceId: escrowId, details: { raisedBy } }); + return escrow; } - fundEscrow(escrowId: string, amount: number): EscrowAgreement | undefined { + async resolveDispute(escrowId: string, release: EscrowRelease): Promise { const escrow = this.escrows.get(escrowId); - if (!escrow) { - return undefined; + if (!escrow || escrow.status !== 'disputed') return null; + + const requiredSigs = Math.ceil((escrow.arbitratorAddresses.length * 2) / 3); + if (release.approvedBy.length < requiredSigs) { + throw new Error(`Need ${requiredSigs} arbitrator signatures, got ${release.approvedBy.length}`); } - escrow.fundedAmount = Number((escrow.fundedAmount + amount).toFixed(2)); - escrow.status = escrow.fundedAmount >= escrow.totalAmount ? 'funded' : 'in_progress'; - escrow.updatedAt = this.nowIso(); - this.escrows.set(escrow.id, escrow); + escrow.status = release.type === 'refund_to_client' ? 'refunded' : 'released'; + escrow.release = release; + escrow.releasedAt = Date.now(); + this.escrows.set(escrowId, escrow); + await auditService.logAction({ action: 'escrow.resolved', resource: 'escrow', resourceId: escrowId, details: { releaseType: release.type, approvedBy: release.approvedBy } }); return escrow; } - submitMilestone(escrowId: string, milestoneId: string, submissionUrl: string, notes?: string): EscrowMilestoneRecord | undefined { + async appealDispute(escrowId: string, appealTargetAddress: string): Promise { const escrow = this.escrows.get(escrowId); - const milestone = escrow?.milestones.find((item) => item.id === milestoneId); - if (!escrow || !milestone) { - return undefined; + if (!escrow || escrow.status !== 'disputed') return null; + if (escrow.appealDeadline && Date.now() > escrow.appealDeadline) { + throw new Error('Appeal deadline has passed'); } - - milestone.status = 'submitted'; - milestone.submissionUrl = submissionUrl; - milestone.submissionNotes = notes ?? null; - milestone.updatedAt = this.nowIso(); - escrow.status = escrow.status === 'draft' ? 'funded' : 'in_progress'; - escrow.updatedAt = this.nowIso(); - this.escrows.set(escrow.id, escrow); - return milestone; + escrow.appealTarget = appealTargetAddress; + escrow.appealDeadline = Date.now() + 14 * 24 * 60 * 60 * 1000; + escrow.arbitratorAddresses = [appealTargetAddress]; + this.escrows.set(escrowId, escrow); + await auditService.logAction({ action: 'escrow.appealed', resource: 'escrow', resourceId: escrowId, details: { appealTarget: appealTargetAddress } }); + return escrow; } - approveMilestone(escrowId: string, milestoneId: string, approvedBy: string): { escrow: EscrowAgreement; milestone: EscrowMilestoneRecord } | undefined { + async timeoutRelease(escrowId: string): Promise { const escrow = this.escrows.get(escrowId); - const milestone = escrow?.milestones.find((item) => item.id === milestoneId); - if (!escrow || !milestone || milestone.status !== 'submitted') { - return undefined; - } - - milestone.status = 'released'; - milestone.approvedAt = this.nowIso(); - milestone.updatedAt = this.nowIso(); - escrow.status = 'in_progress'; - escrow.updatedAt = this.nowIso(); - - const allReleased = escrow.milestones.every((item) => item.status === 'released'); - if (allReleased) { - escrow.status = 'completed'; - } - this.escrows.set(escrow.id, escrow); - return { escrow, milestone }; + if (!escrow || escrow.status !== 'disputed') return null; + if (escrow.deadline > Date.now()) return null; + + escrow.status = 'released'; + escrow.release = { type: 'release_to_freelancer', approvedBy: ['system_timeout'] }; + escrow.releasedAt = Date.now(); + this.escrows.set(escrowId, escrow); + await auditService.logAction({ action: 'escrow.timeout_release', resource: 'escrow', resourceId: escrowId, details: { reason: 'arbitrator_timeout' } }); + return escrow; } - disputeMilestone(escrowId: string, milestoneId: string, reason: string): EscrowMilestoneRecord | undefined { - const escrow = this.escrows.get(escrowId); - const milestone = escrow?.milestones.find((item) => item.id === milestoneId); - if (!escrow || !milestone || milestone.status === 'approved' || milestone.status === 'released') { - return undefined; - } + async getEscrow(escrowId: string): Promise { + return this.escrows.get(escrowId); + } - milestone.status = 'disputed'; - milestone.disputeReason = reason; - milestone.disputedAt = this.nowIso(); - milestone.updatedAt = this.nowIso(); - escrow.status = 'disputed'; - escrow.updatedAt = this.nowIso(); - this.escrows.set(escrow.id, escrow); - return milestone; + async listEscrows(status?: EscrowStatus): Promise { + const all = Array.from(this.escrows.values()); + return status ? all.filter(e => e.status === status) : all; } } diff --git a/backend/src/services/fiat-crypto.ts b/backend/src/services/fiat-crypto.ts new file mode 100644 index 00000000..c0b1d402 --- /dev/null +++ b/backend/src/services/fiat-crypto.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto'; +import { auditService } from './auditService.js'; +import { getStripe } from './stripe.js'; + +export type ConversionStatus = 'pending' | 'rate_locked' | 'completed' | 'failed' | 'expired'; + +export interface RateLock { + id: string; + fromCurrency: string; + toAsset: string; + rate: number; + amount: number; + expiresAt: number; + locked: boolean; +} + +export interface ConversionRecord { + id: string; + userId: string; + fromCurrency: string; + toAsset: string; + fiatAmount: number; + cryptoAmount: string; + rate: number; + status: ConversionStatus; + stripePaymentIntentId?: string; + stellarTxHash?: string; + expiresAt: number; + createdAt: number; + completedAt?: number; +} + +const rateLocks = new Map(); +const conversions = new Map(); + +const STELLAR_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const RATE_EXPIRY_MS = 5 * 60 * 1000; +const CONVERSION_EXPIRY_MS = 30 * 60 * 1000; + +function getCurrentRate(fromCurrency: string, toAsset: string): number { + if (fromCurrency === 'USD' && toAsset === 'USDC') return 1.0; + if (fromCurrency === 'EUR' && toAsset === 'USDC') return 1.08; + if (fromCurrency === 'GBP' && toAsset === 'USDC') return 1.26; + return 1.0; +} + +export async function lockRate(fromCurrency: string, toAsset: string, amount: number): Promise { + const rate = getCurrentRate(fromCurrency, toAsset); + const lock: RateLock = { + id: randomUUID(), + fromCurrency, + toAsset, + rate, + amount, + expiresAt: Date.now() + RATE_EXPIRY_MS, + locked: true, + }; + rateLocks.set(lock.id, lock); + return lock; +} + +export async function confirmConversion(rateLockId: string, stripePaymentIntentId: string): Promise { + const lock = rateLocks.get(rateLockId); + if (!lock) throw new Error('Rate lock not found'); + if (Date.now() > lock.expiresAt) throw new Error('Rate lock has expired'); + + const cryptoAmount = (lock.amount * lock.rate).toFixed(7); + const conversion: ConversionRecord = { + id: randomUUID(), + userId: 'user_default', + fromCurrency: lock.fromCurrency, + toAsset: lock.toAsset, + fiatAmount: lock.amount, + cryptoAmount, + rate: lock.rate, + status: 'completed', + stripePaymentIntentId, + expiresAt: lock.expiresAt, + createdAt: Date.now(), + completedAt: Date.now(), + }; + conversions.set(conversion.id, conversion); + + await auditService.logAction({ action: 'fiat_crypto.conversion_completed', resource: 'conversion', resourceId: conversion.id, details: { fiatAmount: lock.amount, cryptoAmount, rate: lock.rate } }); + return conversion; +} + +export async function settleToStellar(destinationAddress: string, amount: string, asset: string): Promise { + const simulatedTxHash = `stellar_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + + await auditService.logAction({ action: 'fiat_crypto.settled_to_stellar', resource: 'stellar_settlement', details: { destination: destinationAddress, amount, asset, txHash: simulatedTxHash } }); + return simulatedTxHash; +} + +export async function processFiatRefund(conversionId: string): Promise { + const conversion = conversions.get(conversionId); + if (!conversion || conversion.status !== 'completed') return false; + + const stripe = getStripe(); + if (conversion.stripePaymentIntentId) { + await stripe.refunds.create({ payment_intent: conversion.stripePaymentIntentId }); + } + + conversion.status = 'failed'; + conversions.set(conversionId, conversion); + + await auditService.logAction({ action: 'fiat_crypto.refund_processed', resource: 'conversion', resourceId: conversionId }); + return true; +} + +export async function getConversion(id: string): Promise { + return conversions.get(id); +} + +export async function listConversions(userId?: string): Promise { + const all = Array.from(conversions.values()); + return userId ? all.filter(c => c.userId === userId) : all; +} + +export function getRate(fromCurrency: string, toAsset: string): number { + return getCurrentRate(fromCurrency, toAsset); +} diff --git a/backend/src/services/routing.ts b/backend/src/services/routing.ts new file mode 100644 index 00000000..854508dc --- /dev/null +++ b/backend/src/services/routing.ts @@ -0,0 +1,31 @@ +import { paymentRouter, networkRegistry, routeScorer } from '../payments/index.js'; +import type { RouteRequest, RouteResult, RoutingPreference, ScoredRoute } from '../payments/index.js'; +import type { NetworkId, NetworkRoute, NetworkHealth } from '../payments/index.js'; + +export async function findRoute(request: RouteRequest): Promise { + return paymentRouter.route(request); +} + +export async function executeRoute(request: RouteRequest): Promise<{ route: ScoredRoute; txHash: string }> { + return paymentRouter.executeWithFallback(request); +} + +export function getNetworkRoutes(): Record { + return networkRegistry.getRouteStats(); +} + +export function updateNetworkHealth(network: NetworkId, health: Partial): void { + networkRegistry.updateHealth(network, health); +} + +export function getRouteAnalytics() { + return paymentRouter.getRouteAnalytics(); +} + +export function getRouteStrategies(): string[] { + return ['cost', 'speed', 'reliability', 'balanced']; +} + +export function getScoredRoutes(preference: RoutingPreference): ScoredRoute[] { + return routeScorer.scoreAll(preference); +} diff --git a/backend/src/services/stripe-connect.ts b/backend/src/services/stripe-connect.ts new file mode 100644 index 00000000..8a2af8c3 --- /dev/null +++ b/backend/src/services/stripe-connect.ts @@ -0,0 +1,109 @@ +import Stripe from 'stripe'; +import { getStripe } from './stripe.js'; +import { config } from '../config/env.js'; +import { AppError } from '../middleware/errorHandler.js'; +import { auditService } from './auditService.js'; + +export type ConnectOnboardingStatus = 'not_started' | 'onboarding' | 'completed' | 'disabled'; + +export interface ConnectedAccount { + stripeAccountId: string; + merchantId: string; + onboardingStatus: ConnectOnboardingStatus; + chargesEnabled: boolean; + payoutsEnabled: boolean; + createdAt: number; + completedAt?: number; +} + +const connectedAccounts = new Map(); + +export async function createConnectedAccount(merchantId: string, email: string): Promise<{ accountId: string; onboardingUrl: string }> { + const stripe = getStripe(); + const account = await stripe.accounts.create({ + type: 'express', + email, + business_type: 'individual', + capabilities: { card_payments: { requested: true }, transfers: { requested: true } }, + }); + + const record: ConnectedAccount = { + stripeAccountId: account.id, + merchantId, + onboardingStatus: 'onboarding', + chargesEnabled: false, + payoutsEnabled: false, + createdAt: Date.now(), + }; + connectedAccounts.set(merchantId, record); + + const onboardingUrl = await createOnboardingLink(account.id); + + await auditService.logAction({ action: 'stripe_connect.account_created', resource: 'stripe_connect', resourceId: account.id, details: { merchantId } }); + return { accountId: account.id, onboardingUrl }; +} + +async function createOnboardingLink(accountId: string): Promise { + const stripe = getStripe(); + const cfg = config(); + const link = await stripe.accountLinks.create({ + account: accountId, + refresh_url: `${cfg.APP_URL}/dashboard/stripe/refresh`, + return_url: `${cfg.APP_URL}/dashboard/stripe/complete`, + type: 'account_onboarding', + }); + return link.url; +} + +export async function getOnboardingStatus(merchantId: string): Promise { + return connectedAccounts.get(merchantId); +} + +export async function handleConnectWebhook(event: Stripe.Event): Promise { + const stripe = getStripe(); + + switch (event.type) { + case 'account.updated': { + const account = event.data.object as Stripe.Account; + const merchantId = findMerchantByAccountId(account.id); + if (merchantId) { + const record = connectedAccounts.get(merchantId)!; + record.chargesEnabled = account.charges_enabled; + record.payoutsEnabled = account.payouts_enabled; + if (account.charges_enabled && account.payouts_enabled && record.onboardingStatus !== 'completed') { + record.onboardingStatus = 'completed'; + record.completedAt = Date.now(); + } + connectedAccounts.set(merchantId, record); + } + break; + } + } + + await auditService.logAction({ action: 'stripe_connect.webhook_received', resource: 'stripe_connect', details: { eventType: event.type } }); +} + +function findMerchantByAccountId(stripeAccountId: string): string | undefined { + for (const [merchantId, record] of connectedAccounts) { + if (record.stripeAccountId === stripeAccountId) return merchantId; + } + return undefined; +} + +export async function createPaymentIntentWithConnect(amount: number, currency: string, merchantStripeAccountId: string): Promise { + const stripe = getStripe(); + return stripe.paymentIntents.create({ + amount, + currency: currency.toLowerCase(), + payment_method_types: ['card'], + application_fee_amount: Math.round(amount * 0.005), + transfer_data: { destination: merchantStripeAccountId }, + }); +} + +export async function createTransfer(amount: number, currency: string, destination: string): Promise { + const stripe = getStripe(); + return stripe.transfers.create({ amount, currency: currency.toLowerCase(), destination }); +} + +export { connectedAccounts }; diff --git a/frontend/app/dashboard/disputes/page.tsx b/frontend/app/dashboard/disputes/page.tsx index ac15de5b..141947eb 100644 --- a/frontend/app/dashboard/disputes/page.tsx +++ b/frontend/app/dashboard/disputes/page.tsx @@ -1,229 +1,349 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useDisputes } from "@/lib/hooks/useDisputes"; -import { - disputeStatusConfig, - disputeReasonLabels, -} from "@/lib/mock-data/disputes"; -import type { DisputeStatus } from "@/types/disputes"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - AlertTriangle, - Clock, - CheckCircle, - ChevronRight, - Plus, - Filter, -} from "lucide-react"; - -const STATUS_TABS: { label: string; value: DisputeStatus | "all" }[] = [ - { label: "All", value: "all" }, - { label: "Awaiting Response", value: "awaiting_response" }, - { label: "Under Review", value: "under_review" }, - { label: "Escalated", value: "escalated" }, - { label: "Resolved", value: "resolved" }, - { label: "Dismissed", value: "dismissed" }, -]; - -function StatusBadge({ status }: { status: DisputeStatus }) { - const cfg = disputeStatusConfig[status]; +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { AlertCircle, CheckCircle2, Clock, FileText, MessageSquare, Scale, Upload } from 'lucide-react'; + +interface Dispute { + id: string; + projectId: string; + raisedBy: string; + raisedAgainst: string; + reason: string; + status: string; + evidence: Array<{ id: string; type: string; title: string; uploadedBy: string; uploadedAt: number }>; + arbitratorId?: string; + resolution?: { type: string; description: string; approvedBy: string }; + createdAt: number; + updatedAt: number; +} + +interface Arbitrator { + id: string; + name: string; + address: string; + specializations: string[]; + activeDisputes: number; + totalResolved: number; + rating: number; +} + +const statusColors: Record = { + opened: 'bg-yellow-100 text-yellow-800', + evidence_gathering: 'bg-blue-100 text-blue-800', + under_review: 'bg-purple-100 text-purple-800', + resolved: 'bg-green-100 text-green-800', + appealed: 'bg-orange-100 text-orange-800', + closed: 'bg-gray-100 text-gray-800', +}; + +function StatusBadge({ status }: { status: string }) { return ( - - {cfg.label} - + + {status.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase())} + ); } -function formatTimeLeft(deadline: string): string { - const diff = new Date(deadline).getTime() - Date.now(); - if (diff <= 0) return "Overdue"; - const hours = Math.floor(diff / 3600000); - if (hours < 24) return `${hours}h left`; - return `${Math.floor(hours / 24)}d left`; -} +function EvidenceUpload({ disputeId, onUploaded }: { disputeId: string; onUploaded: () => void }) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [type, setType] = useState<'document' | 'image' | 'message'>('document'); + const [url, setUrl] = useState(''); + const [uploading, setUploading] = useState(false); + + const handleUpload = useCallback(async () => { + if (!title || !url) return; + setUploading(true); + try { + await fetch(`/api/v1/disputes/${disputeId}/evidence`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, title, description, url, uploadedBy: 'current_user' }), + }); + setTitle(''); + setDescription(''); + setUrl(''); + onUploaded(); + } catch (err) { + console.error('Failed to upload evidence:', err); + } finally { + setUploading(false); + } + }, [disputeId, title, description, type, url, onUploaded]); -function DisputeCardSkeleton() { return ( - - -
-
- - - -
- -
-
-
+
+

Upload Evidence

+ + setTitle(e.target.value)} /> +