Migración desde trading-platform/apps/mcp-wallet - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
733e1a4581
32
.env.example
Normal file
32
.env.example
Normal file
@ -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_...
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@ -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"]
|
||||||
162
README.md
Normal file
162
README.md
Normal file
@ -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
|
||||||
50
package.json
Normal file
50
package.json
Normal file
@ -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
|
||||||
|
}
|
||||||
84
src/config.ts
Normal file
84
src/config.ts
Normal file
@ -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<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set tenant context for RLS
|
||||||
|
export async function setTenantContext(
|
||||||
|
client: ReturnType<Pool['connect']> extends Promise<infer T> ? T : never,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
485
src/index.ts
Normal file
485
src/index.ts
Normal file
@ -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 };
|
||||||
156
src/middleware/auth.middleware.ts
Normal file
156
src/middleware/auth.middleware.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
6
src/middleware/index.ts
Normal file
6
src/middleware/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Middleware exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { authMiddleware, optionalAuthMiddleware, validateTenant } from './auth.middleware';
|
||||||
|
export type { JWTPayload } from './auth.middleware';
|
||||||
738
src/services/wallet.service.ts
Normal file
738
src/services/wallet.service.ts
Normal file
@ -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<Wallet> {
|
||||||
|
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<Wallet> {
|
||||||
|
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<Wallet> {
|
||||||
|
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<WalletBalance> {
|
||||||
|
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<WalletTransaction> {
|
||||||
|
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<WalletTransaction> {
|
||||||
|
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<WalletTransaction> {
|
||||||
|
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<WalletTransaction> {
|
||||||
|
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<WalletTransaction> {
|
||||||
|
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<PaginatedResult<WalletTransaction>> {
|
||||||
|
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<WalletSummary> {
|
||||||
|
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<Wallet> {
|
||||||
|
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<Wallet> {
|
||||||
|
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<Wallet> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
117
src/tools/index.ts
Normal file
117
src/tools/index.ts
Normal file
@ -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<string, (params: unknown) => 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);
|
||||||
301
src/tools/transactions.ts
Normal file
301
src/tools/transactions.ts
Normal file
@ -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<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOOL IMPLEMENTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific transaction by ID
|
||||||
|
*/
|
||||||
|
export async function transaction_get(
|
||||||
|
params: z.infer<typeof GetTransactionInputSchema>
|
||||||
|
): Promise<ToolResult<WalletTransaction>> {
|
||||||
|
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<typeof GetTransactionHistoryInputSchema>
|
||||||
|
): Promise<ToolResult<PaginatedResult<WalletTransaction>>> {
|
||||||
|
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<typeof GetWalletSummaryInputSchema>
|
||||||
|
): Promise<ToolResult<WalletSummary>> {
|
||||||
|
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<typeof RefundInputSchema>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
655
src/tools/wallet.ts
Normal file
655
src/tools/wallet.ts
Normal file
@ -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<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOOL IMPLEMENTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet
|
||||||
|
*/
|
||||||
|
export async function wallet_create(
|
||||||
|
params: z.infer<typeof CreateWalletInputSchema>
|
||||||
|
): Promise<ToolResult<Wallet>> {
|
||||||
|
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<typeof GetWalletInputSchema>
|
||||||
|
): Promise<ToolResult<Wallet>> {
|
||||||
|
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<typeof GetBalanceInputSchema>
|
||||||
|
): Promise<ToolResult<WalletBalance>> {
|
||||||
|
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<typeof AddCreditsInputSchema>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<typeof DebitCreditsInputSchema>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<typeof TransferInputSchema>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<typeof AddPromoCreditsInputSchema>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<typeof UpdateLimitsInputSchema>
|
||||||
|
): Promise<ToolResult<Wallet>> {
|
||||||
|
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<typeof FreezeWalletInputSchema>
|
||||||
|
): Promise<ToolResult<Wallet>> {
|
||||||
|
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<typeof UnfreezeWalletInputSchema>
|
||||||
|
): Promise<ToolResult<Wallet>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
268
src/types/wallet.types.ts
Normal file
268
src/types/wallet.types.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit input (spending credits)
|
||||||
|
*/
|
||||||
|
export interface DebitInput {
|
||||||
|
walletId: string;
|
||||||
|
amount: number;
|
||||||
|
type: TransactionType;
|
||||||
|
description?: string;
|
||||||
|
referenceType?: string;
|
||||||
|
referenceId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer input
|
||||||
|
*/
|
||||||
|
export interface TransferInput {
|
||||||
|
fromWalletId: string;
|
||||||
|
toWalletId: string;
|
||||||
|
amount: number;
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add promo credits input
|
||||||
|
*/
|
||||||
|
export interface AddPromoCreditsInput {
|
||||||
|
walletId: string;
|
||||||
|
amount: number;
|
||||||
|
expiryDays?: number;
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T> {
|
||||||
|
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<string, unknown>): 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<string, unknown>): 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<string, unknown>) || {},
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
212
src/utils/errors.ts
Normal file
212
src/utils/errors.ts
Normal file
@ -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<string, unknown>
|
||||||
|
) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
110
src/utils/logger.ts
Normal file
110
src/utils/logger.ts
Normal file
@ -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<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logger.info(`Wallet operation: ${operation}`, {
|
||||||
|
operation,
|
||||||
|
walletId,
|
||||||
|
userId,
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logTransaction(
|
||||||
|
type: string,
|
||||||
|
walletId: string,
|
||||||
|
amount: number,
|
||||||
|
transactionId: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logger.info(`Transaction: ${type}`, {
|
||||||
|
type,
|
||||||
|
walletId,
|
||||||
|
amount,
|
||||||
|
transactionId,
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(
|
||||||
|
context: string,
|
||||||
|
error: Error,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logger.error(`Error in ${context}`, {
|
||||||
|
context,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user