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:
rckrdmrd 2026-01-16 08:33:20 -06:00
commit 733e1a4581
16 changed files with 3463 additions and 0 deletions

32
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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 };

View 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
View File

@ -0,0 +1,6 @@
/**
* Middleware exports
*/
export { authMiddleware, optionalAuthMiddleware, validateTenant } from './auth.middleware';
export type { JWTPayload } from './auth.middleware';

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}