diff --git a/.gitignore b/.gitignore index daaac7bf..54508301 100644 --- a/.gitignore +++ b/.gitignore @@ -26,14 +26,46 @@ out/ # Debug *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* # Keys *.pem +# Tools .claude .lhci_reports/ bundle-reports/ trend-data.json + +# Package dist packages/*/dist/ frontend/src/generated/ -packages/contracts/src/generated/ \ No newline at end of file +packages/contracts/src/generated/ + +# Test snapshots & output +**/__snapshots__/ +**/e2e/__snapshots__/ +frontend/e2e/__snapshots__/ +output.txt +**/output.txt +frontend/components/layout/output.txt +frontend/app/output.txt + +# Turbo +.turbo/cookies/ +.turbo/daemon/ + +# TypeScript +*.tsbuildinfo + +# Playwright / test results +/playwright-report +/blob-report +/test-results +/.playwright +/playwright/.cache +/coverage + +# Misc +test-write.txt diff --git a/backend/src/controllers/ComplianceController.ts b/backend/src/controllers/ComplianceController.ts new file mode 100644 index 00000000..a6f37009 --- /dev/null +++ b/backend/src/controllers/ComplianceController.ts @@ -0,0 +1,169 @@ +/** + * ComplianceController.ts — Issue #597 + * + * HTTP layer for compliance dashboard. + * Handles request/response only — no business logic here. + */ + +import { Request, Response, NextFunction } from 'express'; +import { BaseController } from './BaseController.js'; +import { ComplianceService, JurisdictionCode, ComplianceMetricType, ComplianceAlertStatus } from '../services/complianceService.js'; + +export class ComplianceController extends BaseController { + constructor(private readonly complianceService: typeof ComplianceService) { + super(); + } + + getDashboard = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const metrics = this.complianceService.getDashboardMetrics(jurisdiction); + const jurisdictionStatus = this.complianceService.getJurisdictionStatus(); + const openAlerts = this.complianceService.getAlerts('open'); + + res.status(200).json({ + success: true, + data: { metrics, jurisdictionStatus, openAlerts: openAlerts.slice(0, 10), generatedAt: new Date().toISOString() }, + }); + }); + }; + + getMetrics = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const jurisdiction = (req.query.jurisdiction as JurisdictionCode) || 'GLOBAL'; + const complianceMetrics = this.complianceService.getMetrics(jurisdiction); + res.status(200).json({ success: true, data: complianceMetrics, jurisdiction }); + }); + }; + + getJurisdictions = async (_req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(_req, res, next, async (_req, res) => { + const jurisdictionStatus = this.complianceService.getJurisdictionStatus(); + res.status(200).json({ success: true, data: jurisdictionStatus }); + }); + }; + + getThresholds = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const thresholds = this.complianceService.getThresholds(jurisdiction); + res.status(200).json({ success: true, data: thresholds }); + }); + }; + + updateThreshold = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const { jurisdiction, metric } = req.params; + const { warningLevel, criticalLevel } = req.body; + const updated = this.complianceService.updateThreshold( + jurisdiction as JurisdictionCode, + metric as ComplianceMetricType, + { warningLevel, criticalLevel }, + ); + res.status(200).json({ success: true, data: updated }); + }); + }; + + getAlerts = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const status = req.query.status as ComplianceAlertStatus | undefined; + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const alertList = this.complianceService.getAlerts(status, jurisdiction); + res.status(200).json({ success: true, data: alertList, count: alertList.length }); + }); + }; + + evaluateThresholds = async (_req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(_req, res, next, async (_req, res) => { + const newAlerts = this.complianceService.evaluateThresholds(); + res.status(200).json({ + success: true, + data: newAlerts, + count: newAlerts.length, + message: `Evaluation complete. ${newAlerts.length} new alert(s) raised.`, + }); + }); + }; + + acknowledgeAlert = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const userId = String(req.body.userId || 'system'); + const alert = this.complianceService.acknowledgeAlert(String(String(req.params.id)), userId); + res.status(200).json({ success: true, data: alert }); + }); + }; + + resolveAlert = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const userId = String(req.body.userId || 'system'); + const alert = this.complianceService.resolveAlert(String(String(req.params.id)), userId); + res.status(200).json({ success: true, data: alert }); + }); + }; + + requestReport = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const { period, jurisdiction = 'GLOBAL' } = req.body; + if (!period) { + res.status(400).json({ success: false, error: { message: 'period is required (e.g. 2026-01)' } }); + return; + } + const report = await this.complianceService.requestReport(period, jurisdiction as JurisdictionCode); + res.status(202).json({ success: true, data: report }); + }); + }; + + listReports = async (_req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(_req, res, next, async (_req, res) => { + const reportList = this.complianceService.listReports(); + res.status(200).json({ success: true, data: reportList, count: reportList.length }); + }); + }; + + getReport = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const report = this.complianceService.getReport(String(req.params.id)); + if (!report) { + res.status(404).json({ success: false, error: { message: 'Report not found' } }); + return; + } + res.status(200).json({ success: true, data: report }); + }); + }; + + exportReportJSON = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const report = this.complianceService.getReport(String(req.params.id)); + if (!report) { + res.status(404).json({ success: false, error: { message: 'Report not found' } }); + return; + } + const json = this.complianceService.exportReportAsJSON(String(req.params.id)); + const filename = `compliance-report-${report.jurisdiction}-${report.period}.json`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(json); + }); + }; + + exportCSV = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const jurisdiction = (req.query.jurisdiction as JurisdictionCode) || 'GLOBAL'; + const csv = this.complianceService.exportMetricsAsCSV(jurisdiction); + const filename = `compliance-metrics-${jurisdiction}-${new Date().toISOString().slice(0, 10)}.csv`; + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.status(200).send(csv); + }); + }; + + getAuditTrail = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const entityType = req.query.entityType as string | undefined; + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const limit = Math.min(parseInt(String(req.query.limit || '100')), 500); + const entries = this.complianceService.getAuditTrail(entityType, jurisdiction, limit); + res.status(200).json({ success: true, data: entries, count: entries.length }); + }); + }; +} diff --git a/backend/src/controllers/OnboardingController.ts b/backend/src/controllers/OnboardingController.ts new file mode 100644 index 00000000..2a5e4967 --- /dev/null +++ b/backend/src/controllers/OnboardingController.ts @@ -0,0 +1,120 @@ +/** + * OnboardingController.ts — Issue #597 + * + * HTTP layer for onboarding wizard API. + * Handles request/response only — no business logic here. + */ + +import { Request, Response, NextFunction } from 'express'; +import { BaseController } from './BaseController.js'; +import { OnboardingService } from '../services/onboarding.js'; +import { OnboardingAnalyticsService } from '../services/onboardingAnalytics.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export class OnboardingController extends BaseController { + constructor( + private readonly onboardingService: typeof OnboardingService, + private readonly analyticsService: typeof OnboardingAnalyticsService, + ) { + super(); + } + + createOnboarding = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + this.validateRequired(req.body, ['merchantId', 'businessName', 'businessType', 'contactEmail']); + const onboarding = await this.onboardingService.createOnboarding(req.body); + res.status(201).json({ success: true, data: onboarding }); + }); + }; + + getOnboarding = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.getOnboarding(String(req.params.id)); + if (!onboarding) throw new AppError(404, 'Onboarding not found', 'ONBOARDING_NOT_FOUND'); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + getByMerchant = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.getOnboardingByMerchant(String(req.params.merchantId)); + if (!onboarding) throw new AppError(404, 'Onboarding not found for merchant', 'ONBOARDING_NOT_FOUND'); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + updateTask = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.updateTask(String(req.params.id), req.body); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + submitDocument = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.submitDocument(String(req.params.id), req.body); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + skipTask = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.skipTask(String(req.params.id), req.body); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + submitForReview = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.submitForReview(String(req.params.id)); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + adminReview = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const onboarding = await this.onboardingService.adminReview(req.body); + res.status(200).json({ success: true, data: onboarding }); + }); + }; + + listOnboardings = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const { status } = req.query as { status?: string }; + const onboardings = await this.onboardingService.getAllOnboardings(status as any); + res.status(200).json({ success: true, data: onboardings, count: onboardings.length }); + }); + }; + + // ─── Analytics ──────────────────────────────────────────────────────────────── + + upsertAnalyticsSession = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const session = this.analyticsService.upsertSession(req.body); + res.status(200).json({ success: true, data: session }); + }); + }; + + getAnalyticsSummary = async (_req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(_req, res, next, async (_req, res) => { + const summary = this.analyticsService.getSummary(); + res.status(200).json({ success: true, data: summary }); + }); + }; + + listAnalyticsSessions = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const { role, variant } = req.query as { role?: string; variant?: string }; + const sessionList = this.analyticsService.listSessions({ role: role as any, variant: variant as any }); + res.status(200).json({ success: true, data: sessionList, count: sessionList.length }); + }); + }; + + getAnalyticsSession = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const session = this.analyticsService.getSession(String(req.params.sessionId)); + if (!session) throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND'); + res.status(200).json({ success: true, data: session }); + }); + }; +} diff --git a/backend/src/controllers/WalletController.ts b/backend/src/controllers/WalletController.ts new file mode 100644 index 00000000..9bed9e6f --- /dev/null +++ b/backend/src/controllers/WalletController.ts @@ -0,0 +1,126 @@ +/** + * WalletController.ts — Issue #597 + * + * HTTP layer for wallet aggregation API. + * Handles request/response only — no business logic here. + */ + +import { Request, Response, NextFunction } from 'express'; +import { BaseController } from './BaseController.js'; +import { WalletAggregationService, ChainType, RoutingStrategy } from '../services/walletAggregation.js'; + +export class WalletController extends BaseController { + constructor(private readonly walletService: typeof WalletAggregationService) { + super(); + } + + getSupportedChains = async (_req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(_req, res, next, async (_req, res) => { + const chains = this.walletService.getSupportedChains(); + res.status(200).json({ success: true, data: chains }); + }); + }; + + connectWallet = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + this.validateRequired(req.body, ['userId', 'chainType', 'address', 'providerName']); + const { userId, chainType, address, providerName } = req.body; + const connection = this.walletService.connectWallet( + userId, + chainType as ChainType, + address, + providerName, + ); + res.status(201).json({ success: true, data: connection }); + }); + }; + + disconnectWallet = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const disconnected = this.walletService.disconnectWallet(String(req.params.connectionId)); + if (!disconnected) { + res.status(404).json({ success: false, error: { message: 'Wallet connection not found' } }); + return; + } + res.status(200).json({ success: true, message: 'Wallet disconnected' }); + }); + }; + + getUserWallets = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const userId = String(req.query.userId || ''); + if (!userId) { + res.status(400).json({ success: false, error: { message: 'userId is required' } }); + return; + } + const wallets = this.walletService.getUserWallets(userId); + res.status(200).json({ success: true, data: wallets, count: wallets.length }); + }); + }; + + getAggregatedBalance = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const userId = String(req.query.userId || ''); + if (!userId) { + res.status(400).json({ success: false, error: { message: 'userId is required' } }); + return; + } + const balance = await this.walletService.getAggregatedBalance(userId); + res.status(200).json({ success: true, data: balance }); + }); + }; + + getRoutes = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const { source, destination, amount } = req.query; + if (!source || !destination) { + res.status(400).json({ success: false, error: { message: 'source and destination are required' } }); + return; + } + const routes = this.walletService.getRoutes( + source as ChainType, + destination as ChainType, + String(amount || '0'), + ); + res.status(200).json({ success: true, data: routes }); + }); + }; + + initiateTransfer = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + this.validateRequired(req.body, [ + 'sourceChain', 'destinationChain', 'sourceAddress', 'destinationAddress', 'assetCode', 'amount', + ]); + const tx = await this.walletService.initiateCrossChainTransfer({ + userId: req.body.userId || 'anonymous', + ...req.body, + strategy: req.body.strategy as RoutingStrategy | undefined, + }); + res.status(202).json({ success: true, data: tx }); + }); + }; + + getTransaction = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const tx = this.walletService.getTransaction(String(req.params.id)); + if (!tx) { + res.status(404).json({ success: false, error: { message: 'Transaction not found' } }); + return; + } + res.status(200).json({ success: true, data: tx }); + }); + }; + + getTransactionHistory = async (req: Request, res: Response, next: NextFunction): Promise => { + await this.execute(req, res, next, async (req, res) => { + const userId = String(req.query.userId || ''); + if (!userId) { + res.status(400).json({ success: false, error: { message: 'userId is required' } }); + return; + } + const limit = Math.min(parseInt(String(req.query.limit || '50')), 200); + const history = this.walletService.getTransactionHistory(userId, limit); + res.status(200).json({ success: true, data: history, count: history.length }); + }); + }; +} diff --git a/backend/src/di/index.ts b/backend/src/di/index.ts index ba9f72e3..ee6a2f99 100644 --- a/backend/src/di/index.ts +++ b/backend/src/di/index.ts @@ -9,11 +9,17 @@ import { container } from './container.js'; import { registerProjectModule } from './modules/project.module.js'; import { registerAuthModule } from './modules/auth.module.js'; import { registerPaymentModule } from './modules/payment.module.js'; +import { registerComplianceModule } from './modules/compliance.module.js'; +import { registerOnboardingModule } from './modules/onboarding.module.js'; +import { registerWalletModule } from './modules/wallet.module.js'; export function bootstrapDI(): void { registerProjectModule(container); registerAuthModule(container); registerPaymentModule(container); + registerComplianceModule(container); + registerOnboardingModule(container); + registerWalletModule(container); if (process.env.DI_VALIDATION !== 'false') { try { diff --git a/backend/src/di/modules/compliance.module.ts b/backend/src/di/modules/compliance.module.ts new file mode 100644 index 00000000..9a8e7f61 --- /dev/null +++ b/backend/src/di/modules/compliance.module.ts @@ -0,0 +1,53 @@ +/** + * compliance.module.ts — Issue #597 + * + * Compliance domain DI module — registers controller, service, and repositories. + */ +import type { DIContainer } from '../container.js'; + +export function registerComplianceModule(c: DIContainer): void { + c.register( + 'ComplianceAlertRepository', + () => { + const { ComplianceAlertRepository } = require('../../repositories/ComplianceRepository.js'); + return new ComplianceAlertRepository(); + }, + 'singleton', + ); + + c.register( + 'ComplianceReportRepository', + () => { + const { ComplianceReportRepository } = require('../../repositories/ComplianceRepository.js'); + return new ComplianceReportRepository(); + }, + 'singleton', + ); + + c.register( + 'AuditTrailRepository', + () => { + const { AuditTrailRepository } = require('../../repositories/ComplianceRepository.js'); + return new AuditTrailRepository(); + }, + 'singleton', + ); + + c.register( + 'ComplianceService', + () => { + const { ComplianceService } = require('../../services/complianceService.js'); + return ComplianceService; + }, + 'singleton', + ); + + c.register( + 'ComplianceController', + (c) => { + const { ComplianceController } = require('../../controllers/ComplianceController.js'); + return new ComplianceController(c.get('ComplianceService')); + }, + 'singleton', + ); +} diff --git a/backend/src/di/modules/onboarding.module.ts b/backend/src/di/modules/onboarding.module.ts new file mode 100644 index 00000000..9242956c --- /dev/null +++ b/backend/src/di/modules/onboarding.module.ts @@ -0,0 +1,56 @@ +/** + * onboarding.module.ts — Issue #597 + * + * Onboarding domain DI module — registers controller, services, and repositories. + */ +import type { DIContainer } from '../container.js'; + +export function registerOnboardingModule(c: DIContainer): void { + c.register( + 'OnboardingRepository', + () => { + const { OnboardingRepository } = require('../../repositories/OnboardingRepository.js'); + return new OnboardingRepository(); + }, + 'singleton', + ); + + c.register( + 'OnboardingAnalyticsRepository', + () => { + const { OnboardingAnalyticsRepository } = require('../../repositories/OnboardingRepository.js'); + return new OnboardingAnalyticsRepository(); + }, + 'singleton', + ); + + c.register( + 'OnboardingService', + () => { + const { OnboardingService } = require('../../services/onboarding.js'); + return OnboardingService; + }, + 'singleton', + ); + + c.register( + 'OnboardingAnalyticsService', + () => { + const { OnboardingAnalyticsService } = require('../../services/onboardingAnalytics.js'); + return OnboardingAnalyticsService; + }, + 'singleton', + ); + + c.register( + 'OnboardingController', + (c) => { + const { OnboardingController } = require('../../controllers/OnboardingController.js'); + return new OnboardingController( + c.get('OnboardingService'), + c.get('OnboardingAnalyticsService'), + ); + }, + 'singleton', + ); +} diff --git a/backend/src/di/modules/wallet.module.ts b/backend/src/di/modules/wallet.module.ts new file mode 100644 index 00000000..e2ed7534 --- /dev/null +++ b/backend/src/di/modules/wallet.module.ts @@ -0,0 +1,26 @@ +/** + * wallet.module.ts — Issue #597 + * + * Wallet domain DI module — registers controller and service. + */ +import type { DIContainer } from '../container.js'; + +export function registerWalletModule(c: DIContainer): void { + c.register( + 'WalletAggregationService', + () => { + const { WalletAggregationService } = require('../../services/walletAggregation.js'); + return WalletAggregationService; + }, + 'singleton', + ); + + c.register( + 'WalletController', + (c) => { + const { WalletController } = require('../../controllers/WalletController.js'); + return new WalletController(c.get('WalletAggregationService')); + }, + 'singleton', + ); +} diff --git a/backend/src/jobs/compliance-report.job.ts b/backend/src/jobs/compliance-report.job.ts new file mode 100644 index 00000000..20072e17 --- /dev/null +++ b/backend/src/jobs/compliance-report.job.ts @@ -0,0 +1,76 @@ +/** + * compliance-report.job.ts — Issue #590 + * + * Scheduled job for automated monthly compliance report generation. + * Runs on first day of each month (configurable), evaluates thresholds, + * generates reports for each jurisdiction, and raises alerts for breaches. + */ + +import { ComplianceService, JurisdictionCode } from '../services/complianceService.js'; + +const JURISDICTIONS: JurisdictionCode[] = ['GLOBAL', 'US', 'EU', 'UK', 'SG', 'AU']; + +export interface ComplianceJobResult { + reportsGenerated: number; + alertsRaised: number; + errors: string[]; + completedAt: string; +} + +/** + * Generate monthly compliance reports for all jurisdictions. + * Called by the scheduler; can also be triggered manually via admin API. + */ +export async function generateMonthlyComplianceReports(): Promise { + const result: ComplianceJobResult = { + reportsGenerated: 0, + alertsRaised: 0, + errors: [], + completedAt: '', + }; + + // Period: previous month (e.g. "2026-06" when running in July) + const now = new Date(); + const year = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(); + const month = now.getMonth() === 0 ? 12 : now.getMonth(); + const period = `${year}-${String(month).padStart(2, '0')}`; + + // 1. Evaluate thresholds and raise alerts + try { + const newAlerts = ComplianceService.evaluateThresholds(); + result.alertsRaised = newAlerts.length; + } catch (err) { + result.errors.push(`Alert evaluation failed: ${(err as Error).message}`); + } + + // 2. Generate report for each jurisdiction + for (const jurisdiction of JURISDICTIONS) { + try { + const report = await ComplianceService.requestReport(period, jurisdiction); + // Simulate async generation then mark ready + ComplianceService.markReportReady(report.id); + result.reportsGenerated++; + } catch (err) { + result.errors.push(`Report generation failed for ${jurisdiction}: ${(err as Error).message}`); + } + } + + result.completedAt = new Date().toISOString(); + return result; +} + +/** + * Evaluate thresholds and raise alerts. + * Can run more frequently than full reports (e.g. hourly). + */ +export async function evaluateComplianceThresholds(): Promise { + ComplianceService.evaluateThresholds(); +} + +/** + * Export compliance metrics for regulatory submission. + * Called before end-of-quarter deadlines. + */ +export async function exportComplianceMetrics(jurisdiction: JurisdictionCode = 'GLOBAL'): Promise { + return ComplianceService.exportMetricsAsCSV(jurisdiction); +} diff --git a/backend/src/repositories/ComplianceRepository.ts b/backend/src/repositories/ComplianceRepository.ts new file mode 100644 index 00000000..2c99bbf9 --- /dev/null +++ b/backend/src/repositories/ComplianceRepository.ts @@ -0,0 +1,159 @@ +/** + * ComplianceRepository.ts — Issue #597 + * + * Data access layer for compliance records. + * Currently uses in-memory storage; swap out for Prisma/Redis via factory.ts. + */ + +import { BaseRepository, PaginationOptions, PaginatedResult } from './BaseRepository.js'; +import { ComplianceAlert, ComplianceReport, AuditTrailEntry } from '../services/complianceService.js'; + +// ─── Alert repository ───────────────────────────────────────────────────────── + +export class ComplianceAlertRepository extends BaseRepository { + private store: Map = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findAll(options: PaginationOptions): Promise> { + const all = Array.from(this.store.values()).sort( + (a, b) => new Date(b.triggeredAt).getTime() - new Date(a.triggeredAt).getTime(), + ); + let startIndex = 0; + if (options.cursor) { + const idx = all.findIndex((a) => a.id === options.cursor); + startIndex = idx >= 0 ? idx + 1 : 0; + } + const items = all.slice(startIndex, startIndex + options.limit); + const hasMore = startIndex + options.limit < all.length; + return { items, hasMore, nextCursor: hasMore ? items.at(-1)?.id : undefined }; + } + + async create(data: Partial): Promise { + if (!data.id) throw new Error('Alert id is required'); + this.store.set(data.id, data as ComplianceAlert); + return data as ComplianceAlert; + } + + async update(id: string, data: Partial): Promise { + const existing = this.store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data }; + this.store.set(id, updated); + return updated; + } + + async delete(id: string): Promise { + return this.store.delete(id); + } + + async count(filters?: Record): Promise { + if (!filters) return this.store.size; + return Array.from(this.store.values()).filter((a) => + Object.entries(filters).every(([k, v]) => (a as unknown as Record)[k] === v), + ).length; + } + + findByStatus(status: string): ComplianceAlert[] { + return Array.from(this.store.values()).filter((a) => a.status === status); + } + + findByJurisdiction(jurisdiction: string): ComplianceAlert[] { + return Array.from(this.store.values()).filter((a) => a.jurisdiction === jurisdiction); + } +} + +// ─── Report repository ──────────────────────────────────────────────────────── + +export class ComplianceReportRepository extends BaseRepository { + private store: Map = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findAll(options: PaginationOptions): Promise> { + const all = Array.from(this.store.values()).sort( + (a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime(), + ); + let startIndex = 0; + if (options.cursor) { + const idx = all.findIndex((r) => r.id === options.cursor); + startIndex = idx >= 0 ? idx + 1 : 0; + } + const items = all.slice(startIndex, startIndex + options.limit); + const hasMore = startIndex + options.limit < all.length; + return { items, hasMore, nextCursor: hasMore ? items.at(-1)?.id : undefined }; + } + + async create(data: Partial): Promise { + if (!data.id) throw new Error('Report id is required'); + this.store.set(data.id, data as ComplianceReport); + return data as ComplianceReport; + } + + async update(id: string, data: Partial): Promise { + const existing = this.store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data }; + this.store.set(id, updated); + return updated; + } + + async delete(id: string): Promise { + return this.store.delete(id); + } + + async count(): Promise { + return this.store.size; + } +} + +// ─── Audit trail repository ─────────────────────────────────────────────────── + +export class AuditTrailRepository extends BaseRepository { + private entries: AuditTrailEntry[] = []; + + async findById(id: string): Promise { + return this.entries.find((e) => e.id === id) ?? null; + } + + async findAll(options: PaginationOptions): Promise> { + const sorted = [...this.entries].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + let startIndex = 0; + if (options.cursor) { + const idx = sorted.findIndex((e) => e.id === options.cursor); + startIndex = idx >= 0 ? idx + 1 : 0; + } + const items = sorted.slice(startIndex, startIndex + options.limit); + const hasMore = startIndex + options.limit < sorted.length; + return { items, hasMore, nextCursor: hasMore ? items.at(-1)?.id : undefined }; + } + + async create(data: Partial): Promise { + if (!data.id) throw new Error('Entry id is required'); + this.entries.push(data as AuditTrailEntry); + return data as AuditTrailEntry; + } + + async update(id: string, data: Partial): Promise { + const idx = this.entries.findIndex((e) => e.id === id); + if (idx === -1) return null; + this.entries[idx] = { ...this.entries[idx], ...data }; + return this.entries[idx]; + } + + async delete(id: string): Promise { + const before = this.entries.length; + this.entries = this.entries.filter((e) => e.id !== id); + return this.entries.length < before; + } + + async count(): Promise { + return this.entries.length; + } +} diff --git a/backend/src/repositories/OnboardingRepository.ts b/backend/src/repositories/OnboardingRepository.ts new file mode 100644 index 00000000..20bbdc9d --- /dev/null +++ b/backend/src/repositories/OnboardingRepository.ts @@ -0,0 +1,116 @@ +/** + * OnboardingRepository.ts — Issue #597 + * + * Data access layer for onboarding records. + * Currently uses in-memory storage; swap out for Prisma via factory.ts. + */ + +import { BaseRepository, PaginationOptions, PaginatedResult } from './BaseRepository.js'; +import { MerchantOnboarding } from '../services/onboarding.js'; +import { OnboardingSession } from '../services/onboardingAnalytics.js'; + +// ─── Onboarding record repository ───────────────────────────────────────────── + +export class OnboardingRepository extends BaseRepository { + private store: Map = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findAll(options: PaginationOptions): Promise> { + const all = Array.from(this.store.values()).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + let startIndex = 0; + if (options.cursor) { + const idx = all.findIndex((o) => o.id === options.cursor); + startIndex = idx >= 0 ? idx + 1 : 0; + } + const items = all.slice(startIndex, startIndex + options.limit); + const hasMore = startIndex + options.limit < all.length; + return { items, hasMore, nextCursor: hasMore ? items.at(-1)?.id : undefined }; + } + + async create(data: Partial): Promise { + if (!data.id) throw new Error('Onboarding id is required'); + this.store.set(data.id, data as MerchantOnboarding); + return data as MerchantOnboarding; + } + + async update(id: string, data: Partial): Promise { + const existing = this.store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data, updatedAt: new Date().toISOString() }; + this.store.set(id, updated); + return updated; + } + + async delete(id: string): Promise { + return this.store.delete(id); + } + + async count(filters?: Record): Promise { + if (!filters) return this.store.size; + return Array.from(this.store.values()).filter((o) => + Object.entries(filters).every(([k, v]) => (o as unknown as Record)[k] === v), + ).length; + } + + findByMerchantId(merchantId: string): MerchantOnboarding | null { + for (const onboarding of this.store.values()) { + if (onboarding.merchantId === merchantId) return onboarding; + } + return null; + } + + findByStatus(status: string): MerchantOnboarding[] { + return Array.from(this.store.values()).filter((o) => o.status === status); + } +} + +// ─── Analytics session repository ───────────────────────────────────────────── + +export class OnboardingAnalyticsRepository extends BaseRepository { + private store: Map = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findAll(options: PaginationOptions): Promise> { + const all = Array.from(this.store.values()).sort( + (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ); + let startIndex = 0; + if (options.cursor) { + const idx = all.findIndex((s) => s.sessionId === options.cursor); + startIndex = idx >= 0 ? idx + 1 : 0; + } + const items = all.slice(startIndex, startIndex + options.limit); + const hasMore = startIndex + options.limit < all.length; + return { items, hasMore, nextCursor: hasMore ? items.at(-1)?.sessionId : undefined }; + } + + async create(data: Partial): Promise { + if (!data.sessionId) throw new Error('sessionId is required'); + this.store.set(data.sessionId, data as OnboardingSession); + return data as OnboardingSession; + } + + async update(id: string, data: Partial): Promise { + const existing = this.store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data }; + this.store.set(id, updated); + return updated; + } + + async delete(id: string): Promise { + return this.store.delete(id); + } + + async count(): Promise { + return this.store.size; + } +} diff --git a/backend/src/routes/compliance.ts b/backend/src/routes/compliance.ts index a00596cb..3401d91c 100644 --- a/backend/src/routes/compliance.ts +++ b/backend/src/routes/compliance.ts @@ -1,16 +1,245 @@ +/** + * compliance.ts (routes) — Issue #590 + * + * Real-time compliance dashboard API: + * - GET /compliance/metrics → real-time metrics + * - GET /compliance/dashboard → dashboard summary + * - GET /compliance/thresholds → list thresholds + * - PUT /compliance/thresholds/:jurisdiction/:metric → update threshold + * - GET /compliance/alerts → list alerts + * - POST /compliance/alerts/evaluate → trigger evaluation + * - POST /compliance/alerts/:id/acknowledge + * - POST /compliance/alerts/:id/resolve + * - GET /compliance/jurisdictions → per-jurisdiction status + * - POST /compliance/reports → request new report + * - GET /compliance/reports → list reports + * - GET /compliance/reports/:id → get report + * - GET /compliance/reports/:id/export → export report + * - GET /compliance/audit → audit trail + * - GET /compliance/export/csv → CSV export + * - GET /compliance/status (legacy) → basic checks + * - GET /compliance/evidence/audit/export (legacy) + */ + import { Router, Request, Response } from 'express'; import { asyncHandler } from '../middleware/errorHandler.js'; import { runComplianceChecks } from '../compliance/checks.js'; import { auditService } from '../services/auditService.js'; +import { ComplianceService, JurisdictionCode, ComplianceMetricType, ComplianceAlertStatus } from '../services/complianceService.js'; export const complianceRouter = Router(); +// ─── Dashboard ─────────────────────────────────────────────────────────────── + +complianceRouter.get( + '/dashboard', + asyncHandler(async (req: Request, res: Response) => { + const jurisdiction = (req.query.jurisdiction as JurisdictionCode) || undefined; + const metrics = ComplianceService.getDashboardMetrics(jurisdiction); + const jurisdictionStatus = ComplianceService.getJurisdictionStatus(); + const openAlerts = ComplianceService.getAlerts('open'); + + res.status(200).json({ + success: true, + data: { + metrics, + jurisdictionStatus, + openAlerts: openAlerts.slice(0, 10), + generatedAt: new Date().toISOString(), + }, + }); + }), +); + +// ─── Real-time metrics ──────────────────────────────────────────────────────── + +complianceRouter.get( + '/metrics', + asyncHandler(async (req: Request, res: Response) => { + const jurisdiction = (req.query.jurisdiction as JurisdictionCode) || 'GLOBAL'; + const complianceMetrics = ComplianceService.getMetrics(jurisdiction); + + res.status(200).json({ + success: true, + data: complianceMetrics, + jurisdiction, + }); + }), +); + +// ─── Jurisdictions ──────────────────────────────────────────────────────────── + +complianceRouter.get( + '/jurisdictions', + asyncHandler(async (_req: Request, res: Response) => { + const jurisdictionStatus = ComplianceService.getJurisdictionStatus(); + res.status(200).json({ success: true, data: jurisdictionStatus }); + }), +); + +// ─── Thresholds ─────────────────────────────────────────────────────────────── + +complianceRouter.get( + '/thresholds', + asyncHandler(async (req: Request, res: Response) => { + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const thresholds = ComplianceService.getThresholds(jurisdiction); + res.status(200).json({ success: true, data: thresholds }); + }), +); + +complianceRouter.put( + '/thresholds/:jurisdiction/:metric', + asyncHandler(async (req: Request, res: Response) => { + const { jurisdiction, metric } = req.params; + const { warningLevel, criticalLevel } = req.body; + + const updated = ComplianceService.updateThreshold( + jurisdiction as JurisdictionCode, + metric as ComplianceMetricType, + { warningLevel, criticalLevel }, + ); + + res.status(200).json({ success: true, data: updated }); + }), +); + +// ─── Alerts ─────────────────────────────────────────────────────────────────── + +complianceRouter.get( + '/alerts', + asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as ComplianceAlertStatus | undefined; + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const alertList = ComplianceService.getAlerts(status, jurisdiction); + + res.status(200).json({ + success: true, + data: alertList, + count: alertList.length, + }); + }), +); + +complianceRouter.post( + '/alerts/evaluate', + asyncHandler(async (_req: Request, res: Response) => { + const newAlerts = ComplianceService.evaluateThresholds(); + res.status(200).json({ + success: true, + data: newAlerts, + count: newAlerts.length, + message: `Threshold evaluation complete. ${newAlerts.length} new alert(s) raised.`, + }); + }), +); + +complianceRouter.post( + '/alerts/:id/acknowledge', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const userId = String(req.body.userId || 'system'); + const alert = ComplianceService.acknowledgeAlert(id, userId); + res.status(200).json({ success: true, data: alert }); + }), +); + +complianceRouter.post( + '/alerts/:id/resolve', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const userId = String(req.body.userId || 'system'); + const alert = ComplianceService.resolveAlert(id, userId); + res.status(200).json({ success: true, data: alert }); + }), +); + +// ─── Reports ────────────────────────────────────────────────────────────────── + +complianceRouter.post( + '/reports', + asyncHandler(async (req: Request, res: Response) => { + const { period, jurisdiction = 'GLOBAL' } = req.body; + if (!period) { + return res.status(400).json({ success: false, error: { message: 'period is required (e.g. 2026-01)' } }); + } + + const report = await ComplianceService.requestReport(period, jurisdiction as JurisdictionCode); + res.status(202).json({ + success: true, + data: report, + message: 'Compliance report requested. Use GET /compliance/reports/:id to check status.', + }); + }), +); + +complianceRouter.get( + '/reports', + asyncHandler(async (_req: Request, res: Response) => { + const reportList = ComplianceService.listReports(); + res.status(200).json({ success: true, data: reportList, count: reportList.length }); + }), +); + +complianceRouter.get( + '/reports/:id', + asyncHandler(async (req: Request, res: Response) => { + const report = ComplianceService.getReport(req.params.id); + if (!report) return res.status(404).json({ success: false, error: { message: 'Report not found' } }); + res.status(200).json({ success: true, data: report }); + }), +); + +complianceRouter.get( + '/reports/:id/export', + asyncHandler(async (req: Request, res: Response) => { + const report = ComplianceService.getReport(req.params.id); + if (!report) return res.status(404).json({ success: false, error: { message: 'Report not found' } }); + + const json = ComplianceService.exportReportAsJSON(req.params.id); + const filename = `compliance-report-${report.jurisdiction}-${report.period}.json`; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.status(200).send(json); + }), +); + +// ─── Audit trail ───────────────────────────────────────────────────────────── + +complianceRouter.get( + '/audit', + asyncHandler(async (req: Request, res: Response) => { + const entityType = req.query.entityType as string | undefined; + const jurisdiction = req.query.jurisdiction as JurisdictionCode | undefined; + const limit = Math.min(parseInt(String(req.query.limit || '100')), 500); + const entries = ComplianceService.getAuditTrail(entityType, jurisdiction, limit); + + res.status(200).json({ success: true, data: entries, count: entries.length }); + }), +); + +// ─── CSV export ─────────────────────────────────────────────────────────────── + +complianceRouter.get( + '/export/csv', + asyncHandler(async (req: Request, res: Response) => { + const jurisdiction = (req.query.jurisdiction as JurisdictionCode) || 'GLOBAL'; + const csv = ComplianceService.exportMetricsAsCSV(jurisdiction); + const filename = `compliance-metrics-${jurisdiction}-${new Date().toISOString().slice(0, 10)}.csv`; + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.status(200).send(csv); + }), +); + +// ─── Legacy endpoints (maintained for backwards compatibility) ───────────────── + complianceRouter.get( '/status', asyncHandler(async (_req: Request, res: Response) => { const checks = runComplianceChecks(); res.status(200).json({ checks }); - }) + }), ); complianceRouter.get( @@ -28,6 +257,5 @@ complianceRouter.get( const json = await auditService.exportToJSON(); res.setHeader('Content-Type', 'application/json'); res.status(200).send(json); - }) + }), ); - diff --git a/backend/src/routes/onboarding.ts b/backend/src/routes/onboarding.ts index f07839d8..680806eb 100644 --- a/backend/src/routes/onboarding.ts +++ b/backend/src/routes/onboarding.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { OnboardingService } from '../services/onboarding.js'; +import { OnboardingAnalyticsService } from '../services/onboardingAnalytics.js'; import { validate } from '../middleware/validate.js'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { @@ -149,4 +150,49 @@ onboardingRouter.get( count: onboardings.length, }); }) -); \ No newline at end of file +); + +// ─── Analytics endpoints (Issue #591) ──────────────────────────────────────── + +// Record / update an onboarding session from frontend +onboardingRouter.post( + '/analytics/session', + asyncHandler(async (req, res) => { + const session = OnboardingAnalyticsService.upsertSession(req.body); + res.status(200).json({ success: true, data: session }); + }), +); + +// Get analytics summary +onboardingRouter.get( + '/analytics/summary', + asyncHandler(async (_req, res) => { + const summary = OnboardingAnalyticsService.getSummary(); + res.status(200).json({ success: true, data: summary }); + }), +); + +// List sessions +onboardingRouter.get( + '/analytics/sessions', + asyncHandler(async (req, res) => { + const { role, variant } = req.query as { role?: string; variant?: string }; + const sessionList = OnboardingAnalyticsService.listSessions({ + role: role as any, + variant: variant as any, + }); + res.status(200).json({ success: true, data: sessionList, count: sessionList.length }); + }), +); + +// Get single session +onboardingRouter.get( + '/analytics/sessions/:sessionId', + asyncHandler(async (req, res) => { + const session = OnboardingAnalyticsService.getSession(req.params.sessionId); + if (!session) { + throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND'); + } + res.status(200).json({ success: true, data: session }); + }), +); diff --git a/backend/src/routes/wallet.ts b/backend/src/routes/wallet.ts new file mode 100644 index 00000000..0f366301 --- /dev/null +++ b/backend/src/routes/wallet.ts @@ -0,0 +1,185 @@ +/** + * wallet.ts (routes) — Issue #593 + * + * Cross-chain wallet abstraction API: + * - GET /wallet/chains → supported chains + * - POST /wallet/connect → register wallet connection + * - DELETE /wallet/:connectionId → disconnect wallet + * - GET /wallet/connections → list user's wallets + * - GET /wallet/aggregated → aggregated balance across chains + * - GET /wallet/routes → optimal transfer routes + * - POST /wallet/transfer → initiate cross-chain transfer + * - GET /wallet/transfer/:id → get transfer status + * - GET /wallet/history → transaction history + */ + +import { Router, Request, Response } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { WalletAggregationService, ChainType, RoutingStrategy } from '../services/walletAggregation.js'; + +export const walletRouter = Router(); + +// ─── Supported chains ───────────────────────────────────────────────────────── + +walletRouter.get( + '/chains', + asyncHandler(async (_req: Request, res: Response) => { + const chains = WalletAggregationService.getSupportedChains(); + res.status(200).json({ success: true, data: chains }); + }), +); + +// ─── Wallet connections ─────────────────────────────────────────────────────── + +walletRouter.post( + '/connect', + asyncHandler(async (req: Request, res: Response) => { + const { userId, chainType, address, providerName } = req.body; + if (!userId || !chainType || !address || !providerName) { + return res.status(400).json({ + success: false, + error: { message: 'userId, chainType, address, and providerName are required' }, + }); + } + + const connection = WalletAggregationService.connectWallet( + userId, + chainType as ChainType, + address, + providerName, + ); + + res.status(201).json({ success: true, data: connection }); + }), +); + +walletRouter.delete( + '/:connectionId', + asyncHandler(async (req: Request, res: Response) => { + const { connectionId } = req.params; + const disconnected = WalletAggregationService.disconnectWallet(connectionId); + + if (!disconnected) { + return res.status(404).json({ success: false, error: { message: 'Wallet connection not found' } }); + } + + res.status(200).json({ success: true, message: 'Wallet disconnected' }); + }), +); + +walletRouter.get( + '/connections', + asyncHandler(async (req: Request, res: Response) => { + const userId = String(req.query.userId || ''); + if (!userId) { + return res.status(400).json({ success: false, error: { message: 'userId is required' } }); + } + + const wallets = WalletAggregationService.getUserWallets(userId); + res.status(200).json({ success: true, data: wallets, count: wallets.length }); + }), +); + +// ─── Aggregated balance ─────────────────────────────────────────────────────── + +walletRouter.get( + '/aggregated', + asyncHandler(async (req: Request, res: Response) => { + const userId = String(req.query.userId || ''); + if (!userId) { + return res.status(400).json({ success: false, error: { message: 'userId is required' } }); + } + + const balance = await WalletAggregationService.getAggregatedBalance(userId); + res.status(200).json({ success: true, data: balance }); + }), +); + +// ─── Routing ────────────────────────────────────────────────────────────────── + +walletRouter.get( + '/routes', + asyncHandler(async (req: Request, res: Response) => { + const { source, destination, amount } = req.query; + + if (!source || !destination) { + return res.status(400).json({ success: false, error: { message: 'source and destination chains are required' } }); + } + + const routes = WalletAggregationService.getRoutes( + source as ChainType, + destination as ChainType, + String(amount || '0'), + ); + + res.status(200).json({ success: true, data: routes }); + }), +); + +// ─── Cross-chain transfer ───────────────────────────────────────────────────── + +walletRouter.post( + '/transfer', + asyncHandler(async (req: Request, res: Response) => { + const { + userId, + sourceChain, + destinationChain, + sourceAddress, + destinationAddress, + assetCode, + amount, + feePayCurrency, + strategy, + } = req.body; + + if (!sourceChain || !destinationChain || !sourceAddress || !destinationAddress || !assetCode || !amount) { + return res.status(400).json({ + success: false, + error: { message: 'sourceChain, destinationChain, sourceAddress, destinationAddress, assetCode, and amount are required' }, + }); + } + + const tx = await WalletAggregationService.initiateCrossChainTransfer({ + userId: userId || 'anonymous', + sourceChain: sourceChain as ChainType, + destinationChain: destinationChain as ChainType, + sourceAddress, + destinationAddress, + assetCode, + amount: String(amount), + feePayCurrency, + strategy: strategy as RoutingStrategy | undefined, + }); + + res.status(202).json({ success: true, data: tx }); + }), +); + +walletRouter.get( + '/transfer/:id', + asyncHandler(async (req: Request, res: Response) => { + const tx = WalletAggregationService.getTransaction(req.params.id); + if (!tx) { + return res.status(404).json({ success: false, error: { message: 'Transaction not found' } }); + } + res.status(200).json({ success: true, data: tx }); + }), +); + +// ─── Transaction history ────────────────────────────────────────────────────── + +walletRouter.get( + '/history', + asyncHandler(async (req: Request, res: Response) => { + const userId = String(req.query.userId || ''); + const limit = Math.min(parseInt(String(req.query.limit || '50')), 200); + + if (!userId) { + return res.status(400).json({ success: false, error: { message: 'userId is required' } }); + } + + const history = WalletAggregationService.getTransactionHistory(userId, limit); + res.status(200).json({ success: true, data: history, count: history.length }); + }), +); diff --git a/backend/src/services/complianceService.ts b/backend/src/services/complianceService.ts new file mode 100644 index 00000000..d18a882a --- /dev/null +++ b/backend/src/services/complianceService.ts @@ -0,0 +1,433 @@ +/** + * ComplianceService.ts — Issue #590 + * + * Real-time regulatory compliance monitoring service. + * Provides KYC/AML threshold monitoring, multi-jurisdiction tracking, + * alert generation, and audit-trail exports. + */ + +import { randomUUID } from 'crypto'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type JurisdictionCode = 'US' | 'EU' | 'UK' | 'SG' | 'AU' | 'GLOBAL'; +export type ComplianceAlertSeverity = 'info' | 'warning' | 'critical'; +export type ComplianceAlertStatus = 'open' | 'acknowledged' | 'resolved'; +export type ComplianceMetricType = 'kyc_verification_rate' | 'aml_flag_rate' | 'transaction_volume' | 'high_risk_ratio' | 'pep_hit_rate' | 'sanctions_hit_rate'; +export type ReportStatus = 'pending' | 'generating' | 'ready' | 'failed'; + +export interface ComplianceThreshold { + metric: ComplianceMetricType; + warningLevel: number; + criticalLevel: number; + jurisdiction: JurisdictionCode; + description: string; +} + +export interface ComplianceMetric { + metric: ComplianceMetricType; + value: number; + previousValue: number; + changePercent: number; + jurisdiction: JurisdictionCode; + timestamp: string; + status: 'pass' | 'warn' | 'critical'; +} + +export interface ComplianceAlert { + id: string; + type: ComplianceMetricType; + severity: ComplianceAlertSeverity; + status: ComplianceAlertStatus; + message: string; + details: Record; + jurisdiction: JurisdictionCode; + triggeredAt: string; + acknowledgedAt?: string; + resolvedAt?: string; + acknowledgedBy?: string; +} + +export interface JurisdictionStatus { + jurisdiction: JurisdictionCode; + overallStatus: 'compliant' | 'review_required' | 'non_compliant'; + kycComplianceRate: number; + amlFlagRate: number; + transactionVolume: number; + highRiskCount: number; + lastChecked: string; +} + +export interface ComplianceDashboardMetrics { + totalUsers: number; + verifiedUsers: number; + kycVerificationRate: number; + amlFlags: number; + amlFlagRate: number; + highRiskTransactions: number; + sanctionsHits: number; + pepHits: number; + openAlerts: number; + criticalAlerts: number; + generatedAt: string; +} + +export interface ComplianceReport { + id: string; + period: string; + jurisdiction: JurisdictionCode; + status: ReportStatus; + metrics: ComplianceDashboardMetrics; + jurisdictionBreakdown: JurisdictionStatus[]; + alerts: ComplianceAlert[]; + generatedAt?: string; + requestedAt: string; + exportUrl?: string; +} + +export interface AuditTrailEntry { + id: string; + action: string; + entityType: string; + entityId: string; + userId: string; + jurisdiction: JurisdictionCode; + details: Record; + timestamp: string; + ipAddress?: string; +} + +// ─── Default thresholds ─────────────────────────────────────────────────────── + +const DEFAULT_THRESHOLDS: ComplianceThreshold[] = [ + { metric: 'kyc_verification_rate', warningLevel: 85, criticalLevel: 70, jurisdiction: 'GLOBAL', description: 'KYC verification completion rate (%)' }, + { metric: 'aml_flag_rate', warningLevel: 2, criticalLevel: 5, jurisdiction: 'GLOBAL', description: 'AML flag rate per 1000 transactions (%)' }, + { metric: 'high_risk_ratio', warningLevel: 3, criticalLevel: 8, jurisdiction: 'GLOBAL', description: 'High-risk transaction ratio (%)' }, + { metric: 'pep_hit_rate', warningLevel: 0.5, criticalLevel: 1, jurisdiction: 'GLOBAL', description: 'Politically Exposed Person hit rate (%)' }, + { metric: 'sanctions_hit_rate', warningLevel: 0.1, criticalLevel: 0.5, jurisdiction: 'GLOBAL', description: 'Sanctions list hit rate (%)' }, + // US-specific + { metric: 'kyc_verification_rate', warningLevel: 90, criticalLevel: 80, jurisdiction: 'US', description: 'US FinCEN KYC requirement compliance rate' }, + { metric: 'aml_flag_rate', warningLevel: 1.5, criticalLevel: 3, jurisdiction: 'US', description: 'US BSA AML threshold' }, + // EU-specific (AMLD6) + { metric: 'kyc_verification_rate', warningLevel: 88, criticalLevel: 75, jurisdiction: 'EU', description: 'EU AMLD6 KYC compliance rate' }, + { metric: 'high_risk_ratio', warningLevel: 2, criticalLevel: 5, jurisdiction: 'EU', description: 'EU AMLD6 high-risk transaction ratio' }, +]; + +// ─── In-memory stores (replace with DB in production) ───────────────────────── + +const alerts = new Map(); +const reports = new Map(); +const auditTrail: AuditTrailEntry[] = []; +const thresholds = new Map(); + +// Initialise with defaults +DEFAULT_THRESHOLDS.forEach((t) => { + thresholds.set(`${t.jurisdiction}:${t.metric}`, t); +}); + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class ComplianceService { + /** + * Get real-time dashboard metrics. + * In production these would be aggregated from DB / data-warehouse. + */ + static getDashboardMetrics(jurisdiction?: JurisdictionCode): ComplianceDashboardMetrics { + // Simulated realistic metrics — wire to real DB in production + const base: ComplianceDashboardMetrics = { + totalUsers: 12_450, + verifiedUsers: 11_203, + kycVerificationRate: 90.0, + amlFlags: 143, + amlFlagRate: 1.2, + highRiskTransactions: 312, + sanctionsHits: 4, + pepHits: 11, + openAlerts: alerts.size === 0 ? 2 : Array.from(alerts.values()).filter((a) => a.status === 'open').length, + criticalAlerts: alerts.size === 0 ? 0 : Array.from(alerts.values()).filter((a) => a.severity === 'critical' && a.status === 'open').length, + generatedAt: new Date().toISOString(), + }; + + // Apply jurisdiction-specific adjustments + if (jurisdiction && jurisdiction !== 'GLOBAL') { + base.totalUsers = Math.floor(base.totalUsers * 0.3); + base.verifiedUsers = Math.floor(base.verifiedUsers * 0.3); + base.kycVerificationRate = Number((base.verifiedUsers / base.totalUsers * 100).toFixed(2)); + } + + return base; + } + + /** + * Get real-time compliance metrics against thresholds. + */ + static getMetrics(jurisdiction: JurisdictionCode = 'GLOBAL'): ComplianceMetric[] { + const dashboard = this.getDashboardMetrics(jurisdiction); + const now = new Date().toISOString(); + + const rawMetrics: Array<{ metric: ComplianceMetricType; value: number; previous: number }> = [ + { metric: 'kyc_verification_rate', value: dashboard.kycVerificationRate, previous: 89.2 }, + { metric: 'aml_flag_rate', value: dashboard.amlFlagRate, previous: 1.0 }, + { metric: 'high_risk_ratio', value: (dashboard.highRiskTransactions / dashboard.totalUsers) * 100, previous: 2.1 }, + { metric: 'pep_hit_rate', value: (dashboard.pepHits / dashboard.totalUsers) * 100, previous: 0.08 }, + { metric: 'sanctions_hit_rate', value: (dashboard.sanctionsHits / dashboard.totalUsers) * 100, previous: 0.02 }, + ]; + + return rawMetrics.map(({ metric, value, previous }) => { + const threshold = thresholds.get(`${jurisdiction}:${metric}`) || thresholds.get(`GLOBAL:${metric}`); + let status: 'pass' | 'warn' | 'critical' = 'pass'; + + if (threshold) { + // For rate metrics: lower is worse (kyc_verification_rate) + if (metric === 'kyc_verification_rate') { + if (value <= threshold.criticalLevel) status = 'critical'; + else if (value <= threshold.warningLevel) status = 'warn'; + } else { + // For flag/risk metrics: higher is worse + if (value >= threshold.criticalLevel) status = 'critical'; + else if (value >= threshold.warningLevel) status = 'warn'; + } + } + + const changePercent = previous > 0 ? Number((((value - previous) / previous) * 100).toFixed(2)) : 0; + + return { + metric, + value: Number(value.toFixed(4)), + previousValue: previous, + changePercent, + jurisdiction, + timestamp: now, + status, + }; + }); + } + + /** + * Get all compliance thresholds (optionally filtered by jurisdiction). + */ + static getThresholds(jurisdiction?: JurisdictionCode): ComplianceThreshold[] { + const all = Array.from(thresholds.values()); + return jurisdiction ? all.filter((t) => t.jurisdiction === jurisdiction) : all; + } + + /** + * Update a compliance threshold. + */ + static updateThreshold( + jurisdiction: JurisdictionCode, + metric: ComplianceMetricType, + updates: { warningLevel?: number; criticalLevel?: number }, + ): ComplianceThreshold { + const key = `${jurisdiction}:${metric}`; + const existing = thresholds.get(key); + if (!existing) throw new Error(`Threshold not found: ${jurisdiction}/${metric}`); + + const updated: ComplianceThreshold = { ...existing, ...updates }; + thresholds.set(key, updated); + return updated; + } + + /** + * Get jurisdiction-level compliance status summary. + */ + static getJurisdictionStatus(): JurisdictionStatus[] { + const jurisdictions: JurisdictionCode[] = ['US', 'EU', 'UK', 'SG', 'AU']; + + return jurisdictions.map((jurisdiction) => { + const metrics = this.getMetrics(jurisdiction); + const kycMetric = metrics.find((m) => m.metric === 'kyc_verification_rate'); + const amlMetric = metrics.find((m) => m.metric === 'aml_flag_rate'); + const highRiskMetric = metrics.find((m) => m.metric === 'high_risk_ratio'); + + const hasCritical = metrics.some((m) => m.status === 'critical'); + const hasWarning = metrics.some((m) => m.status === 'warn'); + + return { + jurisdiction, + overallStatus: hasCritical ? 'non_compliant' : hasWarning ? 'review_required' : 'compliant', + kycComplianceRate: kycMetric?.value ?? 0, + amlFlagRate: amlMetric?.value ?? 0, + transactionVolume: Math.floor(Math.random() * 50_000) + 10_000, + highRiskCount: Math.floor((highRiskMetric?.value ?? 0) * 100), + lastChecked: new Date().toISOString(), + }; + }); + } + + // ─── Alerts ───────────────────────────────────────────────────────────────── + + static getAlerts(status?: ComplianceAlertStatus, jurisdiction?: JurisdictionCode): ComplianceAlert[] { + let all = Array.from(alerts.values()); + if (status) all = all.filter((a) => a.status === status); + if (jurisdiction) all = all.filter((a) => a.jurisdiction === jurisdiction); + return all.sort((a, b) => new Date(b.triggeredAt).getTime() - new Date(a.triggeredAt).getTime()); + } + + static createAlert( + type: ComplianceMetricType, + severity: ComplianceAlertSeverity, + message: string, + jurisdiction: JurisdictionCode, + details: Record = {}, + ): ComplianceAlert { + const alert: ComplianceAlert = { + id: `alert_${randomUUID()}`, + type, + severity, + status: 'open', + message, + details, + jurisdiction, + triggeredAt: new Date().toISOString(), + }; + alerts.set(alert.id, alert); + return alert; + } + + static acknowledgeAlert(id: string, userId: string): ComplianceAlert { + const alert = alerts.get(id); + if (!alert) throw new Error(`Alert not found: ${id}`); + if (alert.status !== 'open') throw new Error(`Alert is already ${alert.status}`); + + alert.status = 'acknowledged'; + alert.acknowledgedAt = new Date().toISOString(); + alert.acknowledgedBy = userId; + alerts.set(id, alert); + return alert; + } + + static resolveAlert(id: string, userId: string): ComplianceAlert { + const alert = alerts.get(id); + if (!alert) throw new Error(`Alert not found: ${id}`); + + alert.status = 'resolved'; + alert.resolvedAt = new Date().toISOString(); + alert.acknowledgedBy = userId; + alerts.set(id, alert); + return alert; + } + + /** + * Auto-evaluate metrics and raise alerts for threshold breaches. + */ + static evaluateThresholds(): ComplianceAlert[] { + const newAlerts: ComplianceAlert[] = []; + const jurisdictions: JurisdictionCode[] = ['GLOBAL', 'US', 'EU', 'UK']; + + for (const jurisdiction of jurisdictions) { + const metrics = this.getMetrics(jurisdiction); + for (const metric of metrics) { + if (metric.status === 'pass') continue; + + const message = + metric.status === 'critical' + ? `CRITICAL: ${metric.metric} breached critical threshold in ${jurisdiction} (value: ${metric.value})` + : `WARNING: ${metric.metric} approaching threshold in ${jurisdiction} (value: ${metric.value})`; + + const alert = this.createAlert( + metric.metric, + metric.status as ComplianceAlertSeverity, + message, + jurisdiction, + { value: metric.value, threshold: thresholds.get(`${jurisdiction}:${metric.metric}`) }, + ); + newAlerts.push(alert); + } + } + + return newAlerts; + } + + // ─── Reports ───────────────────────────────────────────────────────────────── + + static async requestReport(period: string, jurisdiction: JurisdictionCode): Promise { + const report: ComplianceReport = { + id: `report_${randomUUID()}`, + period, + jurisdiction, + status: 'pending', + metrics: this.getDashboardMetrics(jurisdiction), + jurisdictionBreakdown: this.getJurisdictionStatus(), + alerts: this.getAlerts(undefined, jurisdiction), + requestedAt: new Date().toISOString(), + }; + reports.set(report.id, report); + return report; + } + + static getReport(id: string): ComplianceReport | null { + return reports.get(id) ?? null; + } + + static listReports(): ComplianceReport[] { + return Array.from(reports.values()).sort( + (a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime(), + ); + } + + static markReportReady(id: string): ComplianceReport { + const report = reports.get(id); + if (!report) throw new Error(`Report not found: ${id}`); + report.status = 'ready'; + report.generatedAt = new Date().toISOString(); + reports.set(id, report); + return report; + } + + // ─── Audit trail ───────────────────────────────────────────────────────────── + + static addAuditEntry( + action: string, + entityType: string, + entityId: string, + userId: string, + jurisdiction: JurisdictionCode, + details: Record = {}, + ipAddress?: string, + ): AuditTrailEntry { + const entry: AuditTrailEntry = { + id: `audit_${randomUUID()}`, + action, + entityType, + entityId, + userId, + jurisdiction, + details, + timestamp: new Date().toISOString(), + ipAddress, + }; + auditTrail.push(entry); + return entry; + } + + static getAuditTrail( + entityType?: string, + jurisdiction?: JurisdictionCode, + limit = 100, + ): AuditTrailEntry[] { + let entries = [...auditTrail]; + if (entityType) entries = entries.filter((e) => e.entityType === entityType); + if (jurisdiction) entries = entries.filter((e) => e.jurisdiction === jurisdiction); + return entries + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, limit); + } + + // ─── Export ────────────────────────────────────────────────────────────────── + + static exportMetricsAsCSV(jurisdiction: JurisdictionCode = 'GLOBAL'): string { + const metrics = this.getMetrics(jurisdiction); + const header = 'metric,value,previousValue,changePercent,jurisdiction,status,timestamp'; + const rows = metrics.map( + (m) => `${m.metric},${m.value},${m.previousValue},${m.changePercent},${m.jurisdiction},${m.status},${m.timestamp}`, + ); + return [header, ...rows].join('\n'); + } + + static exportReportAsJSON(reportId: string): string { + const report = this.getReport(reportId); + if (!report) throw new Error(`Report not found: ${reportId}`); + return JSON.stringify(report, null, 2); + } +} diff --git a/backend/src/services/onboardingAnalytics.ts b/backend/src/services/onboardingAnalytics.ts new file mode 100644 index 00000000..f093f28c --- /dev/null +++ b/backend/src/services/onboardingAnalytics.ts @@ -0,0 +1,225 @@ +/** + * onboardingAnalytics.ts — Issue #591 + * + * Onboarding analytics service: tracks completion rates, drop-off points, + * A/B test results, and role-specific funnel data. + */ + +import { randomUUID } from 'crypto'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type UserRole = 'freelancer' | 'client' | 'merchant'; +export type OnboardingVariant = 'A' | 'B'; +export type StepStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped'; + +export interface StepProgressRecord { + stepId: string; + status: StepStatus; + startedAt?: string; + completedAt?: string; + skippedAt?: string; + timeSpentMs?: number; +} + +export interface DropOffEvent { + stepId: string; + timestamp: string; + reason?: string; +} + +export interface OnboardingSession { + sessionId: string; + userId: string; + role: UserRole; + variant: OnboardingVariant; + stepsProgress: StepProgressRecord[]; + dropOffEvents: DropOffEvent[]; + totalTimeMs: number; + completionRate: number; + startedAt: string; + completedAt?: string; + lastActiveAt: string; +} + +export interface StepFunnelStat { + stepId: string; + totalStarted: number; + totalCompleted: number; + totalSkipped: number; + totalDropped: number; + completionRate: number; + avgTimeSpentMs: number; +} + +export interface VariantComparison { + variant: OnboardingVariant; + totalSessions: number; + completedSessions: number; + completionRate: number; + avgCompletionTimeMs: number; + dropOffByStep: Record; +} + +export interface OnboardingAnalyticsSummary { + totalSessions: number; + completedSessions: number; + overallCompletionRate: number; + avgCompletionTimeMs: number; + byRole: Record; + byVariant: VariantComparison[]; + stepFunnel: StepFunnelStat[]; + topDropOffSteps: Array<{ stepId: string; dropCount: number }>; + generatedAt: string; +} + +// ─── In-memory store (replace with DB in production) ───────────────────────── + +const sessions = new Map(); + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class OnboardingAnalyticsService { + /** + * Record or update a session from frontend analytics payload. + */ + static upsertSession(payload: Omit & { sessionId?: string }): OnboardingSession { + const sessionId = payload.sessionId ?? `session_${randomUUID()}`; + const existing = sessions.get(sessionId); + + const session: OnboardingSession = { + ...(existing ?? {}), + ...payload, + sessionId, + }; + + sessions.set(sessionId, session); + return session; + } + + /** + * Get analytics summary across all sessions. + */ + static getSummary(): OnboardingAnalyticsSummary { + const allSessions = Array.from(sessions.values()); + const totalSessions = allSessions.length; + const completedSessions = allSessions.filter((s) => !!s.completedAt).length; + const overallCompletionRate = totalSessions > 0 ? (completedSessions / totalSessions) * 100 : 0; + + const completedTimings = allSessions.filter((s) => s.completedAt && s.totalTimeMs > 0).map((s) => s.totalTimeMs); + const avgCompletionTimeMs = + completedTimings.length > 0 + ? completedTimings.reduce((a, b) => a + b, 0) / completedTimings.length + : 0; + + // By role + const roles: UserRole[] = ['freelancer', 'client', 'merchant']; + const byRole = Object.fromEntries( + roles.map((role) => { + const roleSessions = allSessions.filter((s) => s.role === role); + const roleCompleted = roleSessions.filter((s) => !!s.completedAt).length; + return [ + role, + { + total: roleSessions.length, + completed: roleCompleted, + completionRate: roleSessions.length > 0 ? (roleCompleted / roleSessions.length) * 100 : 0, + }, + ]; + }), + ) as Record; + + // By variant + const variants: OnboardingVariant[] = ['A', 'B']; + const byVariant: VariantComparison[] = variants.map((variant) => { + const variantSessions = allSessions.filter((s) => s.variant === variant); + const variantCompleted = variantSessions.filter((s) => !!s.completedAt).length; + const variantTimings = variantSessions.filter((s) => s.completedAt && s.totalTimeMs > 0).map((s) => s.totalTimeMs); + const avgTime = variantTimings.length > 0 ? variantTimings.reduce((a, b) => a + b, 0) / variantTimings.length : 0; + + // Drop-off by step + const dropOffByStep: Record = {}; + for (const session of variantSessions) { + for (const event of session.dropOffEvents) { + dropOffByStep[event.stepId] = (dropOffByStep[event.stepId] ?? 0) + 1; + } + } + + return { + variant, + totalSessions: variantSessions.length, + completedSessions: variantCompleted, + completionRate: variantSessions.length > 0 ? (variantCompleted / variantSessions.length) * 100 : 0, + avgCompletionTimeMs: avgTime, + dropOffByStep, + }; + }); + + // Step funnel + const stepIds = new Set(); + for (const s of allSessions) { + for (const sp of s.stepsProgress) stepIds.add(sp.stepId); + } + + const stepFunnel: StepFunnelStat[] = Array.from(stepIds).map((stepId) => { + const stepRecords = allSessions.flatMap((s) => s.stepsProgress.filter((sp) => sp.stepId === stepId)); + const started = stepRecords.filter((r) => r.status !== 'not_started').length; + const completed = stepRecords.filter((r) => r.status === 'completed').length; + const skipped = stepRecords.filter((r) => r.status === 'skipped').length; + const dropped = allSessions.filter((s) => s.dropOffEvents.some((e) => e.stepId === stepId)).length; + const timings = stepRecords.filter((r) => r.timeSpentMs && r.timeSpentMs > 0).map((r) => r.timeSpentMs!); + const avgTimeSpentMs = timings.length > 0 ? timings.reduce((a, b) => a + b, 0) / timings.length : 0; + + return { + stepId, + totalStarted: started, + totalCompleted: completed, + totalSkipped: skipped, + totalDropped: dropped, + completionRate: started > 0 ? (completed / started) * 100 : 0, + avgTimeSpentMs, + }; + }); + + // Top drop-off steps + const dropOffCounts: Record = {}; + for (const s of allSessions) { + for (const event of s.dropOffEvents) { + dropOffCounts[event.stepId] = (dropOffCounts[event.stepId] ?? 0) + 1; + } + } + const topDropOffSteps = Object.entries(dropOffCounts) + .map(([stepId, dropCount]) => ({ stepId, dropCount })) + .sort((a, b) => b.dropCount - a.dropCount) + .slice(0, 5); + + return { + totalSessions, + completedSessions, + overallCompletionRate: Number(overallCompletionRate.toFixed(2)), + avgCompletionTimeMs: Math.round(avgCompletionTimeMs), + byRole, + byVariant, + stepFunnel, + topDropOffSteps, + generatedAt: new Date().toISOString(), + }; + } + + /** + * Get a single session by ID. + */ + static getSession(sessionId: string): OnboardingSession | null { + return sessions.get(sessionId) ?? null; + } + + /** + * List sessions (optionally filtered by role or variant). + */ + static listSessions(filters?: { role?: UserRole; variant?: OnboardingVariant }): OnboardingSession[] { + let all = Array.from(sessions.values()); + if (filters?.role) all = all.filter((s) => s.role === filters.role); + if (filters?.variant) all = all.filter((s) => s.variant === filters.variant); + return all.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); + } +} diff --git a/backend/src/services/walletAggregation.ts b/backend/src/services/walletAggregation.ts new file mode 100644 index 00000000..083a4ef1 --- /dev/null +++ b/backend/src/services/walletAggregation.ts @@ -0,0 +1,480 @@ +/** + * walletAggregation.ts — Issue #593 + * + * Cross-chain wallet abstraction service. + * Provides unified balance view, transaction history aggregation, + * gas abstraction, and chain-agnostic payment routing across + * Stellar and EVM-compatible networks. + */ + +import { randomUUID } from 'crypto'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type ChainType = 'stellar' | 'ethereum' | 'polygon' | 'base' | 'arbitrum' | 'optimism'; +export type AssetType = 'native' | 'token' | 'stellar_asset'; +export type TxStatus = 'pending' | 'confirmed' | 'failed'; +export type RoutingStrategy = 'cheapest' | 'fastest' | 'safest'; + +export interface ChainConfig { + chainId: string; + chainType: ChainType; + name: string; + nativeCurrency: string; + nativeCurrencySymbol: string; + nativeDecimals: number; + rpcUrl: string; + explorerUrl: string; + isTestnet: boolean; + avgConfirmationTimeMs: number; +} + +export interface WalletBalance { + chainId: string; + chainType: ChainType; + address: string; + nativeBalance: string; // human-readable + nativeBalanceRaw: string; // wei / stroops + nativeBalanceUSD: number; + tokens: TokenBalance[]; + lastUpdated: string; +} + +export interface TokenBalance { + contractAddress?: string; + issuer?: string; // Stellar asset issuer + assetCode: string; + assetType: AssetType; + balance: string; + balanceRaw: string; + balanceUSD: number; + decimals: number; + logoUrl?: string; +} + +export interface AggregatedBalance { + totalUSD: number; + chains: WalletBalance[]; + topAssets: TokenBalance[]; + lastUpdated: string; +} + +export interface CrossChainTransaction { + id: string; + sourceChain: ChainType; + destinationChain: ChainType; + sourceAddress: string; + destinationAddress: string; + assetCode: string; + amount: string; + amountUSD: number; + fee: string; + feeUSD: number; + feePayCurrency?: string; // gas abstraction: which token pays fees + status: TxStatus; + txHashes: Record; // chainId → txHash + bridgeProtocol?: string; + estimatedTimeMs?: number; + createdAt: string; + confirmedAt?: string; +} + +export interface RouteOption { + sourceChain: ChainType; + destinationChain: ChainType; + bridgeProtocol: string; + estimatedFeeUSD: number; + estimatedTimeMs: number; + strategy: RoutingStrategy; + recommended: boolean; +} + +export interface WalletConnection { + id: string; + userId: string; + chainType: ChainType; + address: string; + providerName: string; // 'freighter', 'metamask', 'walletconnect', 'web3auth' + connectedAt: string; + lastUsedAt: string; + isActive: boolean; +} + +// ─── Supported chains ───────────────────────────────────────────────────────── + +export const SUPPORTED_CHAINS: Record = { + stellar: { + chainId: 'stellar-testnet', + chainType: 'stellar', + name: 'Stellar', + nativeCurrency: 'Lumen', + nativeCurrencySymbol: 'XLM', + nativeDecimals: 7, + rpcUrl: 'https://horizon-testnet.stellar.org', + explorerUrl: 'https://stellar.expert/explorer/testnet', + isTestnet: true, + avgConfirmationTimeMs: 5_000, + }, + ethereum: { + chainId: '1', + chainType: 'ethereum', + name: 'Ethereum', + nativeCurrency: 'Ether', + nativeCurrencySymbol: 'ETH', + nativeDecimals: 18, + rpcUrl: 'https://cloudflare-eth.com', + explorerUrl: 'https://etherscan.io', + isTestnet: false, + avgConfirmationTimeMs: 15_000, + }, + polygon: { + chainId: '137', + chainType: 'polygon', + name: 'Polygon', + nativeCurrency: 'MATIC', + nativeCurrencySymbol: 'MATIC', + nativeDecimals: 18, + rpcUrl: 'https://polygon-rpc.com', + explorerUrl: 'https://polygonscan.com', + isTestnet: false, + avgConfirmationTimeMs: 2_000, + }, + base: { + chainId: '8453', + chainType: 'base', + name: 'Base', + nativeCurrency: 'Ether', + nativeCurrencySymbol: 'ETH', + nativeDecimals: 18, + rpcUrl: 'https://mainnet.base.org', + explorerUrl: 'https://basescan.org', + isTestnet: false, + avgConfirmationTimeMs: 2_000, + }, + arbitrum: { + chainId: '42161', + chainType: 'arbitrum', + name: 'Arbitrum One', + nativeCurrency: 'Ether', + nativeCurrencySymbol: 'ETH', + nativeDecimals: 18, + rpcUrl: 'https://arb1.arbitrum.io/rpc', + explorerUrl: 'https://arbiscan.io', + isTestnet: false, + avgConfirmationTimeMs: 1_000, + }, + optimism: { + chainId: '10', + chainType: 'optimism', + name: 'Optimism', + nativeCurrency: 'Ether', + nativeCurrencySymbol: 'ETH', + nativeDecimals: 18, + rpcUrl: 'https://mainnet.optimism.io', + explorerUrl: 'https://optimistic.etherscan.io', + isTestnet: false, + avgConfirmationTimeMs: 1_000, + }, +}; + +// ─── In-memory stores ───────────────────────────────────────────────────────── + +const walletConnections = new Map(); +const transactions = new Map(); + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class WalletAggregationService { + /** + * Get supported chains list. + */ + static getSupportedChains(): ChainConfig[] { + return Object.values(SUPPORTED_CHAINS); + } + + /** + * Register a wallet connection for a user. + */ + static connectWallet( + userId: string, + chainType: ChainType, + address: string, + providerName: string, + ): WalletConnection { + const existing = Array.from(walletConnections.values()).find( + (w) => w.userId === userId && w.chainType === chainType && w.address.toLowerCase() === address.toLowerCase(), + ); + + if (existing) { + existing.lastUsedAt = new Date().toISOString(); + existing.isActive = true; + walletConnections.set(existing.id, existing); + return existing; + } + + const connection: WalletConnection = { + id: `wallet_${randomUUID()}`, + userId, + chainType, + address, + providerName, + connectedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + isActive: true, + }; + walletConnections.set(connection.id, connection); + return connection; + } + + /** + * Disconnect a wallet. + */ + static disconnectWallet(connectionId: string): boolean { + const conn = walletConnections.get(connectionId); + if (!conn) return false; + conn.isActive = false; + walletConnections.set(connectionId, conn); + return true; + } + + /** + * Get all wallet connections for a user. + */ + static getUserWallets(userId: string): WalletConnection[] { + return Array.from(walletConnections.values()).filter( + (w) => w.userId === userId && w.isActive, + ); + } + + /** + * Get aggregated balance across all chains for a user. + * In production, this would call Horizon, Alchemy/Infura, etc. + */ + static async getAggregatedBalance(userId: string): Promise { + const userWallets = this.getUserWallets(userId); + const chainBalances: WalletBalance[] = []; + + for (const wallet of userWallets) { + // Simulated balances — wire to real RPC providers in production + const balance = await this._fetchChainBalance(wallet.chainType, wallet.address); + chainBalances.push(balance); + } + + const totalUSD = chainBalances.reduce((sum, b) => sum + b.nativeBalanceUSD, 0) + + chainBalances.reduce((sum, b) => sum + b.tokens.reduce((ts, t) => ts + t.balanceUSD, 0), 0); + + // Aggregate top assets across all chains + const assetMap = new Map(); + for (const chain of chainBalances) { + for (const token of chain.tokens) { + const key = token.assetCode; + if (assetMap.has(key)) { + const existing = assetMap.get(key)!; + existing.balanceUSD += token.balanceUSD; + } else { + assetMap.set(key, { ...token }); + } + } + } + const topAssets = Array.from(assetMap.values()) + .sort((a, b) => b.balanceUSD - a.balanceUSD) + .slice(0, 10); + + return { + totalUSD: Number(totalUSD.toFixed(2)), + chains: chainBalances, + topAssets, + lastUpdated: new Date().toISOString(), + }; + } + + /** + * Get optimal routes for a cross-chain transfer. + */ + static getRoutes( + sourceChain: ChainType, + destinationChain: ChainType, + amount: string, + ): RouteOption[] { + if (sourceChain === destinationChain) return []; + + const routes: RouteOption[] = []; + const strategies: RoutingStrategy[] = ['cheapest', 'fastest', 'safest']; + + const destConfig = SUPPORTED_CHAINS[destinationChain]; + + strategies.forEach((strategy) => { + let feeUSD = 0; + let timeMs = 0; + let bridgeProtocol = ''; + + switch (strategy) { + case 'cheapest': + feeUSD = 0.5; + timeMs = 300_000; // 5 min + bridgeProtocol = 'layerzero'; + break; + case 'fastest': + feeUSD = 3.0; + timeMs = destConfig.avgConfirmationTimeMs * 2; + bridgeProtocol = 'across'; + break; + case 'safest': + feeUSD = 1.5; + timeMs = 60_000; // 1 min + bridgeProtocol = 'stargate'; + break; + } + + routes.push({ + sourceChain, + destinationChain, + bridgeProtocol, + estimatedFeeUSD: feeUSD, + estimatedTimeMs: timeMs, + strategy, + recommended: strategy === 'cheapest', + }); + }); + + return routes; + } + + /** + * Initiate a cross-chain transfer. + */ + static async initiateCrossChainTransfer(params: { + userId: string; + sourceChain: ChainType; + destinationChain: ChainType; + sourceAddress: string; + destinationAddress: string; + assetCode: string; + amount: string; + feePayCurrency?: string; + strategy?: RoutingStrategy; + }): Promise { + const routes = this.getRoutes(params.sourceChain, params.destinationChain, params.amount); + const strategy = params.strategy ?? 'cheapest'; + const route = routes.find((r) => r.strategy === strategy) ?? routes[0]; + + const tx: CrossChainTransaction = { + id: `xchain_${randomUUID()}`, + sourceChain: params.sourceChain, + destinationChain: params.destinationChain, + sourceAddress: params.sourceAddress, + destinationAddress: params.destinationAddress, + assetCode: params.assetCode, + amount: params.amount, + amountUSD: parseFloat(params.amount) * 0.11, // approximate XLM price + fee: route?.estimatedFeeUSD.toString() ?? '0', + feeUSD: route?.estimatedFeeUSD ?? 0, + feePayCurrency: params.feePayCurrency, + status: 'pending', + txHashes: {}, + bridgeProtocol: route?.bridgeProtocol, + estimatedTimeMs: route?.estimatedTimeMs, + createdAt: new Date().toISOString(), + }; + + transactions.set(tx.id, tx); + return tx; + } + + /** + * Get transaction by ID. + */ + static getTransaction(txId: string): CrossChainTransaction | null { + return transactions.get(txId) ?? null; + } + + /** + * Get transaction history for a user (all chains). + */ + static getTransactionHistory(userId: string, limit = 50): CrossChainTransaction[] { + const userWallets = this.getUserWallets(userId); + const userAddresses = new Set(userWallets.map((w) => w.address.toLowerCase())); + + return Array.from(transactions.values()) + .filter( + (tx) => + userAddresses.has(tx.sourceAddress.toLowerCase()) || + userAddresses.has(tx.destinationAddress.toLowerCase()), + ) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit); + } + + /** + * Update transaction status (called by bridge webhook or polling). + */ + static updateTransactionStatus( + txId: string, + status: TxStatus, + txHash?: string, + chainId?: string, + ): CrossChainTransaction { + const tx = transactions.get(txId); + if (!tx) throw new Error(`Transaction not found: ${txId}`); + + tx.status = status; + if (txHash && chainId) tx.txHashes[chainId] = txHash; + if (status === 'confirmed') tx.confirmedAt = new Date().toISOString(); + + transactions.set(txId, tx); + return tx; + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + /** + * Fetch balance for a wallet on a specific chain. + * In production: call Horizon for Stellar, Alchemy/Infura for EVM. + */ + private static async _fetchChainBalance(chainType: ChainType, address: string): Promise { + const chain = SUPPORTED_CHAINS[chainType]; + const now = new Date().toISOString(); + + // Simulated data — replace with real RPC calls in production + const nativeBalanceMap: Record = { + stellar: 1_250.5, + ethereum: 0.42, + polygon: 850, + base: 0.18, + arbitrum: 0.25, + optimism: 0.31, + }; + + const usdPrices: Record = { + stellar: 0.11, + ethereum: 3_200, + polygon: 0.95, + base: 3_200, + arbitrum: 3_200, + optimism: 3_200, + }; + + const nativeBalance = nativeBalanceMap[chainType] ?? 0; + const usdPrice = usdPrices[chainType] ?? 0; + + const tokens: TokenBalance[] = chainType === 'stellar' + ? [ + { assetCode: 'USDC', assetType: 'stellar_asset', issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', balance: '250.00', balanceRaw: '2500000000', balanceUSD: 250, decimals: 7 }, + { assetCode: 'yXLM', assetType: 'stellar_asset', issuer: 'GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55', balance: '500.00', balanceRaw: '5000000000', balanceUSD: 55, decimals: 7 }, + ] + : [ + { assetCode: 'USDC', assetType: 'token', contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', balance: '100.00', balanceRaw: '100000000', balanceUSD: 100, decimals: 6 }, + ]; + + return { + chainId: chain.chainId, + chainType, + address, + nativeBalance: nativeBalance.toString(), + nativeBalanceRaw: (nativeBalance * Math.pow(10, chain.nativeDecimals)).toString(), + nativeBalanceUSD: Number((nativeBalance * usdPrice).toFixed(2)), + tokens, + lastUpdated: now, + }; + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 11c08d3e..1f58510b 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +**/__snapshots__/ +/e2e/__snapshots__/ # next.js /.next/ @@ -46,3 +48,9 @@ next-env.d.ts /test-results /.playwright /playwright/.cache + +# output files +output.txt +/app/output.txt +/components/layout/output.txt + diff --git a/frontend/app/dashboard/compliance/page.tsx b/frontend/app/dashboard/compliance/page.tsx new file mode 100644 index 00000000..9c81b49e --- /dev/null +++ b/frontend/app/dashboard/compliance/page.tsx @@ -0,0 +1,406 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; + +// ─── Types (mirrors backend) ────────────────────────────────────────────────── + +type JurisdictionCode = 'US' | 'EU' | 'UK' | 'SG' | 'AU' | 'GLOBAL'; +type AlertStatus = 'open' | 'acknowledged' | 'resolved'; +type AlertSeverity = 'info' | 'warning' | 'critical'; + +interface ComplianceDashboardMetrics { + totalUsers: number; + verifiedUsers: number; + kycVerificationRate: number; + amlFlags: number; + amlFlagRate: number; + highRiskTransactions: number; + sanctionsHits: number; + pepHits: number; + openAlerts: number; + criticalAlerts: number; + generatedAt: string; +} + +interface ComplianceMetric { + metric: string; + value: number; + previousValue: number; + changePercent: number; + jurisdiction: JurisdictionCode; + status: 'pass' | 'warn' | 'critical'; + timestamp: string; +} + +interface JurisdictionStatus { + jurisdiction: JurisdictionCode; + overallStatus: 'compliant' | 'review_required' | 'non_compliant'; + kycComplianceRate: number; + amlFlagRate: number; + transactionVolume: number; + highRiskCount: number; + lastChecked: string; +} + +interface ComplianceAlert { + id: string; + type: string; + severity: AlertSeverity; + status: AlertStatus; + message: string; + jurisdiction: JurisdictionCode; + triggeredAt: string; +} + +// ─── API helpers ───────────────────────────────────────────────────────────── + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + +async function fetchJSON(path: string): Promise { + const res = await fetch(`${API_BASE}${path}`); + if (!res.ok) throw new Error(`API error ${res.status}: ${path}`); + return res.json(); +} + +async function postJSON(path: string, body?: unknown): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API error ${res.status}: ${path}`); + return res.json(); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function MetricCard({ label, value, sub, status }: { label: string; value: string | number; sub?: string; status?: 'pass' | 'warn' | 'critical' }) { + const borderColor = status === 'critical' ? 'border-red-500' : status === 'warn' ? 'border-yellow-500' : 'border-green-500'; + const textColor = status === 'critical' ? 'text-red-600' : status === 'warn' ? 'text-yellow-600' : 'text-green-600'; + + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +function SeverityBadge({ severity }: { severity: AlertSeverity }) { + const classes: Record = { + critical: 'bg-red-100 text-red-800', + warning: 'bg-yellow-100 text-yellow-800', + info: 'bg-blue-100 text-blue-800', + }; + return ( + + {severity} + + ); +} + +function StatusBadge({ status }: { status: 'compliant' | 'review_required' | 'non_compliant' | AlertStatus }) { + const classes: Record = { + compliant: 'bg-green-100 text-green-800', + review_required: 'bg-yellow-100 text-yellow-800', + non_compliant: 'bg-red-100 text-red-800', + open: 'bg-red-100 text-red-800', + acknowledged: 'bg-yellow-100 text-yellow-800', + resolved: 'bg-green-100 text-green-800', + }; + return ( + + {status.replace('_', ' ')} + + ); +} + +// ─── Main dashboard component ───────────────────────────────────────────────── + +export default function ComplianceDashboardPage() { + const [metrics, setMetrics] = useState(null); + const [complianceMetrics, setComplianceMetrics] = useState([]); + const [jurisdictions, setJurisdictions] = useState([]); + const [alerts, setAlerts] = useState([]); + const [selectedJurisdiction, setSelectedJurisdiction] = useState('GLOBAL'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [evaluating, setEvaluating] = useState(false); + const [reportRequesting, setReportRequesting] = useState(false); + + const loadData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const [dashRes, metricsRes, jurisdRes, alertsRes] = await Promise.all([ + fetchJSON<{ data: { metrics: ComplianceDashboardMetrics } }>('/compliance/dashboard'), + fetchJSON<{ data: ComplianceMetric[] }>(`/compliance/metrics?jurisdiction=${selectedJurisdiction}`), + fetchJSON<{ data: JurisdictionStatus[] }>('/compliance/jurisdictions'), + fetchJSON<{ data: ComplianceAlert[] }>('/compliance/alerts?status=open'), + ]); + + setMetrics(dashRes.data.metrics); + setComplianceMetrics(metricsRes.data); + setJurisdictions(jurisdRes.data); + setAlerts(alertsRes.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load compliance data'); + } finally { + setLoading(false); + } + }, [selectedJurisdiction]); + + useEffect(() => { + void loadData(); + // Auto-refresh every 60 seconds for real-time monitoring + const interval = setInterval(() => void loadData(), 60_000); + return () => clearInterval(interval); + }, [loadData]); + + const handleEvaluateThresholds = async () => { + setEvaluating(true); + try { + await postJSON('/compliance/alerts/evaluate'); + await loadData(); + } finally { + setEvaluating(false); + } + }; + + const handleRequestReport = async () => { + const period = new Date().toISOString().slice(0, 7); // e.g. "2026-07" + setReportRequesting(true); + try { + await postJSON('/compliance/reports', { period, jurisdiction: selectedJurisdiction }); + alert(`Compliance report for ${period} (${selectedJurisdiction}) requested successfully.`); + } finally { + setReportRequesting(false); + } + }; + + const handleAcknowledgeAlert = async (id: string) => { + await postJSON(`/compliance/alerts/${id}/acknowledge`, { userId: 'current-user' }); + await loadData(); + }; + + const handleExportCSV = () => { + window.open(`${API_BASE}/compliance/export/csv?jurisdiction=${selectedJurisdiction}`, '_blank'); + }; + + if (loading && !metrics) { + return ( +
+
+ Loading compliance data… +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Compliance Dashboard

+

+ Real-time regulatory monitoring · Last updated: {metrics?.generatedAt ? new Date(metrics.generatedAt).toLocaleTimeString() : '—'} +

+
+
+ {/* Jurisdiction selector */} + + + + + + + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* KPI cards */} + {metrics && ( +
+ = 90 ? 'pass' : metrics.kycVerificationRate >= 80 ? 'warn' : 'critical'} + /> + + + 0 ? 'critical' : metrics.openAlerts > 5 ? 'warn' : 'pass'} + /> + + + + +
+ )} + +
+ {/* Compliance metrics table */} +
+
+

+ Real-time Metrics — {selectedJurisdiction} +

+
+
+ + + + + + + + + + + {complianceMetrics.map((m) => ( + + + + + + + ))} + {complianceMetrics.length === 0 && ( + + )} + +
MetricValueChangeStatus
{m.metric.replace(/_/g, ' ')}{m.value.toFixed(3)}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {m.changePercent >= 0 ? '+' : ''}{m.changePercent.toFixed(2)}% + + + {m.status} + +
No metrics available
+
+
+ + {/* Jurisdiction status */} +
+
+

Multi-Jurisdiction Status

+
+
+ + + + + + + + + + + {jurisdictions.map((j) => ( + + + + + + + ))} + +
JurisdictionKYC RateAML RateStatus
{j.jurisdiction}{j.kycComplianceRate.toFixed(1)}%{j.amlFlagRate.toFixed(2)}%
+
+
+
+ + {/* Open alerts */} +
+
+

+ Open Alerts + {alerts.length > 0 && ( + + {alerts.length} + + )} +

+
+ {alerts.length === 0 ? ( +
+ No open alerts — all thresholds within acceptable limits. +
+ ) : ( +
    + {alerts.map((alert) => ( +
  • +
    +
    + + {alert.jurisdiction} + {new Date(alert.triggeredAt).toLocaleString()} +
    +

    {alert.message}

    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx new file mode 100644 index 00000000..a87f9d28 --- /dev/null +++ b/frontend/app/onboarding/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +/** + * Onboarding wizard entry page — Issue #591 + * + * Role selection gateway. After selecting a role, the user is directed + * to the step-by-step wizard (/onboarding/wizard). + */ + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { useOnboardingStore } from '@/store/useOnboardingStore'; +import type { UserRole } from '@/store/useOnboardingStore'; + +interface RoleCard { + role: NonNullable; + title: string; + description: string; + icon: string; + highlights: string[]; +} + +const ROLE_CARDS: RoleCard[] = [ + { + role: 'freelancer', + title: 'Freelancer', + description: 'Find projects, submit work, and get paid instantly via Stellar.', + icon: '💻', + highlights: ['Browse & apply to projects', 'Get paid in crypto or fiat', 'Build your portfolio'], + }, + { + role: 'client', + title: 'Client', + description: 'Post projects, hire freelancers, and release payments from escrow.', + icon: '🏢', + highlights: ['Post projects', 'Escrow-secured payments', 'AI work verification'], + }, + { + role: 'merchant', + title: 'Merchant / Business', + description: 'Accept payments, manage subscriptions, and integrate via API.', + icon: '🛒', + highlights: ['Accept crypto & fiat', 'Subscription billing', 'Developer API'], + }, +]; + +export default function OnboardingPage() { + const router = useRouter(); + const { initWizard, analytics } = useOnboardingStore(); + + const handleSelectRole = (role: NonNullable) => { + initWizard(role); + router.push('/onboarding/wizard'); + }; + + return ( +
+
+ {/* Header */} +
+

Welcome to AgenticPay

+

+ Tell us how you'll use the platform so we can personalise your experience. +

+ {/* A/B variant indicator (dev only) */} + {process.env.NODE_ENV === 'development' && ( +

A/B Variant: {analytics.variant} · Session: {analytics.sessionId}

+ )} +
+ + {/* Role cards */} +
+ {ROLE_CARDS.map(({ role, title, description, icon, highlights }) => ( + + ))} +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ); +} diff --git a/frontend/app/onboarding/wizard/page.tsx b/frontend/app/onboarding/wizard/page.tsx new file mode 100644 index 00000000..64936e6b --- /dev/null +++ b/frontend/app/onboarding/wizard/page.tsx @@ -0,0 +1,275 @@ +'use client'; + +/** + * Onboarding wizard step page — Issue #591 + * + * Multi-step wizard with: + * - Role-based step paths + * - Progress bar + * - Skip functionality + * - Completion incentive + * - Analytics tracking + * - Personalized recommendations on completion + */ + +import React, { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + useOnboardingStore, + selectCurrentStep, + selectProgress, + selectIsLastStep, + selectIsFirstStep, +} from '@/store/useOnboardingStore'; + +// ─── Step content map ──────────────────────────────────────────────────────── + +interface StepConfig { + title: string; + description: string; + icon: string; + optional?: boolean; + content?: React.ReactNode; +} + +const STEP_CONFIGS: Record = { + welcome: { title: 'Welcome to AgenticPay', description: "Let's get you set up in a few quick steps.", icon: '👋' }, + profile_setup: { title: 'Set up your profile', description: 'Add your name, bio, and profile photo.', icon: '🧑' }, + skills_portfolio: { title: 'Skills & Portfolio', description: 'Add your skills and showcase previous work.', icon: '🎨', optional: true }, + payment_setup: { title: 'Payment Setup', description: 'Configure how you receive payments.', icon: '💳' }, + wallet_connect: { title: 'Connect Wallet', description: 'Link your Stellar or EVM wallet for crypto payments.', icon: '🔗', optional: true }, + first_project: { title: 'Find your first project', description: 'Browse and apply to available projects.', icon: '🚀', optional: true }, + business_info: { title: 'Business Information', description: 'Tell us about your business.', icon: '🏢' }, + payment_method: { title: 'Payment Method', description: 'Set up how you pay for projects.', icon: '💰' }, + create_project: { title: 'Create a project', description: 'Post your first project to find talent.', icon: '📋', optional: true }, + invite_team: { title: 'Invite your team', description: 'Add team members to collaborate.', icon: '👥', optional: true }, + kyc_verification: { title: 'Identity Verification', description: 'Complete KYC to unlock full platform access.', icon: '🔐' }, + bank_verification: { title: 'Bank Account', description: 'Link your bank account for fiat payouts.', icon: '🏦' }, + api_keys: { title: 'API Keys', description: 'Generate API keys to integrate AgenticPay.', icon: '🔑', optional: true }, + complete: { title: 'All set!', description: 'Your account is ready to use.', icon: '🎉' }, +}; + +// ─── Step component ─────────────────────────────────────────────────────────── + +function StepContent({ stepId, variant }: { stepId: string; variant: 'A' | 'B' }) { + const config = STEP_CONFIGS[stepId]; + if (!config) return null; + + const isComplete = stepId === 'complete'; + + return ( +
+ +

{config.title}

+

{config.description}

+ + {/* Variant-specific call-to-action for welcome step */} + {stepId === 'welcome' && ( +
+

+ {variant === 'A' + ? '✨ Most users complete setup in under 5 minutes.' + : '🚀 You\'re 3 steps away from your first payment.'} +

+
+ )} + + {/* Completion screen */} + {isComplete && ( +
+
+

+ 🎊 Onboarding complete! Your account is fully set up. +

+
+
+ )} +
+ ); +} + +// ─── Recommendations component ──────────────────────────────────────────────── + +function RecommendationsList() { + const getRecommendations = useOnboardingStore((s) => s.getRecommendations); + const recs = getRecommendations(); + + return ( +
+

Recommended next steps

+ +
+ ); +} + +// ─── Main wizard component ──────────────────────────────────────────────────── + +export default function OnboardingWizardPage() { + const router = useRouter(); + + const { + steps, + currentStepIndex, + role, + analytics, + nextStep, + prevStep, + skipStep, + trackStepStart, + trackStepComplete, + claimIncentive, + incentiveClaimed, + } = useOnboardingStore(); + + const currentStep = useOnboardingStore(selectCurrentStep); + const progress = useOnboardingStore(selectProgress); + const isLastStep = useOnboardingStore(selectIsLastStep); + const isFirstStep = useOnboardingStore(selectIsFirstStep); + + // Redirect to role selection if no role set + useEffect(() => { + if (!role) { + router.replace('/onboarding'); + } + }, [role, router]); + + // Track step start on mount/change + useEffect(() => { + if (currentStep) trackStepStart(currentStep); + }, [currentStep, trackStepStart]); + + const stepConfig = STEP_CONFIGS[currentStep] ?? { title: currentStep, description: '', icon: '📄' }; + const isOptional = stepConfig.optional === true; + + const handleNext = () => { + trackStepComplete(currentStep); + if (isLastStep) { + router.push('/dashboard'); + } else { + nextStep(); + } + }; + + const handleSkip = () => { + skipStep('user_skipped'); + }; + + if (!role) return null; + + return ( +
+ {/* Top progress bar */} +
+
+
+ {role} setup + + Step {currentStepIndex + 1} of {steps.length} + +
+
+
+
+
+
+ + {/* Step content */} +
+
+ {/* Step indicator dots */} +
+ {steps.map((s, i) => ( +
+ ))} +
+ + {/* Card */} +
+ + + {/* Completion incentive */} + {isLastStep && !incentiveClaimed && ( +
+

+ 🎁 Completion bonus: Get 10 XLM to try your first transaction! +

+ +
+ )} + + {/* Recommendations on completion */} + {isLastStep && } +
+ + {/* Navigation buttons */} +
+
+ {!isFirstStep && ( + + )} + {isOptional && !isLastStep && ( + + )} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/wallet/CrossChainTransfer.tsx b/frontend/components/wallet/CrossChainTransfer.tsx new file mode 100644 index 00000000..e436e9f2 --- /dev/null +++ b/frontend/components/wallet/CrossChainTransfer.tsx @@ -0,0 +1,269 @@ +'use client'; + +/** + * CrossChainTransfer.tsx — Issue #593 + * + * UI for initiating cross-chain asset transfers with automatic routing. + * Displays route options (cheapest / fastest / safest) and handles + * gas abstraction (pay fees in any supported token). + */ + +import React, { useState } from 'react'; +import { useWeb3Store, SupportedChain } from '@/store/web3Store'; + +interface RouteOption { + sourceChain: SupportedChain; + destinationChain: SupportedChain; + bridgeProtocol: string; + estimatedFeeUSD: number; + estimatedTimeMs: number; + strategy: 'cheapest' | 'fastest' | 'safest'; + recommended: boolean; +} + +const CHAIN_NAMES: Record = { + stellar: 'Stellar', + ethereum: 'Ethereum', + polygon: 'Polygon', + base: 'Base', + arbitrum: 'Arbitrum', + optimism: 'Optimism', +}; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + +export function CrossChainTransfer() { + const { connectedWallets } = useWeb3Store(); + + const [sourceChain, setSourceChain] = useState('stellar'); + const [destinationChain, setDestinationChain] = useState('ethereum'); + const [amount, setAmount] = useState(''); + const [asset, setAsset] = useState('XLM'); + const [selectedStrategy, setSelectedStrategy] = useState<'cheapest' | 'fastest' | 'safest'>('cheapest'); + const [routes, setRoutes] = useState([]); + const [loadingRoutes, setLoadingRoutes] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [txId, setTxId] = useState(null); + const [error, setError] = useState(null); + + const sourceWallet = connectedWallets.find((w) => w.chainType === sourceChain); + const destWallet = connectedWallets.find((w) => w.chainType === destinationChain); + + const loadRoutes = async () => { + if (!amount || sourceChain === destinationChain) return; + setLoadingRoutes(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/wallet/routes?source=${sourceChain}&destination=${destinationChain}&amount=${amount}`); + if (!res.ok) throw new Error('Failed to fetch routes'); + const data = await res.json(); + setRoutes(data.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load routes'); + } finally { + setLoadingRoutes(false); + } + }; + + const handleTransfer = async () => { + if (!sourceWallet || !destWallet || !amount) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/wallet/transfer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChain, + destinationChain, + sourceAddress: sourceWallet.address, + destinationAddress: destWallet.address, + assetCode: asset, + amount, + strategy: selectedStrategy, + }), + }); + if (!res.ok) throw new Error('Transfer initiation failed'); + const data = await res.json(); + setTxId(data.data?.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Transfer failed'); + } finally { + setSubmitting(false); + } + }; + + const formatTime = (ms: number) => { + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.round(ms / 60_000)}m`; + }; + + const chains = Object.keys(CHAIN_NAMES) as SupportedChain[]; + + return ( +
+

Cross-Chain Transfer

+ + {txId ? ( +
+ +

Transfer initiated!

+

{txId}

+ +
+ ) : ( +
+ {/* Source chain */} +
+ + + {!sourceWallet && ( +

+ No {CHAIN_NAMES[sourceChain]} wallet connected. +

+ )} +
+ + {/* Destination chain */} +
+ + + {!destWallet && ( +

+ No {CHAIN_NAMES[destinationChain]} wallet connected. +

+ )} +
+ + {/* Amount & asset */} +
+
+ + { setAmount(e.target.value); setRoutes([]); }} + placeholder="0.00" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setAsset(e.target.value.toUpperCase())} + placeholder="XLM" + maxLength={12} + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Get routes button */} + + + {/* Route options */} + {routes.length > 0 && ( +
+ Select Route +
+ {routes.map((route) => ( + + ))} +
+
+ )} + + {error && ( +

{error}

+ )} + + {/* Transfer button */} + +
+ )} +
+ ); +} diff --git a/frontend/components/wallet/WalletManager.tsx b/frontend/components/wallet/WalletManager.tsx new file mode 100644 index 00000000..b91c5dc4 --- /dev/null +++ b/frontend/components/wallet/WalletManager.tsx @@ -0,0 +1,263 @@ +'use client'; + +/** + * WalletManager.tsx — Issue #593 + * + * Unified multi-chain wallet connection management UI. + * Displays connected wallets, aggregated balance, and allows + * connecting / disconnecting wallets across Stellar and EVM chains. + */ + +import React, { useEffect } from 'react'; +import { useWeb3Store, SupportedChain, ConnectedWallet } from '@/store/web3Store'; + +// ─── Chain metadata ─────────────────────────────────────────────────────────── + +interface ChainMeta { + name: string; + symbol: string; + icon: string; + color: string; +} + +const CHAIN_META: Record = { + stellar: { name: 'Stellar', symbol: 'XLM', icon: '✦', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }, + ethereum: { name: 'Ethereum', symbol: 'ETH', icon: '⟠', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' }, + polygon: { name: 'Polygon', symbol: 'MATIC', icon: '⬡', color: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300' }, + base: { name: 'Base', symbol: 'ETH', icon: '🔵', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' }, + arbitrum: { name: 'Arbitrum', symbol: 'ETH', icon: '🔷', color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300' }, + optimism: { name: 'Optimism', symbol: 'ETH', icon: '🔴', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' }, +}; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function ChainBadge({ chainType }: { chainType: SupportedChain }) { + const meta = CHAIN_META[chainType]; + return ( + + + {meta.name} + + ); +} + +function WalletRow({ wallet, onDisconnect }: { wallet: ConnectedWallet; onDisconnect: (id: string) => void }) { + const shortAddress = `${wallet.address.slice(0, 6)}…${wallet.address.slice(-4)}`; + + return ( +
  • +
    + +
    +

    {shortAddress}

    +

    {wallet.providerName}

    +
    +
    + +
  • + ); +} + +function BalanceRow({ chainType, nativeBalance, nativeBalanceUSD, symbol }: { + chainType: SupportedChain; + nativeBalance: string; + nativeBalanceUSD: number; + symbol: string; +}) { + return ( +
  • +
    + +
    +
    +

    + {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {symbol} +

    +

    + ${nativeBalanceUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })} +

    +
    +
  • + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function WalletManager({ userId }: { userId: string }) { + const { + connectedWallets, + aggregatedBalance, + activeChain, + removeWallet, + setActiveChain, + fetchAggregatedBalance, + } = useWeb3Store(); + + useEffect(() => { + if (userId) { + void fetchAggregatedBalance(userId); + } + }, [userId, fetchAggregatedBalance]); + + const handleDisconnect = (walletId: string) => { + removeWallet(walletId); + }; + + const handleRefresh = () => { + void fetchAggregatedBalance(userId); + }; + + return ( +
    + {/* Total balance card */} +
    +
    +

    Total Portfolio Value

    + +
    +

    + ${aggregatedBalance.totalUSD.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

    + {aggregatedBalance.lastUpdated && ( +

    + Last updated {new Date(aggregatedBalance.lastUpdated).toLocaleTimeString()} +

    + )} + {aggregatedBalance.error && ( +

    {aggregatedBalance.error}

    + )} +
    + + {/* Chain filter */} +
    + + {(Object.keys(CHAIN_META) as SupportedChain[]).map((chain) => ( + + ))} +
    + +
    + {/* Connected wallets */} +
    +
    +

    + Connected Wallets + ({connectedWallets.length}) +

    +
    + {connectedWallets.length === 0 ? ( +
    +

    No wallets connected.

    +

    Use the Connect Wallet button to add one.

    +
    + ) : ( +
      + {connectedWallets + .filter((w) => !activeChain || w.chainType === activeChain) + .map((wallet) => ( + + ))} +
    + )} +
    + + {/* Per-chain balances */} +
    +
    +

    Chain Balances

    +
    + {aggregatedBalance.isLoading ? ( +
    +
    +
    + ) : aggregatedBalance.chains.length === 0 ? ( +
    + Connect a wallet to see balances. +
    + ) : ( +
      + {aggregatedBalance.chains + .filter((c) => !activeChain || c.chainType === activeChain) + .map((chain) => ( + + ))} +
    + )} +
    +
    + + {/* Top assets across all chains */} + {aggregatedBalance.topAssets && aggregatedBalance.topAssets.length > 0 && ( +
    +
    +

    Top Assets

    +
    +
    + + + + + + + + + + {aggregatedBalance.topAssets.map((asset) => ( + + + + + + ))} + +
    AssetBalanceValue (USD)
    {asset.assetCode} + {parseFloat(asset.balance).toLocaleString(undefined, { maximumFractionDigits: 4 })} + + ${asset.balanceUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
    +
    +
    + )} +
    + ); +} diff --git a/frontend/components/wallet/index.ts b/frontend/components/wallet/index.ts new file mode 100644 index 00000000..192cf8dc --- /dev/null +++ b/frontend/components/wallet/index.ts @@ -0,0 +1,2 @@ +export { WalletManager } from './WalletManager'; +export { CrossChainTransfer } from './CrossChainTransfer'; diff --git a/frontend/store/useOnboardingStore.ts b/frontend/store/useOnboardingStore.ts index 0ef93ebc..6b4ba9b2 100644 --- a/frontend/store/useOnboardingStore.ts +++ b/frontend/store/useOnboardingStore.ts @@ -1,98 +1,479 @@ +'use client'; + +/** + * useOnboardingStore.ts — Issue #591 + * + * Onboarding state with: + * - Multi-step wizard with role-based paths (freelancer vs client) + * - Progress persistence across sessions (localStorage via Zustand persist) + * - Personalized recommendations + * - Skip functionality for experienced users + * - Onboarding analytics (completion tracking, drop-off) + * - A/B testing variant tracking + */ + import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { MerchantOnboarding, OnboardingTask, TaskStatus } from '@/lib/types/onboarding'; +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type UserRole = 'freelancer' | 'client' | 'merchant' | null; +export type OnboardingVariant = 'A' | 'B'; + +export interface StepProgress { + stepId: string; + status: 'not_started' | 'in_progress' | 'completed' | 'skipped'; + startedAt?: string; + completedAt?: string; + skippedAt?: string; + timeSpentMs?: number; +} + +export interface DropOffEvent { + stepId: string; + timestamp: string; + reason?: string; +} + +export interface OnboardingAnalytics { + sessionId: string; + variant: OnboardingVariant; + role: UserRole; + stepsProgress: StepProgress[]; + dropOffEvents: DropOffEvent[]; + totalTimeMs: number; + completionRate: number; + startedAt: string; + completedAt?: string; + lastActiveAt: string; +} + +export interface Recommendation { + id: string; + title: string; + description: string; + ctaLabel: string; + ctaHref: string; + priority: 'high' | 'medium' | 'low'; + forRoles: UserRole[]; +} + +// ─── Role-based step paths ──────────────────────────────────────────────────── + +export const ROLE_STEP_PATHS: Record, string[]> = { + freelancer: [ + 'welcome', + 'profile_setup', + 'skills_portfolio', + 'payment_setup', + 'wallet_connect', + 'first_project', + 'complete', + ], + client: [ + 'welcome', + 'business_info', + 'payment_method', + 'create_project', + 'invite_team', + 'complete', + ], + merchant: [ + 'welcome', + 'business_info', + 'kyc_verification', + 'bank_verification', + 'payment_setup', + 'api_keys', + 'complete', + ], +}; + +// ─── Recommendations by role ────────────────────────────────────────────────── + +const RECOMMENDATIONS: Recommendation[] = [ + { + id: 'rec_wallet_connect', + title: 'Connect your Stellar wallet', + description: 'Enable crypto payments and access DeFi features on Stellar.', + ctaLabel: 'Connect Wallet', + ctaHref: '/dashboard/wallet', + priority: 'high', + forRoles: ['freelancer', 'client', 'merchant'], + }, + { + id: 'rec_2fa', + title: 'Enable Two-Factor Authentication', + description: 'Protect your account with TOTP-based 2FA.', + ctaLabel: 'Enable 2FA', + ctaHref: '/dashboard/settings/security', + priority: 'high', + forRoles: ['freelancer', 'client', 'merchant'], + }, + { + id: 'rec_first_project_freelancer', + title: 'Browse available projects', + description: 'Find your first project and start earning.', + ctaLabel: 'Browse Projects', + ctaHref: '/dashboard/projects', + priority: 'medium', + forRoles: ['freelancer'], + }, + { + id: 'rec_create_project', + title: 'Create your first project', + description: 'Post a project and find talented freelancers.', + ctaLabel: 'Create Project', + ctaHref: '/dashboard/projects/new', + priority: 'medium', + forRoles: ['client'], + }, + { + id: 'rec_api_keys', + title: 'Generate API keys', + description: 'Integrate AgenticPay into your application.', + ctaLabel: 'API Keys', + ctaHref: '/dashboard/api-keys', + priority: 'medium', + forRoles: ['merchant'], + }, + { + id: 'rec_portfolio', + title: 'Add portfolio samples', + description: 'Showcase your work to attract better-paying projects.', + ctaLabel: 'Add Portfolio', + ctaHref: '/dashboard/profile', + priority: 'low', + forRoles: ['freelancer'], + }, +]; + +// ─── A/B variant assignment ─────────────────────────────────────────────────── + +function assignVariant(): OnboardingVariant { + // 50/50 split — stable for the session + return Math.random() < 0.5 ? 'A' : 'B'; +} + +function generateSessionId(): string { + return `onb_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +// ─── State / Actions ───────────────────────────────────────────────────────── + interface OnboardingState { + // Wizard state onboarding: MerchantOnboarding | null; - currentStep: number; + role: UserRole; + currentStepIndex: number; + steps: string[]; isLoading: boolean; error: string | null; + + // Analytics & A/B + analytics: OnboardingAnalytics; + + // Recommendations + recommendations: Recommendation[]; + + // Completion incentive flag + incentiveClaimed: boolean; +} + +interface OnboardingActions { + // Setup + initWizard: (role: UserRole) => void; + setRole: (role: UserRole) => void; + + // Navigation + nextStep: () => void; + prevStep: () => void; + goToStep: (index: number) => void; + skipStep: (reason?: string) => void; + + // Data fetchOnboarding: () => Promise; - updateTask: (taskId: string, status: TaskStatus, data?: any) => Promise; + updateTask: (taskId: string, status: TaskStatus, data?: Record) => Promise; submitForReview: () => Promise; - setCurrentStep: (step: number) => void; + + // Analytics + trackStepStart: (stepId: string) => void; + trackStepComplete: (stepId: string) => void; + trackDropOff: (stepId: string, reason?: string) => void; + + // Misc + claimIncentive: () => void; + reset: () => void; + + // Recommendations + getRecommendations: () => Recommendation[]; } -export const useOnboardingStore = create((set, get) => ({ - onboarding: null, - currentStep: 0, - isLoading: false, - error: null, - - fetchOnboarding: async () => { - set({ isLoading: true, error: null }); - try { - // TODO: Replace with actual API call - const response = await fetch('/api/v1/onboarding/merchant/current-merchant-id'); - if (!response.ok) { - throw new Error('Failed to fetch onboarding'); - } - const data = await response.json(); - set({ onboarding: data.data, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to fetch onboarding', - isLoading: false - }); - } - }, +export type OnboardingStore = OnboardingState & OnboardingActions; - updateTask: async (taskId: string, status: TaskStatus, data?: any) => { - const { onboarding } = get(); - if (!onboarding) return; - - set({ isLoading: true, error: null }); - try { - const response = await fetch(`/api/v1/onboarding/${onboarding.id}/task`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - taskId, - status, - data, - }), - }); - - if (!response.ok) { - throw new Error('Failed to update task'); - } - - const result = await response.json(); - set({ onboarding: result.data, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update task', - isLoading: false - }); - } - }, +// ─── Initial analytics ──────────────────────────────────────────────────────── - submitForReview: async () => { - const { onboarding } = get(); - if (!onboarding) return; - - set({ isLoading: true, error: null }); - try { - const response = await fetch(`/api/v1/onboarding/${onboarding.id}/submit`, { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('Failed to submit for review'); - } - - const result = await response.json(); - set({ onboarding: result.data, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to submit for review', - isLoading: false - }); - } - }, +function createInitialAnalytics(): OnboardingAnalytics { + return { + sessionId: generateSessionId(), + variant: assignVariant(), + role: null, + stepsProgress: [], + dropOffEvents: [], + totalTimeMs: 0, + completionRate: 0, + startedAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + }; +} - setCurrentStep: (step: number) => { - set({ currentStep: step }); - }, -})); \ No newline at end of file +// ─── Store ──────────────────────────────────────────────────────────────────── + +export const useOnboardingStore = create()( + persist( + (set, get) => ({ + onboarding: null, + role: null, + currentStepIndex: 0, + steps: ROLE_STEP_PATHS['client'], + isLoading: false, + error: null, + analytics: createInitialAnalytics(), + recommendations: RECOMMENDATIONS, + incentiveClaimed: false, + + // ── Setup ──────────────────────────────────────────────────────────────── + + initWizard: (role) => { + const steps = role ? ROLE_STEP_PATHS[role] : ROLE_STEP_PATHS['client']; + set((state) => ({ + role, + steps, + currentStepIndex: 0, + analytics: { + ...state.analytics, + role, + stepsProgress: steps.map((s) => ({ + stepId: s, + status: 'not_started' as const, + })), + lastActiveAt: new Date().toISOString(), + }, + })); + }, + + setRole: (role) => { + const steps = role ? ROLE_STEP_PATHS[role] : ROLE_STEP_PATHS['client']; + set({ role, steps, currentStepIndex: 0 }); + }, + + // ── Navigation ─────────────────────────────────────────────────────────── + + nextStep: () => { + const { currentStepIndex, steps } = get(); + if (currentStepIndex < steps.length - 1) { + const nextIndex = currentStepIndex + 1; + get().trackStepStart(steps[nextIndex]); + set({ currentStepIndex: nextIndex }); + } + }, + + prevStep: () => { + const { currentStepIndex } = get(); + if (currentStepIndex > 0) { + set({ currentStepIndex: currentStepIndex - 1 }); + } + }, + + goToStep: (index) => { + const { steps } = get(); + if (index >= 0 && index < steps.length) { + get().trackStepStart(steps[index]); + set({ currentStepIndex: index }); + } + }, + + skipStep: (reason) => { + const { currentStepIndex, steps } = get(); + const stepId = steps[currentStepIndex]; + get().trackDropOff(stepId, reason ?? 'user_skipped'); + + const progress = get().analytics.stepsProgress.map((sp) => + sp.stepId === stepId + ? { ...sp, status: 'skipped' as const, skippedAt: new Date().toISOString() } + : sp, + ); + + set((state) => ({ + currentStepIndex: Math.min(currentStepIndex + 1, steps.length - 1), + analytics: { ...state.analytics, stepsProgress: progress }, + })); + }, + + // ── Data ───────────────────────────────────────────────────────────────── + + fetchOnboarding: async () => { + set({ isLoading: true, error: null }); + try { + const res = await fetch('/api/v1/onboarding/merchant/current-merchant-id'); + if (!res.ok) throw new Error('Failed to fetch onboarding'); + const data = await res.json(); + set({ onboarding: data.data, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to fetch onboarding', + isLoading: false, + }); + } + }, + + updateTask: async (taskId, status, data) => { + const { onboarding } = get(); + if (!onboarding) return; + set({ isLoading: true, error: null }); + try { + const res = await fetch(`/api/v1/onboarding/${onboarding.id}/task`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, status, data }), + }); + if (!res.ok) throw new Error('Failed to update task'); + const result = await res.json(); + set({ onboarding: result.data, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update task', + isLoading: false, + }); + } + }, + + submitForReview: async () => { + const { onboarding } = get(); + if (!onboarding) return; + set({ isLoading: true, error: null }); + try { + const res = await fetch(`/api/v1/onboarding/${onboarding.id}/submit`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to submit for review'); + const result = await res.json(); + set({ onboarding: result.data, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to submit for review', + isLoading: false, + }); + } + }, + + // ── Analytics ──────────────────────────────────────────────────────────── + + trackStepStart: (stepId) => { + set((state) => { + const stepsProgress = state.analytics.stepsProgress.map((sp) => + sp.stepId === stepId && sp.status === 'not_started' + ? { ...sp, status: 'in_progress' as const, startedAt: new Date().toISOString() } + : sp, + ); + return { + analytics: { + ...state.analytics, + stepsProgress, + lastActiveAt: new Date().toISOString(), + }, + }; + }); + }, + + trackStepComplete: (stepId) => { + set((state) => { + const now = new Date().toISOString(); + const stepsProgress = state.analytics.stepsProgress.map((sp) => { + if (sp.stepId !== stepId) return sp; + const timeSpentMs = sp.startedAt + ? Date.now() - new Date(sp.startedAt).getTime() + : 0; + return { ...sp, status: 'completed' as const, completedAt: now, timeSpentMs }; + }); + + const completed = stepsProgress.filter((s) => s.status === 'completed' || s.status === 'skipped').length; + const completionRate = stepsProgress.length > 0 ? (completed / stepsProgress.length) * 100 : 0; + const allDone = completed === stepsProgress.length; + + return { + analytics: { + ...state.analytics, + stepsProgress, + completionRate, + lastActiveAt: now, + ...(allDone ? { completedAt: now } : {}), + }, + }; + }); + }, + + trackDropOff: (stepId, reason) => { + set((state) => ({ + analytics: { + ...state.analytics, + dropOffEvents: [ + ...state.analytics.dropOffEvents, + { stepId, timestamp: new Date().toISOString(), reason }, + ], + lastActiveAt: new Date().toISOString(), + }, + })); + }, + + // ── Misc ───────────────────────────────────────────────────────────────── + + claimIncentive: () => set({ incentiveClaimed: true }), + + reset: () => { + set({ + onboarding: null, + role: null, + currentStepIndex: 0, + steps: ROLE_STEP_PATHS['client'], + isLoading: false, + error: null, + analytics: createInitialAnalytics(), + incentiveClaimed: false, + }); + }, + + // ── Recommendations ────────────────────────────────────────────────────── + + getRecommendations: () => { + const { role } = get(); + return RECOMMENDATIONS.filter((r) => !role || r.forRoles.includes(role)).sort((a, b) => { + const order = { high: 0, medium: 1, low: 2 }; + return order[a.priority] - order[b.priority]; + }); + }, + }), + { + name: 'agenticpay-onboarding', + storage: createJSONStorage(() => (typeof localStorage !== 'undefined' ? localStorage : sessionStorage)), + // Only persist the essential state + partialize: (state) => ({ + role: state.role, + currentStepIndex: state.currentStepIndex, + steps: state.steps, + analytics: state.analytics, + incentiveClaimed: state.incentiveClaimed, + onboarding: state.onboarding, + }), + }, + ), +); + +// ─── Selectors ──────────────────────────────────────────────────────────────── + +export const selectCurrentStep = (s: OnboardingStore) => s.steps[s.currentStepIndex]; +export const selectProgress = (s: OnboardingStore) => + s.steps.length > 1 ? Math.round((s.currentStepIndex / (s.steps.length - 1)) * 100) : 0; +export const selectIsLastStep = (s: OnboardingStore) => s.currentStepIndex === s.steps.length - 1; +export const selectIsFirstStep = (s: OnboardingStore) => s.currentStepIndex === 0; +export const selectCompletionRate = (s: OnboardingStore) => s.analytics.completionRate; +export const selectVariant = (s: OnboardingStore) => s.analytics.variant; diff --git a/frontend/store/web3Store.ts b/frontend/store/web3Store.ts index 22f5eb66..039e4380 100644 --- a/frontend/store/web3Store.ts +++ b/frontend/store/web3Store.ts @@ -7,6 +7,38 @@ import { devtools, persist, subscribeWithSelector, createJSONStorage } from 'zus export type ProviderType = 'injected' | 'walletconnect' | 'web3auth' | null; export type TxStatus = 'pending' | 'confirmed' | 'failed'; +export type SupportedChain = 'stellar' | 'ethereum' | 'polygon' | 'base' | 'arbitrum' | 'optimism'; + +export interface ChainBalance { + chainType: SupportedChain; + chainId: string; + address: string; + nativeBalance: string; + nativeBalanceUSD: number; + tokens: Array<{ + assetCode: string; + balance: string; + balanceUSD: number; + decimals: number; + }>; + lastUpdated: string; +} + +export interface AggregatedWalletState { + totalUSD: number; + chains: ChainBalance[]; + lastUpdated: string; + isLoading: boolean; + error: string | null; +} + +export interface ConnectedWallet { + id: string; + chainType: SupportedChain; + address: string; + providerName: string; + isActive: boolean; +} export interface TransactionRecord { hash: string; @@ -28,6 +60,11 @@ interface Web3State { providerType: ProviderType; connectionError: string | null; transactions: TransactionRecord[]; + + // ── Multi-chain state (Issue #593) ────────────────────────────────────────── + connectedWallets: ConnectedWallet[]; + aggregatedBalance: AggregatedWalletState; + activeChain: SupportedChain | null; } interface Web3Actions { @@ -41,6 +78,13 @@ interface Web3Actions { addTransaction: (tx: Omit) => void; updateTransaction: (hash: string, status: TxStatus) => void; clearTransactions: () => void; + + // ── Multi-chain actions (Issue #593) ──────────────────────────────────────── + addWallet: (wallet: ConnectedWallet) => void; + removeWallet: (walletId: string) => void; + setActiveChain: (chain: SupportedChain | null) => void; + setAggregatedBalance: (balance: AggregatedWalletState) => void; + fetchAggregatedBalance: (userId: string) => Promise; } export type Web3Store = Web3State & Web3Actions; @@ -48,7 +92,7 @@ export type Web3Store = Web3State & Web3Actions; // Persisted slice — only serialisable fields type PersistedWeb3 = Pick< Web3State, - 'account' | 'chainId' | 'providerType' | 'isConnected' | 'transactions' + 'account' | 'chainId' | 'providerType' | 'isConnected' | 'transactions' | 'connectedWallets' | 'activeChain' >; // ─── Lightweight XOR-based storage encryption ───────────────────────────────── @@ -122,6 +166,17 @@ const INITIAL_STATE: Web3State = { providerType: null, connectionError: null, transactions: [], + + // Multi-chain (Issue #593) + connectedWallets: [], + activeChain: null, + aggregatedBalance: { + totalUSD: 0, + chains: [], + lastUpdated: '', + isLoading: false, + error: null, + }, }; // ─── Store ──────────────────────────────────────────────────────────────────── @@ -188,6 +243,74 @@ export const useWeb3Store = create()( clearTransactions: () => set({ transactions: [] }, false, 'web3/clearTransactions'), + + // ── Multi-chain actions (Issue #593) ────────────────────────────────── + + addWallet: (wallet) => + set( + (state) => ({ + connectedWallets: [ + ...state.connectedWallets.filter((w) => w.id !== wallet.id), + wallet, + ], + }), + false, + 'web3/addWallet', + ), + + removeWallet: (walletId) => + set( + (state) => ({ + connectedWallets: state.connectedWallets.filter((w) => w.id !== walletId), + }), + false, + 'web3/removeWallet', + ), + + setActiveChain: (chain) => + set({ activeChain: chain }, false, 'web3/setActiveChain'), + + setAggregatedBalance: (balance) => + set({ aggregatedBalance: balance }, false, 'web3/setAggregatedBalance'), + + fetchAggregatedBalance: async (userId: string) => { + set( + (state) => ({ + aggregatedBalance: { ...state.aggregatedBalance, isLoading: true, error: null }, + }), + false, + 'web3/fetchAggregatedBalance/start', + ); + try { + const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + const res = await fetch(`${apiBase}/wallet/aggregated?userId=${userId}`); + if (!res.ok) throw new Error(`API error: ${res.status}`); + const data = await res.json(); + set( + { + aggregatedBalance: { + ...data.data, + isLoading: false, + error: null, + }, + }, + false, + 'web3/fetchAggregatedBalance/success', + ); + } catch (err) { + set( + (state) => ({ + aggregatedBalance: { + ...state.aggregatedBalance, + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to fetch balances', + }, + }), + false, + 'web3/fetchAggregatedBalance/error', + ); + } + }, }), { name: 'agenticpay-web3', @@ -199,6 +322,8 @@ export const useWeb3Store = create()( providerType: state.providerType, isConnected: state.isConnected, transactions: state.transactions, + connectedWallets: state.connectedWallets, + activeChain: state.activeChain, }), } )