From 733e1a45818a6cbacdc2dd6f1800ff8ba650db4e Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 16 Jan 2026 08:33:20 -0600 Subject: [PATCH] =?UTF-8?q?Migraci=C3=B3n=20desde=20trading-platform/apps/?= =?UTF-8?q?mcp-wallet=20-=20Est=C3=A1ndar=20multi-repo=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .env.example | 32 ++ Dockerfile | 56 +++ README.md | 162 +++++++ package.json | 50 ++ src/config.ts | 84 ++++ src/index.ts | 485 ++++++++++++++++++++ src/middleware/auth.middleware.ts | 156 +++++++ src/middleware/index.ts | 6 + src/services/wallet.service.ts | 738 ++++++++++++++++++++++++++++++ src/tools/index.ts | 117 +++++ src/tools/transactions.ts | 301 ++++++++++++ src/tools/wallet.ts | 655 ++++++++++++++++++++++++++ src/types/wallet.types.ts | 268 +++++++++++ src/utils/errors.ts | 212 +++++++++ src/utils/logger.ts | 110 +++++ tsconfig.json | 31 ++ 16 files changed, 3463 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/middleware/auth.middleware.ts create mode 100644 src/middleware/index.ts create mode 100644 src/services/wallet.service.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/transactions.ts create mode 100644 src/tools/wallet.ts create mode 100644 src/types/wallet.types.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/logger.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..06c2fde --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# ============================================================================= +# MCP WALLET SERVER CONFIGURATION +# ============================================================================= + +# Server +PORT=3090 +NODE_ENV=development +LOG_LEVEL=info +API_KEY=your_api_key_here + +# Database (PostgreSQL) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=trading_platform +DB_USER=trading_app +DB_PASSWORD=your_secure_password +DB_SSL=false +DB_POOL_MAX=20 + +# Wallet Defaults +WALLET_DEFAULT_DAILY_LIMIT=1000 +WALLET_DEFAULT_MONTHLY_LIMIT=10000 +WALLET_DEFAULT_SINGLE_TX_LIMIT=500 +WALLET_MAX_DAILY_LIMIT=50000 +WALLET_MAX_SINGLE_TX=10000 +WALLET_CURRENCY=USD +WALLET_PROMO_EXPIRY_DAYS=90 + +# Stripe (for credit purchases) +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLIC_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5fb8943 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# ============================================================================= +# MCP WALLET SERVER DOCKERFILE +# ============================================================================= + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production=false + +# Copy source +COPY tsconfig.json ./ +COPY src ./src + +# Build +RUN npm run build + +# Prune dev dependencies +RUN npm prune --production + +# ============================================================================= +# Production stage +# ============================================================================= +FROM node:20-alpine AS production + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S wallet -u 1001 + +# Copy built artifacts +COPY --from=builder --chown=wallet:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=wallet:nodejs /app/dist ./dist +COPY --from=builder --chown=wallet:nodejs /app/package.json ./ + +# Create logs directory +RUN mkdir -p logs && chown wallet:nodejs logs + +# Switch to non-root user +USER wallet + +# Expose port +EXPOSE 3090 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3090/health || exit 1 + +# Start server +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e612e1 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# MCP Wallet Server + +Virtual Wallet MCP Server for the Trading Platform. Manages credits (USD equivalent) for purchases, subscriptions, and agent funding. + +## Overview + +This service provides: +- **Virtual Wallet System**: Credits-based wallet (1 credit = 1 USD equivalent, not real money) +- **Transaction Management**: Full audit trail of all wallet operations +- **MCP Protocol Support**: Tools can be called by AI agents via MCP +- **REST API**: Traditional API endpoints for frontend integration +- **Multi-tenancy**: Full RLS support for tenant isolation + +## Quick Start + +```bash +# Install dependencies +npm install + +# Copy environment config +cp .env.example .env + +# Start development server +npm run dev + +# Build for production +npm run build + +# Start production server +npm start +``` + +## API Endpoints + +### Health & Info +- `GET /health` - Health check with database status +- `GET /info` - Server info and available tools + +### MCP Protocol +- `GET /mcp/tools` - List available MCP tools +- `POST /mcp/tools/:toolName` - Execute a specific tool +- `POST /mcp/call` - MCP-style call `{name, arguments}` + +### REST API + +#### Wallets +- `POST /api/v1/wallets` - Create wallet +- `GET /api/v1/wallets/:walletId` - Get wallet +- `GET /api/v1/wallets/:walletId/balance` - Get balance with limits +- `GET /api/v1/wallets/:walletId/summary` - Dashboard summary +- `POST /api/v1/wallets/:walletId/credits` - Add credits (Stripe) +- `POST /api/v1/wallets/:walletId/debit` - Debit credits +- `POST /api/v1/wallets/transfer` - Transfer between wallets + +#### Transactions +- `GET /api/v1/wallets/:walletId/transactions` - Transaction history +- `GET /api/v1/transactions/:transactionId` - Get transaction +- `POST /api/v1/wallets/:walletId/refund` - Process refund + +## MCP Tools + +| Tool | Description | Risk Level | +|------|-------------|------------| +| `wallet_create` | Create new wallet | MEDIUM | +| `wallet_get` | Get wallet by ID/user | LOW | +| `wallet_get_balance` | Get balance with limits | LOW | +| `wallet_add_credits` | Add credits via Stripe | HIGH | +| `wallet_debit` | Debit credits | HIGH | +| `wallet_transfer` | Transfer between wallets | HIGH | +| `wallet_add_promo` | Add promotional credits | MEDIUM | +| `wallet_update_limits` | Update spending limits | MEDIUM | +| `wallet_freeze` | Freeze wallet | HIGH | +| `wallet_unfreeze` | Unfreeze wallet | HIGH | +| `transaction_get` | Get transaction details | LOW | +| `transaction_history` | Get transaction history | LOW | +| `wallet_summary` | Dashboard summary | LOW | +| `transaction_refund` | Process refund | HIGH | + +## Transaction Types + +- `CREDIT_PURCHASE` - Buy credits via Stripe +- `AGENT_FUNDING` - Fund Money Manager agent +- `AGENT_WITHDRAWAL` - Withdraw from agent +- `AGENT_PROFIT` - Profit from agent trading +- `AGENT_LOSS` - Loss from agent trading +- `PRODUCT_PURCHASE` - Buy product/service +- `SUBSCRIPTION_CHARGE` - Subscription payment +- `PREDICTION_PURCHASE` - Buy ML predictions +- `REFUND` - Refund to wallet +- `PROMO_CREDIT` - Promotional credits +- `PROMO_EXPIRY` - Expired promo credits +- `ADJUSTMENT` - Manual adjustment +- `TRANSFER_IN/OUT` - Wallet transfers +- `FEE` - Platform fees + +## Environment Variables + +```bash +# Server +PORT=3090 +NODE_ENV=development +LOG_LEVEL=info + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=trading_platform +DB_USER=trading_app +DB_PASSWORD=secret + +# Wallet Config +WALLET_DEFAULT_DAILY_LIMIT=1000 +WALLET_DEFAULT_MONTHLY_LIMIT=10000 +WALLET_DEFAULT_SINGLE_TX_LIMIT=500 +WALLET_CURRENCY=USD + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +``` + +## Docker + +```bash +# Build +docker build -t mcp-wallet:1.0.0 . + +# Run +docker run -p 3090:3090 --env-file .env mcp-wallet:1.0.0 +``` + +## Architecture + +``` +src/ +├── index.ts # Express server entry point +├── config.ts # Configuration & DB pool +├── types/ +│ └── wallet.types.ts # TypeScript interfaces +├── services/ +│ └── wallet.service.ts # Business logic +├── tools/ +│ ├── index.ts # Tool registry +│ ├── wallet.ts # Wallet operations +│ └── transactions.ts # Transaction operations +└── utils/ + ├── logger.ts # Winston logging + └── errors.ts # Custom error classes +``` + +## Testing + +```bash +# Run tests +npm test + +# Watch mode +npm run test:watch +``` + +## License + +UNLICENSED - Private repository diff --git a/package.json b/package.json new file mode 100644 index 0000000..67785c6 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "@trading-platform/mcp-wallet", + "version": "1.0.0", + "description": "MCP Server for Virtual Wallet operations - Credits system (USD equivalent)", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.18.2", + "pg": "^8.11.3", + "zod": "^3.22.4", + "winston": "^3.11.0", + "decimal.js": "^10.4.3", + "uuid": "^9.0.1", + "dotenv": "^16.3.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.5", + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/cors": "^2.8.17", + "typescript": "^5.3.2", + "ts-node-dev": "^2.0.0", + "jest": "^29.7.0", + "@types/jest": "^29.5.11", + "ts-jest": "^29.1.1", + "eslint": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^6.13.0", + "@typescript-eslint/parser": "^6.13.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "author": "Trading Platform Team", + "license": "UNLICENSED", + "private": true +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c7886e4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,84 @@ +import { Pool, PoolConfig } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Server configuration +export const serverConfig = { + port: parseInt(process.env.PORT || '3090', 10), + nodeEnv: process.env.NODE_ENV || 'development', + logLevel: process.env.LOG_LEVEL || 'info', + apiKey: process.env.API_KEY || '', +}; + +// Database configuration +export const dbConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'trading_platform', + user: process.env.DB_USER || 'trading_app', + password: process.env.DB_PASSWORD || '', + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + max: parseInt(process.env.DB_POOL_MAX || '20', 10), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}; + +// Wallet configuration +export const walletConfig = { + defaultDailyLimit: parseFloat(process.env.WALLET_DEFAULT_DAILY_LIMIT || '1000'), + defaultMonthlyLimit: parseFloat(process.env.WALLET_DEFAULT_MONTHLY_LIMIT || '10000'), + defaultSingleTxLimit: parseFloat(process.env.WALLET_DEFAULT_SINGLE_TX_LIMIT || '500'), + maxDailyLimit: parseFloat(process.env.WALLET_MAX_DAILY_LIMIT || '50000'), + maxSingleTransaction: parseFloat(process.env.WALLET_MAX_SINGLE_TX || '10000'), + currency: process.env.WALLET_CURRENCY || 'USD', + promoExpiryDays: parseInt(process.env.WALLET_PROMO_EXPIRY_DAYS || '90', 10), +}; + +// Stripe configuration (for credit purchases) +export const stripeConfig = { + secretKey: process.env.STRIPE_SECRET_KEY || '', + publicKey: process.env.STRIPE_PUBLIC_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', +}; + +// Database pool singleton +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(dbConfig); + + pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + }); + + pool.on('connect', () => { + if (serverConfig.nodeEnv === 'development') { + console.log('New database connection established'); + } + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} + +// Helper to set tenant context for RLS +export async function setTenantContext( + client: ReturnType extends Promise ? T : never, + tenantId: string +): Promise { + await client.query(`SET app.current_tenant_id = $1`, [tenantId]); +} + +// Validation helpers +export function isValidUUID(str: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1b45316 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,485 @@ +/** + * MCP Wallet Server + * + * Virtual wallet system for trading platform. + * Manages credits (USD equivalent) for purchases, subscriptions, and agent funding. + */ + +import express, { Request, Response, NextFunction } from 'express'; +import helmet from 'helmet'; +import cors from 'cors'; +import { ZodError } from 'zod'; + +import { serverConfig, closePool, getPool } from './config'; +import { logger } from './utils/logger'; +import { WalletError, isWalletError } from './utils/errors'; +import { allToolSchemas, toolHandlers, toolNames } from './tools'; +import { authMiddleware, optionalAuthMiddleware } from './middleware/auth.middleware'; + +// ============================================================================ +// EXPRESS APP SETUP +// ============================================================================ + +const app = express(); + +// Middleware +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); + +// Request logging middleware +app.use((req: Request, _res: Response, next: NextFunction) => { + logger.debug('Incoming request', { + method: req.method, + path: req.path, + query: req.query, + }); + next(); +}); + +// ============================================================================ +// HEALTH & INFO ENDPOINTS +// ============================================================================ + +app.get('/health', async (_req: Request, res: Response) => { + try { + // Test database connection + const pool = getPool(); + await pool.query('SELECT 1'); + + res.json({ + status: 'healthy', + service: 'mcp-wallet', + timestamp: new Date().toISOString(), + database: 'connected', + }); + } catch (error) { + logger.error('Health check failed', { error }); + res.status(503).json({ + status: 'unhealthy', + service: 'mcp-wallet', + timestamp: new Date().toISOString(), + database: 'disconnected', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +app.get('/info', (_req: Request, res: Response) => { + res.json({ + name: 'mcp-wallet', + version: '1.0.0', + description: 'Virtual Wallet MCP Server for Trading Platform', + tools: toolNames, + toolCount: toolNames.length, + }); +}); + +// ============================================================================ +// MCP PROTOCOL ENDPOINTS +// ============================================================================ + +// List available tools +app.get('/mcp/tools', (_req: Request, res: Response) => { + res.json({ + tools: Object.values(allToolSchemas), + }); +}); + +// Execute a tool +app.post('/mcp/tools/:toolName', async (req: Request, res: Response, next: NextFunction) => { + const { toolName } = req.params; + const params = req.body; + + logger.info('Tool execution requested', { toolName, params }); + + const handler = toolHandlers[toolName]; + + if (!handler) { + res.status(404).json({ + error: `Tool not found: ${toolName}`, + availableTools: toolNames, + }); + return; + } + + try { + const result = await handler(params); + res.json(result); + } catch (error) { + next(error); + } +}); + +// MCP-style call endpoint (alternative format) +app.post('/mcp/call', async (req: Request, res: Response, next: NextFunction) => { + const { name, arguments: args } = req.body; + + if (!name) { + res.status(400).json({ error: 'Tool name is required' }); + return; + } + + logger.info('MCP call requested', { name, args }); + + const handler = toolHandlers[name]; + + if (!handler) { + res.status(404).json({ + error: `Tool not found: ${name}`, + availableTools: toolNames, + }); + return; + } + + try { + const result = await handler(args || {}); + res.json(result); + } catch (error) { + next(error); + } +}); + +// ============================================================================ +// REST API ENDPOINTS (convenience wrappers) +// Protected by auth middleware +// ============================================================================ + +// Create protected router for API endpoints +const apiRouter = express.Router(); + +// Apply auth middleware to all API routes +apiRouter.use(authMiddleware); + +// Wallet endpoints +apiRouter.post('/wallets', async (req: Request, res: Response, next: NextFunction) => { + try { + // Use tenantId from authenticated user + const result = await toolHandlers.wallet_create({ + ...req.body, + tenantId: req.tenantId, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/me', async (req: Request, res: Response, next: NextFunction) => { + try { + // Get wallet for authenticated user + const result = await toolHandlers.wallet_get_by_user({ + userId: req.userId, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/:walletId', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_get({ + walletId: req.params.walletId, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/:walletId/balance', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_get_balance({ + walletId: req.params.walletId, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/me/summary', async (req: Request, res: Response, next: NextFunction) => { + try { + // Get summary for authenticated user's wallet + const wallet = await toolHandlers.wallet_get_by_user({ + userId: req.userId, + tenantId: req.tenantId, + }); + const result = await toolHandlers.wallet_summary({ + walletId: wallet.data.id, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/:walletId/summary', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_summary({ + walletId: req.params.walletId, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/me/deposit', async (req: Request, res: Response, next: NextFunction) => { + try { + // Get wallet for authenticated user first + const wallet = await toolHandlers.wallet_get_by_user({ + userId: req.userId, + tenantId: req.tenantId, + }); + const result = await toolHandlers.wallet_add_credits({ + walletId: wallet.data.id, + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/:walletId/credits', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_add_credits({ + walletId: req.params.walletId, + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/me/withdraw', async (req: Request, res: Response, next: NextFunction) => { + try { + // Get wallet for authenticated user first + const wallet = await toolHandlers.wallet_get_by_user({ + userId: req.userId, + tenantId: req.tenantId, + }); + const result = await toolHandlers.wallet_debit({ + walletId: wallet.data.id, + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/:walletId/debit', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_debit({ + walletId: req.params.walletId, + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/transfer', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.wallet_transfer({ + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +// Transaction endpoints +apiRouter.get('/wallets/me/transactions', async (req: Request, res: Response, next: NextFunction) => { + try { + // Get wallet for authenticated user first + const wallet = await toolHandlers.wallet_get_by_user({ + userId: req.userId, + tenantId: req.tenantId, + }); + const result = await toolHandlers.transaction_history({ + walletId: wallet.data.id, + tenantId: req.tenantId, + type: req.query.type as string, + status: req.query.status as string, + fromDate: req.query.fromDate as string, + toDate: req.query.toDate as string, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/wallets/:walletId/transactions', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.transaction_history({ + walletId: req.params.walletId, + tenantId: req.tenantId, + type: req.query.type as string, + status: req.query.status as string, + fromDate: req.query.fromDate as string, + toDate: req.query.toDate as string, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.get('/transactions/:transactionId', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.transaction_get({ + transactionId: req.params.transactionId, + tenantId: req.tenantId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +apiRouter.post('/wallets/:walletId/refund', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.transaction_refund({ + walletId: req.params.walletId, + tenantId: req.tenantId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +// Mount API router +app.use('/api/v1', apiRouter); + +// ============================================================================ +// ERROR HANDLING +// ============================================================================ + +// 404 handler +app.use((_req: Request, res: Response) => { + res.status(404).json({ + error: 'Not found', + message: 'The requested endpoint does not exist', + }); +}); + +// Global error handler +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + // Zod validation errors + if (err instanceof ZodError) { + logger.warn('Validation error', { errors: err.errors }); + res.status(400).json({ + error: 'Validation error', + details: err.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }); + return; + } + + // Wallet business errors + if (isWalletError(err)) { + logger.warn('Wallet error', { error: err.toJSON() }); + res.status(err.statusCode).json({ + error: err.message, + code: err.code, + details: err.details, + }); + return; + } + + // Unexpected errors + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ + error: 'Internal server error', + message: serverConfig.nodeEnv === 'development' ? err.message : 'An unexpected error occurred', + }); +}); + +// ============================================================================ +// SERVER STARTUP +// ============================================================================ + +const server = app.listen(serverConfig.port, () => { + logger.info(`MCP Wallet Server started`, { + port: serverConfig.port, + env: serverConfig.nodeEnv, + tools: toolNames.length, + }); + + console.log(` +╔════════════════════════════════════════════════════════════╗ +║ MCP WALLET SERVER ║ +╠════════════════════════════════════════════════════════════╣ +║ Status: Running ║ +║ Port: ${serverConfig.port} ║ +║ Env: ${serverConfig.nodeEnv.padEnd(12)} ║ +║ Tools: ${String(toolNames.length).padEnd(12)} ║ +╠════════════════════════════════════════════════════════════╣ +║ Endpoints: ║ +║ GET /health - Health check ║ +║ GET /info - Server info ║ +║ GET /mcp/tools - List MCP tools ║ +║ POST /mcp/tools/:name - Execute MCP tool ║ +║ POST /mcp/call - MCP-style call ║ +║ /api/v1/wallets/* - REST API endpoints ║ +╚════════════════════════════════════════════════════════════╝ + `); +}); + +// ============================================================================ +// GRACEFUL SHUTDOWN +// ============================================================================ + +async function shutdown(signal: string) { + logger.info(`Received ${signal}, shutting down gracefully...`); + + server.close(async () => { + logger.info('HTTP server closed'); + + try { + await closePool(); + logger.info('Database pool closed'); + } catch (error) { + logger.error('Error closing database pool', { error }); + } + + process.exit(0); + }); + + // Force exit after 30 seconds + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +export { app }; diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..bbfb4e5 --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,156 @@ +/** + * Auth Middleware + * Verifies JWT tokens and sets user context + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { logger } from '../utils/logger'; + +// JWT configuration (should match mcp-auth config) +const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits'; + +// JWT Payload interface +export interface JWTPayload { + sub: string; // user_id + email: string; + tenantId: string; + isOwner: boolean; + iat: number; + exp: number; +} + +// Extend Express Request to include auth info +declare global { + namespace Express { + interface Request { + userId?: string; + tenantId?: string; + userEmail?: string; + isOwner?: boolean; + isAuthenticated?: boolean; + } + } +} + +/** + * Auth middleware that verifies JWT tokens + * If valid, sets userId, tenantId, and userEmail on request + */ +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + error: 'Unauthorized', + code: 'MISSING_TOKEN', + message: 'No authentication token provided', + }); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + // Set auth info on request + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + + // Also set tenant ID header for RLS queries + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + res.status(401).json({ + error: 'Unauthorized', + code: 'TOKEN_EXPIRED', + message: 'Token has expired', + }); + return; + } + + if (error instanceof jwt.JsonWebTokenError) { + res.status(401).json({ + error: 'Unauthorized', + code: 'INVALID_TOKEN', + message: 'Invalid token', + }); + return; + } + + logger.error('Auth middleware error', { error }); + res.status(500).json({ + error: 'Internal server error', + code: 'AUTH_ERROR', + }); + } +} + +/** + * Optional auth middleware + * Sets auth info if token is valid, but allows unauthenticated requests + */ +export function optionalAuthMiddleware(req: Request, _res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.isAuthenticated = false; + next(); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + } catch { + req.isAuthenticated = false; + } + + next(); +} + +/** + * Tenant validation middleware + * Ensures tenant ID in request matches authenticated user's tenant + */ +export function validateTenant(req: Request, res: Response, next: NextFunction): void { + const requestTenantId = req.headers['x-tenant-id'] || req.query.tenantId || req.body?.tenantId; + + if (!req.tenantId) { + res.status(401).json({ + error: 'Unauthorized', + code: 'NO_TENANT_CONTEXT', + message: 'No tenant context', + }); + return; + } + + // If a tenant ID is explicitly provided in the request, validate it matches + if (requestTenantId && requestTenantId !== req.tenantId) { + res.status(403).json({ + error: 'Forbidden', + code: 'TENANT_MISMATCH', + message: 'Tenant ID mismatch', + }); + return; + } + + next(); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..4283124 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,6 @@ +/** + * Middleware exports + */ + +export { authMiddleware, optionalAuthMiddleware, validateTenant } from './auth.middleware'; +export type { JWTPayload } from './auth.middleware'; diff --git a/src/services/wallet.service.ts b/src/services/wallet.service.ts new file mode 100644 index 0000000..15852ef --- /dev/null +++ b/src/services/wallet.service.ts @@ -0,0 +1,738 @@ +import { Pool, PoolClient } from 'pg'; +import Decimal from 'decimal.js'; +import { v4 as uuidv4 } from 'uuid'; +import { getPool, setTenantContext, walletConfig } from '../config'; +import { logger, logWalletOperation, logTransaction, logError } from '../utils/logger'; +import { + WalletError, + WalletNotFoundError, + InsufficientBalanceError, + DuplicateWalletError, + WalletFrozenError, + mapPostgresError, +} from '../utils/errors'; +import { + Wallet, + WalletBalance, + WalletTransaction, + TransactionType, + CreateWalletInput, + CreditPurchaseInput, + DebitInput, + TransferInput, + AddPromoCreditsInput, + TransactionHistoryQuery, + PaginatedResult, + WalletSummary, + mapRowToWallet, + mapRowToTransaction, +} from '../types/wallet.types'; + +/** + * Wallet Service - Core business logic for wallet operations + */ +export class WalletService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + /** + * Create a new wallet for a user + */ + async createWallet(input: CreateWalletInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + const query = ` + INSERT INTO financial.wallets ( + tenant_id, user_id, balance, currency, + daily_spend_limit, monthly_spend_limit, single_transaction_limit + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + + const values = [ + input.tenantId, + input.userId, + input.initialBalance || 0, + input.currency || walletConfig.currency, + input.dailySpendLimit || walletConfig.defaultDailyLimit, + input.monthlySpendLimit || walletConfig.defaultMonthlyLimit, + input.singleTransactionLimit || walletConfig.defaultSingleTxLimit, + ]; + + const result = await client.query(query, values); + const wallet = mapRowToWallet(result.rows[0]); + + logWalletOperation('CREATE', wallet.id, input.userId, { + initialBalance: input.initialBalance || 0, + }); + + return wallet; + } catch (error) { + logError('createWallet', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Get wallet by ID + */ + async getWalletById(walletId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + 'SELECT * FROM financial.wallets WHERE id = $1', + [walletId] + ); + + if (result.rows.length === 0) { + throw new WalletNotFoundError(walletId); + } + + return mapRowToWallet(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Get wallet by user ID + */ + async getWalletByUserId(userId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + 'SELECT * FROM financial.wallets WHERE user_id = $1', + [userId] + ); + + if (result.rows.length === 0) { + throw new WalletNotFoundError(undefined, userId); + } + + return mapRowToWallet(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Get wallet balance with limits info + */ + async getWalletBalance(walletId: string, tenantId: string): Promise { + const wallet = await this.getWalletById(walletId, tenantId); + + return { + walletId: wallet.id, + available: wallet.balance, + reserved: wallet.reserved, + promo: wallet.promoBalance, + total: wallet.balance.plus(wallet.reserved).plus(wallet.promoBalance), + currency: wallet.currency, + status: wallet.status, + limits: { + dailySpendLimit: wallet.dailySpendLimit, + monthlySpendLimit: wallet.monthlySpendLimit, + singleTransactionLimit: wallet.singleTransactionLimit, + dailySpent: wallet.dailySpent, + monthlySpent: wallet.monthlySpent, + dailyRemaining: wallet.dailySpendLimit.minus(wallet.dailySpent), + monthlyRemaining: wallet.monthlySpendLimit.minus(wallet.monthlySpent), + }, + }; + } + + /** + * Add credits via Stripe payment (CREDIT_PURCHASE) + */ + async addCredits(input: CreditPurchaseInput, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + // Use stored procedure for atomic operation + const result = await client.query( + `SELECT financial.create_wallet_transaction( + $1, 'CREDIT_PURCHASE', $2, $3, NULL, NULL, $4 + ) as transaction_id`, + [ + input.walletId, + input.amount, + `Credit purchase via ${input.paymentMethod || 'card'}`, + JSON.stringify({ + stripe_payment_intent_id: input.stripePaymentIntentId, + stripe_charge_id: input.stripeChargeId, + payment_method: input.paymentMethod, + ...input.metadata, + }), + ] + ); + + const transactionId = result.rows[0].transaction_id; + + // Update Stripe references in transaction + await client.query( + `UPDATE financial.wallet_transactions + SET stripe_payment_intent_id = $1, stripe_charge_id = $2, payment_method = $3 + WHERE id = $4`, + [input.stripePaymentIntentId, input.stripeChargeId, input.paymentMethod, transactionId] + ); + + await client.query('COMMIT'); + + const transaction = await this.getTransactionById(transactionId, tenantId); + + logTransaction('CREDIT_PURCHASE', input.walletId, input.amount, transactionId, { + stripePaymentIntentId: input.stripePaymentIntentId, + }); + + return transaction; + } catch (error) { + await client.query('ROLLBACK'); + logError('addCredits', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Debit credits from wallet + */ + async debitCredits(input: DebitInput, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + // Amount should be negative for debits + const amount = -Math.abs(input.amount); + + const result = await client.query( + `SELECT financial.create_wallet_transaction( + $1, $2::financial.transaction_type, $3, $4, $5, $6, $7 + ) as transaction_id`, + [ + input.walletId, + input.type, + amount, + input.description || `${input.type} transaction`, + input.referenceType || null, + input.referenceId || null, + JSON.stringify(input.metadata || {}), + ] + ); + + const transactionId = result.rows[0].transaction_id; + + await client.query('COMMIT'); + + const transaction = await this.getTransactionById(transactionId, tenantId); + + logTransaction(input.type, input.walletId, amount, transactionId, { + referenceType: input.referenceType, + referenceId: input.referenceId, + }); + + return transaction; + } catch (error) { + await client.query('ROLLBACK'); + logError('debitCredits', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Transfer credits between wallets + */ + async transfer(input: TransferInput, tenantId: string): Promise<{ + fromTransaction: WalletTransaction; + toTransaction: WalletTransaction; + }> { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + // Debit from source wallet + const fromResult = await client.query( + `SELECT financial.create_wallet_transaction( + $1, 'TRANSFER_OUT', $2, $3, 'transfer', NULL, $4 + ) as transaction_id`, + [ + input.fromWalletId, + -Math.abs(input.amount), + input.description || 'Transfer out', + JSON.stringify({ toWalletId: input.toWalletId, ...input.metadata }), + ] + ); + + const fromTransactionId = fromResult.rows[0].transaction_id; + + // Credit to destination wallet + const toResult = await client.query( + `SELECT financial.create_wallet_transaction( + $1, 'TRANSFER_IN', $2, $3, 'transfer', NULL, $4 + ) as transaction_id`, + [ + input.toWalletId, + Math.abs(input.amount), + input.description || 'Transfer in', + JSON.stringify({ fromWalletId: input.fromWalletId, ...input.metadata }), + ] + ); + + const toTransactionId = toResult.rows[0].transaction_id; + + // Link related transactions + await client.query( + `UPDATE financial.wallet_transactions + SET related_wallet_id = $1, related_transaction_id = $2 + WHERE id = $3`, + [input.toWalletId, toTransactionId, fromTransactionId] + ); + + await client.query( + `UPDATE financial.wallet_transactions + SET related_wallet_id = $1, related_transaction_id = $2 + WHERE id = $3`, + [input.fromWalletId, fromTransactionId, toTransactionId] + ); + + await client.query('COMMIT'); + + const fromTransaction = await this.getTransactionById(fromTransactionId, tenantId); + const toTransaction = await this.getTransactionById(toTransactionId, tenantId); + + logWalletOperation('TRANSFER', input.fromWalletId, '', { + toWalletId: input.toWalletId, + amount: input.amount, + }); + + return { fromTransaction, toTransaction }; + } catch (error) { + await client.query('ROLLBACK'); + logError('transfer', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Add promotional credits + */ + async addPromoCredits(input: AddPromoCreditsInput, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + // Calculate expiry date + const expiryDays = input.expiryDays || walletConfig.promoExpiryDays; + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + expiryDays); + + // Update promo balance + await client.query( + `UPDATE financial.wallets + SET promo_balance = promo_balance + $1, + promo_expiry = COALESCE(promo_expiry, $2) + WHERE id = $3`, + [input.amount, expiryDate, input.walletId] + ); + + // Create transaction record + const result = await client.query( + `INSERT INTO financial.wallet_transactions ( + wallet_id, tenant_id, type, status, amount, + balance_before, balance_after, description, metadata + ) + SELECT + id, tenant_id, 'PROMO_CREDIT', 'completed', $1, + promo_balance - $1, promo_balance, $2, $3 + FROM financial.wallets WHERE id = $4 + RETURNING id`, + [ + input.amount, + input.description || 'Promotional credits added', + JSON.stringify({ expiryDate, ...input.metadata }), + input.walletId, + ] + ); + + const transactionId = result.rows[0].id; + + await client.query('COMMIT'); + + const transaction = await this.getTransactionById(transactionId, tenantId); + + logTransaction('PROMO_CREDIT', input.walletId, input.amount, transactionId, { + expiryDate, + }); + + return transaction; + } catch (error) { + await client.query('ROLLBACK'); + logError('addPromoCredits', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Process refund + */ + async refund( + walletId: string, + amount: number, + originalTransactionId: string, + reason: string, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT financial.create_wallet_transaction( + $1, 'REFUND', $2, $3, 'refund', $4, $5 + ) as transaction_id`, + [ + walletId, + Math.abs(amount), + `Refund: ${reason}`, + originalTransactionId, + JSON.stringify({ originalTransactionId, reason }), + ] + ); + + const transactionId = result.rows[0].transaction_id; + + await client.query('COMMIT'); + + const transaction = await this.getTransactionById(transactionId, tenantId); + + logTransaction('REFUND', walletId, amount, transactionId, { + originalTransactionId, + reason, + }); + + return transaction; + } catch (error) { + await client.query('ROLLBACK'); + logError('refund', error as Error, { walletId, amount, originalTransactionId }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Get transaction by ID + */ + async getTransactionById(transactionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + 'SELECT * FROM financial.wallet_transactions WHERE id = $1', + [transactionId] + ); + + if (result.rows.length === 0) { + throw new WalletError('Transaction not found', 'TRANSACTION_NOT_FOUND', 404); + } + + return mapRowToTransaction(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Get transaction history + */ + async getTransactionHistory( + query: TransactionHistoryQuery, + tenantId: string + ): Promise> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const conditions: string[] = ['wallet_id = $1']; + const values: unknown[] = [query.walletId]; + let paramIndex = 2; + + if (query.type) { + conditions.push(`type = $${paramIndex}`); + values.push(query.type); + paramIndex++; + } + + if (query.status) { + conditions.push(`status = $${paramIndex}`); + values.push(query.status); + paramIndex++; + } + + if (query.fromDate) { + conditions.push(`created_at >= $${paramIndex}`); + values.push(query.fromDate); + paramIndex++; + } + + if (query.toDate) { + conditions.push(`created_at <= $${paramIndex}`); + values.push(query.toDate); + paramIndex++; + } + + const whereClause = conditions.join(' AND '); + const limit = query.limit || 50; + const offset = query.offset || 0; + + // Get total count + const countResult = await client.query( + `SELECT COUNT(*) as total FROM financial.wallet_transactions WHERE ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].total, 10); + + // Get paginated data + const dataResult = await client.query( + `SELECT * FROM financial.wallet_transactions + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...values, limit, offset] + ); + + const transactions = dataResult.rows.map(mapRowToTransaction); + + return { + data: transactions, + total, + limit, + offset, + hasMore: offset + transactions.length < total, + }; + } finally { + client.release(); + } + } + + /** + * Get wallet summary for dashboard + */ + async getWalletSummary(walletId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const wallet = await this.getWalletById(walletId, tenantId); + + // Get recent transactions + const recentResult = await client.query( + `SELECT * FROM financial.wallet_transactions + WHERE wallet_id = $1 + ORDER BY created_at DESC + LIMIT 10`, + [walletId] + ); + + // Get monthly stats + const statsResult = await client.query( + `SELECT + COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) as credits, + COALESCE(SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END), 0) as debits, + COALESCE(SUM(amount), 0) as net_change, + COUNT(*) as transaction_count + FROM financial.wallet_transactions + WHERE wallet_id = $1 + AND created_at >= DATE_TRUNC('month', CURRENT_DATE)`, + [walletId] + ); + + const stats = statsResult.rows[0]; + + return { + walletId: wallet.id, + balance: wallet.balance, + currency: wallet.currency, + totalCredited: wallet.totalCredited, + totalDebited: wallet.totalDebited, + recentTransactions: recentResult.rows.map(mapRowToTransaction), + monthlyStats: { + credits: new Decimal(stats.credits), + debits: new Decimal(stats.debits), + netChange: new Decimal(stats.net_change), + transactionCount: parseInt(stats.transaction_count, 10), + }, + }; + } finally { + client.release(); + } + } + + /** + * Update wallet limits + */ + async updateLimits( + walletId: string, + limits: { + dailySpendLimit?: number; + monthlySpendLimit?: number; + singleTransactionLimit?: number; + }, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (limits.dailySpendLimit !== undefined) { + updates.push(`daily_spend_limit = $${paramIndex}`); + values.push(Math.min(limits.dailySpendLimit, walletConfig.maxDailyLimit)); + paramIndex++; + } + + if (limits.monthlySpendLimit !== undefined) { + updates.push(`monthly_spend_limit = $${paramIndex}`); + values.push(limits.monthlySpendLimit); + paramIndex++; + } + + if (limits.singleTransactionLimit !== undefined) { + updates.push(`single_transaction_limit = $${paramIndex}`); + values.push(Math.min(limits.singleTransactionLimit, walletConfig.maxSingleTransaction)); + paramIndex++; + } + + if (updates.length === 0) { + return this.getWalletById(walletId, tenantId); + } + + values.push(walletId); + + const result = await client.query( + `UPDATE financial.wallets + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + throw new WalletNotFoundError(walletId); + } + + logWalletOperation('UPDATE_LIMITS', walletId, '', { limits }); + + return mapRowToWallet(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Freeze wallet + */ + async freezeWallet(walletId: string, reason: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE financial.wallets + SET status = 'frozen' + WHERE id = $1 AND status = 'active' + RETURNING *`, + [walletId] + ); + + if (result.rows.length === 0) { + throw new WalletNotFoundError(walletId); + } + + logWalletOperation('FREEZE', walletId, '', { reason }); + + return mapRowToWallet(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Unfreeze wallet + */ + async unfreezeWallet(walletId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE financial.wallets + SET status = 'active' + WHERE id = $1 AND status = 'frozen' + RETURNING *`, + [walletId] + ); + + if (result.rows.length === 0) { + throw new WalletNotFoundError(walletId); + } + + logWalletOperation('UNFREEZE', walletId, ''); + + return mapRowToWallet(result.rows[0]); + } finally { + client.release(); + } + } +} + +// Singleton instance +let walletServiceInstance: WalletService | null = null; + +export function getWalletService(): WalletService { + if (!walletServiceInstance) { + walletServiceInstance = new WalletService(); + } + return walletServiceInstance; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..63d6c01 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,117 @@ +/** + * MCP Wallet Tools Registry + * + * Exports all wallet and transaction tools for MCP server registration + */ + +// Wallet tools +export { + // Tool implementations + wallet_create, + wallet_get, + wallet_get_balance, + wallet_add_credits, + wallet_debit, + wallet_transfer, + wallet_add_promo, + wallet_update_limits, + wallet_freeze, + wallet_unfreeze, + // MCP handlers + handleWalletCreate, + handleWalletGet, + handleWalletGetBalance, + handleWalletAddCredits, + handleWalletDebit, + handleWalletTransfer, + handleWalletAddPromo, + handleWalletUpdateLimits, + handleWalletFreeze, + handleWalletUnfreeze, + // Schemas + walletToolSchemas, + // Input schemas (for external validation) + CreateWalletInputSchema, + GetWalletInputSchema, + GetBalanceInputSchema, + AddCreditsInputSchema, + DebitCreditsInputSchema, + TransferInputSchema, + AddPromoCreditsInputSchema, + UpdateLimitsInputSchema, + FreezeWalletInputSchema, + UnfreezeWalletInputSchema, +} from './wallet'; + +// Transaction tools +export { + // Tool implementations + transaction_get, + transaction_history, + wallet_summary, + transaction_refund, + // MCP handlers + handleTransactionGet, + handleTransactionHistory, + handleWalletSummary, + handleTransactionRefund, + // Schemas + transactionToolSchemas, + // Input schemas + GetTransactionInputSchema, + GetTransactionHistoryInputSchema, + GetWalletSummaryInputSchema, + RefundInputSchema, +} from './transactions'; + +// Combined tool schemas for MCP registration +import { walletToolSchemas } from './wallet'; +import { transactionToolSchemas } from './transactions'; + +export const allToolSchemas = { + ...walletToolSchemas, + ...transactionToolSchemas, +}; + +// Tool handler registry +import { + handleWalletCreate, + handleWalletGet, + handleWalletGetBalance, + handleWalletAddCredits, + handleWalletDebit, + handleWalletTransfer, + handleWalletAddPromo, + handleWalletUpdateLimits, + handleWalletFreeze, + handleWalletUnfreeze, +} from './wallet'; + +import { + handleTransactionGet, + handleTransactionHistory, + handleWalletSummary, + handleTransactionRefund, +} from './transactions'; + +export const toolHandlers: Record Promise<{ content: Array<{ type: string; text: string }> }>> = { + // Wallet tools + wallet_create: handleWalletCreate, + wallet_get: handleWalletGet, + wallet_get_balance: handleWalletGetBalance, + wallet_add_credits: handleWalletAddCredits, + wallet_debit: handleWalletDebit, + wallet_transfer: handleWalletTransfer, + wallet_add_promo: handleWalletAddPromo, + wallet_update_limits: handleWalletUpdateLimits, + wallet_freeze: handleWalletFreeze, + wallet_unfreeze: handleWalletUnfreeze, + // Transaction tools + transaction_get: handleTransactionGet, + transaction_history: handleTransactionHistory, + wallet_summary: handleWalletSummary, + transaction_refund: handleTransactionRefund, +}; + +// Get list of all tool names +export const toolNames = Object.keys(allToolSchemas); diff --git a/src/tools/transactions.ts b/src/tools/transactions.ts new file mode 100644 index 0000000..f6431ad --- /dev/null +++ b/src/tools/transactions.ts @@ -0,0 +1,301 @@ +import { z } from 'zod'; +import { getWalletService } from '../services/wallet.service'; +import { isWalletError } from '../utils/errors'; +import { logger } from '../utils/logger'; +import { + WalletTransaction, + PaginatedResult, + WalletSummary, + TransactionType, + TransactionStatus, +} from '../types/wallet.types'; + +// ============================================================================ +// INPUT SCHEMAS +// ============================================================================ + +export const GetTransactionInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + transactionId: z.string().uuid('Invalid transaction ID'), +}); + +export const GetTransactionHistoryInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + type: z.enum([ + 'CREDIT_PURCHASE', + 'AGENT_FUNDING', + 'AGENT_WITHDRAWAL', + 'AGENT_PROFIT', + 'AGENT_LOSS', + 'PRODUCT_PURCHASE', + 'SUBSCRIPTION_CHARGE', + 'PREDICTION_PURCHASE', + 'REFUND', + 'PROMO_CREDIT', + 'PROMO_EXPIRY', + 'ADJUSTMENT', + 'TRANSFER_IN', + 'TRANSFER_OUT', + 'FEE', + ] as const).optional(), + status: z.enum(['pending', 'completed', 'failed', 'cancelled', 'reversed'] as const).optional(), + fromDate: z.string().datetime().optional(), + toDate: z.string().datetime().optional(), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}); + +export const GetWalletSummaryInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), +}); + +export const RefundInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + amount: z.number().positive('Amount must be positive'), + originalTransactionId: z.string().uuid('Invalid original transaction ID'), + reason: z.string().min(1, 'Reason is required'), +}); + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +interface ToolResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ============================================================================ +// TOOL IMPLEMENTATIONS +// ============================================================================ + +/** + * Get a specific transaction by ID + */ +export async function transaction_get( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const transaction = await service.getTransactionById( + params.transactionId, + params.tenantId + ); + return { success: true, data: transaction }; + } catch (error) { + logger.error('transaction_get failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get transaction' }; + } +} + +/** + * Get transaction history for a wallet + */ +export async function transaction_history( + params: z.infer +): Promise>> { + try { + const service = getWalletService(); + const history = await service.getTransactionHistory( + { + walletId: params.walletId, + type: params.type as TransactionType | undefined, + status: params.status as TransactionStatus | undefined, + fromDate: params.fromDate ? new Date(params.fromDate) : undefined, + toDate: params.toDate ? new Date(params.toDate) : undefined, + limit: params.limit, + offset: params.offset, + }, + params.tenantId + ); + return { success: true, data: history }; + } catch (error) { + logger.error('transaction_history failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get transaction history' }; + } +} + +/** + * Get wallet summary for dashboard + */ +export async function wallet_summary( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const summary = await service.getWalletSummary(params.walletId, params.tenantId); + return { success: true, data: summary }; + } catch (error) { + logger.error('wallet_summary failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get wallet summary' }; + } +} + +/** + * Process a refund + */ +export async function transaction_refund( + params: z.infer +): Promise { + try { + const service = getWalletService(); + const transaction = await service.refund( + params.walletId, + params.amount, + params.originalTransactionId, + params.reason, + params.tenantId + ); + return { + success: true, + data: { + transactionId: transaction.id, + refundedAmount: params.amount, + newBalance: transaction.balanceAfter.toString(), + }, + }; + } catch (error) { + logger.error('transaction_refund failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to process refund' }; + } +} + +// ============================================================================ +// MCP TOOL SCHEMAS +// ============================================================================ + +export const transactionToolSchemas = { + transaction_get: { + name: 'transaction_get', + description: 'Get details of a specific wallet transaction', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + transactionId: { type: 'string', description: 'Transaction UUID' }, + }, + required: ['tenantId', 'transactionId'], + }, + riskLevel: 'LOW', + }, + + transaction_history: { + name: 'transaction_history', + description: 'Get paginated transaction history for a wallet with optional filters', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + type: { + type: 'string', + enum: [ + 'CREDIT_PURCHASE', 'AGENT_FUNDING', 'AGENT_WITHDRAWAL', 'AGENT_PROFIT', + 'AGENT_LOSS', 'PRODUCT_PURCHASE', 'SUBSCRIPTION_CHARGE', 'PREDICTION_PURCHASE', + 'REFUND', 'PROMO_CREDIT', 'PROMO_EXPIRY', 'ADJUSTMENT', 'TRANSFER_IN', + 'TRANSFER_OUT', 'FEE', + ], + description: 'Filter by transaction type', + }, + status: { + type: 'string', + enum: ['pending', 'completed', 'failed', 'cancelled', 'reversed'], + description: 'Filter by status', + }, + fromDate: { type: 'string', description: 'Start date (ISO 8601)' }, + toDate: { type: 'string', description: 'End date (ISO 8601)' }, + limit: { type: 'number', description: 'Results per page (max 100, default 50)' }, + offset: { type: 'number', description: 'Offset for pagination' }, + }, + required: ['tenantId', 'walletId'], + }, + riskLevel: 'LOW', + }, + + wallet_summary: { + name: 'wallet_summary', + description: 'Get wallet summary including balance, recent transactions, and monthly stats', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + }, + required: ['tenantId', 'walletId'], + }, + riskLevel: 'LOW', + }, + + transaction_refund: { + name: 'transaction_refund', + description: 'Process a refund for a previous transaction', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + amount: { type: 'number', description: 'Amount to refund' }, + originalTransactionId: { type: 'string', description: 'Original transaction UUID' }, + reason: { type: 'string', description: 'Refund reason' }, + }, + required: ['tenantId', 'walletId', 'amount', 'originalTransactionId', 'reason'], + }, + riskLevel: 'HIGH', + }, +}; + +// ============================================================================ +// MCP HANDLERS +// ============================================================================ + +function formatMcpResponse(result: ToolResult): { content: Array<{ type: string; text: string }> } { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; +} + +export async function handleTransactionGet(params: unknown) { + const validated = GetTransactionInputSchema.parse(params); + const result = await transaction_get(validated); + return formatMcpResponse(result); +} + +export async function handleTransactionHistory(params: unknown) { + const validated = GetTransactionHistoryInputSchema.parse(params); + const result = await transaction_history(validated); + return formatMcpResponse(result); +} + +export async function handleWalletSummary(params: unknown) { + const validated = GetWalletSummaryInputSchema.parse(params); + const result = await wallet_summary(validated); + return formatMcpResponse(result); +} + +export async function handleTransactionRefund(params: unknown) { + const validated = RefundInputSchema.parse(params); + const result = await transaction_refund(validated); + return formatMcpResponse(result); +} diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts new file mode 100644 index 0000000..9e462e9 --- /dev/null +++ b/src/tools/wallet.ts @@ -0,0 +1,655 @@ +import { z } from 'zod'; +import Decimal from 'decimal.js'; +import { getWalletService } from '../services/wallet.service'; +import { WalletError, isWalletError } from '../utils/errors'; +import { logger } from '../utils/logger'; +import { + Wallet, + WalletBalance, + CreateWalletInput, + CreditPurchaseInput, + DebitInput, + TransferInput, + AddPromoCreditsInput, + TransactionType, +} from '../types/wallet.types'; + +// ============================================================================ +// INPUT SCHEMAS (Zod validation) +// ============================================================================ + +export const CreateWalletInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + initialBalance: z.number().min(0).optional(), + currency: z.string().length(3).default('USD'), + dailySpendLimit: z.number().positive().optional(), + monthlySpendLimit: z.number().positive().optional(), + singleTransactionLimit: z.number().positive().optional(), +}); + +export const GetWalletInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID').optional(), + userId: z.string().uuid('Invalid user ID').optional(), +}).refine( + (data) => data.walletId || data.userId, + { message: 'Either walletId or userId must be provided' } +); + +export const GetBalanceInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), +}); + +export const AddCreditsInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + amount: z.number().positive('Amount must be positive'), + stripePaymentIntentId: z.string().min(1, 'Stripe payment intent ID required'), + stripeChargeId: z.string().optional(), + paymentMethod: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const DebitCreditsInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + amount: z.number().positive('Amount must be positive'), + type: z.enum([ + 'PRODUCT_PURCHASE', + 'SUBSCRIPTION_CHARGE', + 'PREDICTION_PURCHASE', + 'AGENT_FUNDING', + 'FEE', + ] as const), + description: z.string().optional(), + referenceType: z.string().optional(), + referenceId: z.string().uuid().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const TransferInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + fromWalletId: z.string().uuid('Invalid source wallet ID'), + toWalletId: z.string().uuid('Invalid destination wallet ID'), + amount: z.number().positive('Amount must be positive'), + description: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const AddPromoCreditsInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + amount: z.number().positive('Amount must be positive'), + expiryDays: z.number().int().positive().optional(), + description: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const UpdateLimitsInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + dailySpendLimit: z.number().positive().optional(), + monthlySpendLimit: z.number().positive().optional(), + singleTransactionLimit: z.number().positive().optional(), +}); + +export const FreezeWalletInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), + reason: z.string().min(1, 'Reason is required'), +}); + +export const UnfreezeWalletInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + walletId: z.string().uuid('Invalid wallet ID'), +}); + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +interface ToolResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ============================================================================ +// TOOL IMPLEMENTATIONS +// ============================================================================ + +/** + * Create a new wallet + */ +export async function wallet_create( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const wallet = await service.createWallet(params); + return { success: true, data: wallet }; + } catch (error) { + logger.error('wallet_create failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to create wallet' }; + } +} + +/** + * Get wallet by ID or user ID + */ +export async function wallet_get( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + let wallet: Wallet; + + if (params.walletId) { + wallet = await service.getWalletById(params.walletId, params.tenantId); + } else if (params.userId) { + wallet = await service.getWalletByUserId(params.userId, params.tenantId); + } else { + return { success: false, error: 'Either walletId or userId required' }; + } + + return { success: true, data: wallet }; + } catch (error) { + logger.error('wallet_get failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get wallet' }; + } +} + +/** + * Get wallet balance with limits + */ +export async function wallet_get_balance( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const balance = await service.getWalletBalance(params.walletId, params.tenantId); + return { success: true, data: balance }; + } catch (error) { + logger.error('wallet_get_balance failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get balance' }; + } +} + +/** + * Add credits via Stripe payment + */ +export async function wallet_add_credits( + params: z.infer +): Promise { + try { + const service = getWalletService(); + const transaction = await service.addCredits( + { + walletId: params.walletId, + amount: params.amount, + stripePaymentIntentId: params.stripePaymentIntentId, + stripeChargeId: params.stripeChargeId, + paymentMethod: params.paymentMethod, + metadata: params.metadata, + }, + params.tenantId + ); + return { + success: true, + data: { + transactionId: transaction.id, + newBalance: transaction.balanceAfter.toString(), + amount: params.amount, + }, + }; + } catch (error) { + logger.error('wallet_add_credits failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to add credits' }; + } +} + +/** + * Debit credits from wallet + */ +export async function wallet_debit( + params: z.infer +): Promise { + try { + const service = getWalletService(); + const transaction = await service.debitCredits( + { + walletId: params.walletId, + amount: params.amount, + type: params.type as TransactionType, + description: params.description, + referenceType: params.referenceType, + referenceId: params.referenceId, + metadata: params.metadata, + }, + params.tenantId + ); + return { + success: true, + data: { + transactionId: transaction.id, + newBalance: transaction.balanceAfter.toString(), + amountDebited: params.amount, + }, + }; + } catch (error) { + logger.error('wallet_debit failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to debit credits' }; + } +} + +/** + * Transfer credits between wallets + */ +export async function wallet_transfer( + params: z.infer +): Promise { + try { + const service = getWalletService(); + const { fromTransaction, toTransaction } = await service.transfer( + { + fromWalletId: params.fromWalletId, + toWalletId: params.toWalletId, + amount: params.amount, + description: params.description, + metadata: params.metadata, + }, + params.tenantId + ); + return { + success: true, + data: { + fromTransactionId: fromTransaction.id, + toTransactionId: toTransaction.id, + amount: params.amount, + fromNewBalance: fromTransaction.balanceAfter.toString(), + toNewBalance: toTransaction.balanceAfter.toString(), + }, + }; + } catch (error) { + logger.error('wallet_transfer failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to transfer credits' }; + } +} + +/** + * Add promotional credits + */ +export async function wallet_add_promo( + params: z.infer +): Promise { + try { + const service = getWalletService(); + const transaction = await service.addPromoCredits( + { + walletId: params.walletId, + amount: params.amount, + expiryDays: params.expiryDays, + description: params.description, + metadata: params.metadata, + }, + params.tenantId + ); + return { + success: true, + data: { + transactionId: transaction.id, + promoAmount: params.amount, + }, + }; + } catch (error) { + logger.error('wallet_add_promo failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to add promo credits' }; + } +} + +/** + * Update wallet limits + */ +export async function wallet_update_limits( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const wallet = await service.updateLimits( + params.walletId, + { + dailySpendLimit: params.dailySpendLimit, + monthlySpendLimit: params.monthlySpendLimit, + singleTransactionLimit: params.singleTransactionLimit, + }, + params.tenantId + ); + return { success: true, data: wallet }; + } catch (error) { + logger.error('wallet_update_limits failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to update limits' }; + } +} + +/** + * Freeze wallet + */ +export async function wallet_freeze( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const wallet = await service.freezeWallet( + params.walletId, + params.reason, + params.tenantId + ); + return { success: true, data: wallet }; + } catch (error) { + logger.error('wallet_freeze failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to freeze wallet' }; + } +} + +/** + * Unfreeze wallet + */ +export async function wallet_unfreeze( + params: z.infer +): Promise> { + try { + const service = getWalletService(); + const wallet = await service.unfreezeWallet(params.walletId, params.tenantId); + return { success: true, data: wallet }; + } catch (error) { + logger.error('wallet_unfreeze failed', { error, params }); + if (isWalletError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to unfreeze wallet' }; + } +} + +// ============================================================================ +// MCP TOOL SCHEMAS (for registration) +// ============================================================================ + +export const walletToolSchemas = { + wallet_create: { + name: 'wallet_create', + description: 'Create a new virtual wallet for a user. Each user can have one wallet per tenant.', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + userId: { type: 'string', description: 'User UUID' }, + initialBalance: { type: 'number', description: 'Initial balance in credits (default: 0)' }, + currency: { type: 'string', description: 'Currency code (default: USD)' }, + dailySpendLimit: { type: 'number', description: 'Daily spending limit' }, + monthlySpendLimit: { type: 'number', description: 'Monthly spending limit' }, + singleTransactionLimit: { type: 'number', description: 'Single transaction limit' }, + }, + required: ['tenantId', 'userId'], + }, + riskLevel: 'MEDIUM', + }, + + wallet_get: { + name: 'wallet_get', + description: 'Get wallet details by wallet ID or user ID', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID (optional if userId provided)' }, + userId: { type: 'string', description: 'User UUID (optional if walletId provided)' }, + }, + required: ['tenantId'], + }, + riskLevel: 'LOW', + }, + + wallet_get_balance: { + name: 'wallet_get_balance', + description: 'Get wallet balance including available, reserved, promo credits and spending limits', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + }, + required: ['tenantId', 'walletId'], + }, + riskLevel: 'LOW', + }, + + wallet_add_credits: { + name: 'wallet_add_credits', + description: 'Add credits to wallet after successful Stripe payment', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + amount: { type: 'number', description: 'Amount of credits to add' }, + stripePaymentIntentId: { type: 'string', description: 'Stripe PaymentIntent ID' }, + stripeChargeId: { type: 'string', description: 'Stripe Charge ID (optional)' }, + paymentMethod: { type: 'string', description: 'Payment method type' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, + required: ['tenantId', 'walletId', 'amount', 'stripePaymentIntentId'], + }, + riskLevel: 'HIGH', + }, + + wallet_debit: { + name: 'wallet_debit', + description: 'Debit credits from wallet for purchases, subscriptions, or agent funding', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + amount: { type: 'number', description: 'Amount to debit' }, + type: { + type: 'string', + enum: ['PRODUCT_PURCHASE', 'SUBSCRIPTION_CHARGE', 'PREDICTION_PURCHASE', 'AGENT_FUNDING', 'FEE'], + description: 'Transaction type', + }, + description: { type: 'string', description: 'Transaction description' }, + referenceType: { type: 'string', description: 'Type of referenced entity' }, + referenceId: { type: 'string', description: 'UUID of referenced entity' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, + required: ['tenantId', 'walletId', 'amount', 'type'], + }, + riskLevel: 'HIGH', + }, + + wallet_transfer: { + name: 'wallet_transfer', + description: 'Transfer credits between two wallets', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + fromWalletId: { type: 'string', description: 'Source wallet UUID' }, + toWalletId: { type: 'string', description: 'Destination wallet UUID' }, + amount: { type: 'number', description: 'Amount to transfer' }, + description: { type: 'string', description: 'Transfer description' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, + required: ['tenantId', 'fromWalletId', 'toWalletId', 'amount'], + }, + riskLevel: 'HIGH', + }, + + wallet_add_promo: { + name: 'wallet_add_promo', + description: 'Add promotional credits to wallet with optional expiry', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + amount: { type: 'number', description: 'Promo credits amount' }, + expiryDays: { type: 'number', description: 'Days until expiry (default: 90)' }, + description: { type: 'string', description: 'Promo description' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, + required: ['tenantId', 'walletId', 'amount'], + }, + riskLevel: 'MEDIUM', + }, + + wallet_update_limits: { + name: 'wallet_update_limits', + description: 'Update wallet spending limits', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + dailySpendLimit: { type: 'number', description: 'New daily limit' }, + monthlySpendLimit: { type: 'number', description: 'New monthly limit' }, + singleTransactionLimit: { type: 'number', description: 'New single tx limit' }, + }, + required: ['tenantId', 'walletId'], + }, + riskLevel: 'MEDIUM', + }, + + wallet_freeze: { + name: 'wallet_freeze', + description: 'Freeze a wallet to prevent transactions', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + reason: { type: 'string', description: 'Reason for freezing' }, + }, + required: ['tenantId', 'walletId', 'reason'], + }, + riskLevel: 'HIGH', + }, + + wallet_unfreeze: { + name: 'wallet_unfreeze', + description: 'Unfreeze a previously frozen wallet', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + walletId: { type: 'string', description: 'Wallet UUID' }, + }, + required: ['tenantId', 'walletId'], + }, + riskLevel: 'HIGH', + }, +}; + +// ============================================================================ +// MCP HANDLERS (formatted responses) +// ============================================================================ + +function formatMcpResponse(result: ToolResult): { content: Array<{ type: string; text: string }> } { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; +} + +export async function handleWalletCreate(params: unknown) { + const validated = CreateWalletInputSchema.parse(params); + const result = await wallet_create(validated); + return formatMcpResponse(result); +} + +export async function handleWalletGet(params: unknown) { + const validated = GetWalletInputSchema.parse(params); + const result = await wallet_get(validated); + return formatMcpResponse(result); +} + +export async function handleWalletGetBalance(params: unknown) { + const validated = GetBalanceInputSchema.parse(params); + const result = await wallet_get_balance(validated); + return formatMcpResponse(result); +} + +export async function handleWalletAddCredits(params: unknown) { + const validated = AddCreditsInputSchema.parse(params); + const result = await wallet_add_credits(validated); + return formatMcpResponse(result); +} + +export async function handleWalletDebit(params: unknown) { + const validated = DebitCreditsInputSchema.parse(params); + const result = await wallet_debit(validated); + return formatMcpResponse(result); +} + +export async function handleWalletTransfer(params: unknown) { + const validated = TransferInputSchema.parse(params); + const result = await wallet_transfer(validated); + return formatMcpResponse(result); +} + +export async function handleWalletAddPromo(params: unknown) { + const validated = AddPromoCreditsInputSchema.parse(params); + const result = await wallet_add_promo(validated); + return formatMcpResponse(result); +} + +export async function handleWalletUpdateLimits(params: unknown) { + const validated = UpdateLimitsInputSchema.parse(params); + const result = await wallet_update_limits(validated); + return formatMcpResponse(result); +} + +export async function handleWalletFreeze(params: unknown) { + const validated = FreezeWalletInputSchema.parse(params); + const result = await wallet_freeze(validated); + return formatMcpResponse(result); +} + +export async function handleWalletUnfreeze(params: unknown) { + const validated = UnfreezeWalletInputSchema.parse(params); + const result = await wallet_unfreeze(validated); + return formatMcpResponse(result); +} diff --git a/src/types/wallet.types.ts b/src/types/wallet.types.ts new file mode 100644 index 0000000..1ea76ea --- /dev/null +++ b/src/types/wallet.types.ts @@ -0,0 +1,268 @@ +import Decimal from 'decimal.js'; + +/** + * Wallet status enum matching database + */ +export type WalletStatus = 'active' | 'frozen' | 'suspended' | 'closed'; + +/** + * Transaction types matching database enum + */ +export type TransactionType = + | 'CREDIT_PURCHASE' + | 'AGENT_FUNDING' + | 'AGENT_WITHDRAWAL' + | 'AGENT_PROFIT' + | 'AGENT_LOSS' + | 'PRODUCT_PURCHASE' + | 'SUBSCRIPTION_CHARGE' + | 'PREDICTION_PURCHASE' + | 'REFUND' + | 'PROMO_CREDIT' + | 'PROMO_EXPIRY' + | 'ADJUSTMENT' + | 'TRANSFER_IN' + | 'TRANSFER_OUT' + | 'FEE'; + +/** + * Transaction status enum + */ +export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled' | 'reversed'; + +/** + * Wallet entity from database + */ +export interface Wallet { + id: string; + tenantId: string; + userId: string; + balance: Decimal; + reserved: Decimal; + totalCredited: Decimal; + totalDebited: Decimal; + promoBalance: Decimal; + promoExpiry: Date | null; + currency: string; + status: WalletStatus; + dailySpendLimit: Decimal; + monthlySpendLimit: Decimal; + singleTransactionLimit: Decimal; + dailySpent: Decimal; + monthlySpent: Decimal; + lastDailyReset: Date; + lastMonthlyReset: Date; + lastTransactionAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * Wallet transaction entity from database + */ +export interface WalletTransaction { + id: string; + walletId: string; + tenantId: string; + type: TransactionType; + status: TransactionStatus; + amount: Decimal; + balanceBefore: Decimal; + balanceAfter: Decimal; + description: string | null; + referenceType: string | null; + referenceId: string | null; + stripePaymentIntentId: string | null; + stripeChargeId: string | null; + paymentMethod: string | null; + relatedWalletId: string | null; + relatedTransactionId: string | null; + metadata: Record; + ipAddress: string | null; + userAgent: string | null; + createdBy: string | null; + createdAt: Date; + processedAt: Date | null; +} + +/** + * Wallet balance response + */ +export interface WalletBalance { + walletId: string; + available: Decimal; + reserved: Decimal; + promo: Decimal; + total: Decimal; + currency: string; + status: WalletStatus; + limits: { + dailySpendLimit: Decimal; + monthlySpendLimit: Decimal; + singleTransactionLimit: Decimal; + dailySpent: Decimal; + monthlySpent: Decimal; + dailyRemaining: Decimal; + monthlyRemaining: Decimal; + }; +} + +/** + * Create wallet input + */ +export interface CreateWalletInput { + tenantId: string; + userId: string; + initialBalance?: number; + currency?: string; + dailySpendLimit?: number; + monthlySpendLimit?: number; + singleTransactionLimit?: number; +} + +/** + * Credit purchase input (buying credits via Stripe) + */ +export interface CreditPurchaseInput { + walletId: string; + amount: number; + stripePaymentIntentId: string; + stripeChargeId?: string; + paymentMethod?: string; + metadata?: Record; +} + +/** + * Debit input (spending credits) + */ +export interface DebitInput { + walletId: string; + amount: number; + type: TransactionType; + description?: string; + referenceType?: string; + referenceId?: string; + metadata?: Record; +} + +/** + * Transfer input + */ +export interface TransferInput { + fromWalletId: string; + toWalletId: string; + amount: number; + description?: string; + metadata?: Record; +} + +/** + * Add promo credits input + */ +export interface AddPromoCreditsInput { + walletId: string; + amount: number; + expiryDays?: number; + description?: string; + metadata?: Record; +} + +/** + * Transaction history query + */ +export interface TransactionHistoryQuery { + walletId: string; + type?: TransactionType; + status?: TransactionStatus; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +/** + * Wallet summary for dashboard + */ +export interface WalletSummary { + walletId: string; + balance: Decimal; + currency: string; + totalCredited: Decimal; + totalDebited: Decimal; + recentTransactions: WalletTransaction[]; + monthlyStats: { + credits: Decimal; + debits: Decimal; + netChange: Decimal; + transactionCount: number; + }; +} + +/** + * Database row to entity mappers + */ +export function mapRowToWallet(row: Record): Wallet { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + userId: row.user_id as string, + balance: new Decimal(row.balance as string), + reserved: new Decimal(row.reserved as string), + totalCredited: new Decimal(row.total_credited as string), + totalDebited: new Decimal(row.total_debited as string), + promoBalance: new Decimal(row.promo_balance as string), + promoExpiry: row.promo_expiry ? new Date(row.promo_expiry as string) : null, + currency: row.currency as string, + status: row.status as WalletStatus, + dailySpendLimit: new Decimal(row.daily_spend_limit as string), + monthlySpendLimit: new Decimal(row.monthly_spend_limit as string), + singleTransactionLimit: new Decimal(row.single_transaction_limit as string), + dailySpent: new Decimal(row.daily_spent as string), + monthlySpent: new Decimal(row.monthly_spent as string), + lastDailyReset: new Date(row.last_daily_reset as string), + lastMonthlyReset: new Date(row.last_monthly_reset as string), + lastTransactionAt: row.last_transaction_at + ? new Date(row.last_transaction_at as string) + : null, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +export function mapRowToTransaction(row: Record): WalletTransaction { + return { + id: row.id as string, + walletId: row.wallet_id as string, + tenantId: row.tenant_id as string, + type: row.type as TransactionType, + status: row.status as TransactionStatus, + amount: new Decimal(row.amount as string), + balanceBefore: new Decimal(row.balance_before as string), + balanceAfter: new Decimal(row.balance_after as string), + description: row.description as string | null, + referenceType: row.reference_type as string | null, + referenceId: row.reference_id as string | null, + stripePaymentIntentId: row.stripe_payment_intent_id as string | null, + stripeChargeId: row.stripe_charge_id as string | null, + paymentMethod: row.payment_method as string | null, + relatedWalletId: row.related_wallet_id as string | null, + relatedTransactionId: row.related_transaction_id as string | null, + metadata: (row.metadata as Record) || {}, + ipAddress: row.ip_address as string | null, + userAgent: row.user_agent as string | null, + createdBy: row.created_by as string | null, + createdAt: new Date(row.created_at as string), + processedAt: row.processed_at ? new Date(row.processed_at as string) : null, + }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..0009bff --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,212 @@ +/** + * Custom error classes for wallet operations + */ + +export class WalletError extends Error { + constructor( + message: string, + public code: string, + public statusCode: number = 400, + public details?: Record + ) { + super(message); + this.name = 'WalletError'; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + details: this.details, + }; + } +} + +// Specific error types +export class WalletNotFoundError extends WalletError { + constructor(walletId?: string, userId?: string) { + super( + walletId + ? `Wallet not found: ${walletId}` + : userId + ? `Wallet not found for user: ${userId}` + : 'Wallet not found', + 'WALLET_NOT_FOUND', + 404 + ); + this.name = 'WalletNotFoundError'; + } +} + +export class InsufficientBalanceError extends WalletError { + constructor(available: number, required: number) { + super( + `Insufficient balance. Available: ${available}, Required: ${required}`, + 'INSUFFICIENT_BALANCE', + 400, + { available, required } + ); + this.name = 'InsufficientBalanceError'; + } +} + +export class DailyLimitExceededError extends WalletError { + constructor(limit: number, attempted: number, currentSpent: number) { + super( + `Daily spend limit exceeded. Limit: ${limit}, Already spent: ${currentSpent}, Attempted: ${attempted}`, + 'DAILY_LIMIT_EXCEEDED', + 400, + { limit, attempted, currentSpent } + ); + this.name = 'DailyLimitExceededError'; + } +} + +export class MonthlyLimitExceededError extends WalletError { + constructor(limit: number, attempted: number, currentSpent: number) { + super( + `Monthly spend limit exceeded. Limit: ${limit}, Already spent: ${currentSpent}, Attempted: ${attempted}`, + 'MONTHLY_LIMIT_EXCEEDED', + 400, + { limit, attempted, currentSpent } + ); + this.name = 'MonthlyLimitExceededError'; + } +} + +export class SingleTransactionLimitError extends WalletError { + constructor(limit: number, attempted: number) { + super( + `Single transaction limit exceeded. Limit: ${limit}, Attempted: ${attempted}`, + 'SINGLE_TX_LIMIT_EXCEEDED', + 400, + { limit, attempted } + ); + this.name = 'SingleTransactionLimitError'; + } +} + +export class WalletFrozenError extends WalletError { + constructor(walletId: string, status: string) { + super( + `Wallet is not active. Current status: ${status}`, + 'WALLET_NOT_ACTIVE', + 403, + { walletId, status } + ); + this.name = 'WalletFrozenError'; + } +} + +export class InvalidTransactionError extends WalletError { + constructor(reason: string, details?: Record) { + super(`Invalid transaction: ${reason}`, 'INVALID_TRANSACTION', 400, details); + this.name = 'InvalidTransactionError'; + } +} + +export class DuplicateWalletError extends WalletError { + constructor(userId: string, tenantId: string) { + super( + `Wallet already exists for user ${userId} in tenant ${tenantId}`, + 'DUPLICATE_WALLET', + 409, + { userId, tenantId } + ); + this.name = 'DuplicateWalletError'; + } +} + +export class TransactionNotFoundError extends WalletError { + constructor(transactionId: string) { + super(`Transaction not found: ${transactionId}`, 'TRANSACTION_NOT_FOUND', 404); + this.name = 'TransactionNotFoundError'; + } +} + +export class ValidationError extends WalletError { + constructor(message: string, field?: string) { + super(message, 'VALIDATION_ERROR', 400, field ? { field } : undefined); + this.name = 'ValidationError'; + } +} + +export class DatabaseError extends WalletError { + constructor(message: string, originalError?: Error) { + super(message, 'DATABASE_ERROR', 500, { + originalMessage: originalError?.message, + }); + this.name = 'DatabaseError'; + } +} + +// Error handler helper +export function isWalletError(error: unknown): error is WalletError { + return error instanceof WalletError; +} + +// Map PostgreSQL errors to WalletErrors +export function mapPostgresError(error: unknown): WalletError { + if (error instanceof Error) { + const pgError = error as { code?: string; constraint?: string; detail?: string }; + + // Unique constraint violation + if (pgError.code === '23505') { + if (pgError.constraint?.includes('wallet')) { + return new DuplicateWalletError('unknown', 'unknown'); + } + return new WalletError('Duplicate entry', 'DUPLICATE_ENTRY', 409); + } + + // Check constraint violation + if (pgError.code === '23514') { + if (pgError.constraint?.includes('balance')) { + return new InsufficientBalanceError(0, 0); + } + return new InvalidTransactionError('Constraint violation', { + constraint: pgError.constraint, + }); + } + + // Foreign key violation + if (pgError.code === '23503') { + return new WalletError('Referenced entity not found', 'REFERENCE_NOT_FOUND', 404); + } + + // Custom exception from stored procedure + if (pgError.code === 'P0001') { + const message = error.message; + + if (message.includes('Insufficient balance')) { + const match = message.match(/Current: ([\d.]+), Required: ([\d.]+)/); + if (match) { + return new InsufficientBalanceError(parseFloat(match[1]), parseFloat(match[2])); + } + return new InsufficientBalanceError(0, 0); + } + + if (message.includes('daily spend limit')) { + return new DailyLimitExceededError(0, 0, 0); + } + + if (message.includes('single transaction limit')) { + return new SingleTransactionLimitError(0, 0); + } + + if (message.includes('not active')) { + return new WalletFrozenError('unknown', 'unknown'); + } + + if (message.includes('not found')) { + return new WalletNotFoundError(); + } + + return new WalletError(message, 'BUSINESS_RULE_VIOLATION', 400); + } + } + + return new DatabaseError('Database operation failed', error instanceof Error ? error : undefined); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..a3b679e --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,110 @@ +import winston from 'winston'; +import { serverConfig } from '../config'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +// Custom format for console output +const consoleFormat = printf(({ level, message, timestamp, stack, ...meta }) => { + let msg = `${timestamp} [${level}]: ${message}`; + + if (Object.keys(meta).length > 0) { + msg += ` ${JSON.stringify(meta)}`; + } + + if (stack) { + msg += `\n${stack}`; + } + + return msg; +}); + +// Custom format for JSON output (production) +const jsonFormat = printf(({ level, message, timestamp, ...meta }) => { + return JSON.stringify({ + timestamp, + level, + message, + ...meta, + }); +}); + +// Create logger instance +export const logger = winston.createLogger({ + level: serverConfig.logLevel, + format: combine( + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }) + ), + defaultMeta: { service: 'mcp-wallet' }, + transports: [ + // Console transport + new winston.transports.Console({ + format: combine( + serverConfig.nodeEnv === 'development' ? colorize() : winston.format.uncolorize(), + serverConfig.nodeEnv === 'development' ? consoleFormat : jsonFormat + ), + }), + ], +}); + +// Add file transports in production +if (serverConfig.nodeEnv === 'production') { + logger.add( + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + format: jsonFormat, + }) + ); + + logger.add( + new winston.transports.File({ + filename: 'logs/combined.log', + format: jsonFormat, + }) + ); +} + +// Helper functions for structured logging +export function logWalletOperation( + operation: string, + walletId: string, + userId: string, + details: Record = {} +): void { + logger.info(`Wallet operation: ${operation}`, { + operation, + walletId, + userId, + ...details, + }); +} + +export function logTransaction( + type: string, + walletId: string, + amount: number, + transactionId: string, + details: Record = {} +): void { + logger.info(`Transaction: ${type}`, { + type, + walletId, + amount, + transactionId, + ...details, + }); +} + +export function logError( + context: string, + error: Error, + details: Record = {} +): void { + logger.error(`Error in ${context}`, { + context, + error: error.message, + stack: error.stack, + ...details, + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0671d50 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}