commit 41952f8985a491b723844308230863ec5e7154d3 Author: rckrdmrd Date: Fri Jan 16 08:33:18 2026 -0600 Migración desde trading-platform/apps/mcp-vip - Estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c948d5a --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# MCP VIP SERVER CONFIGURATION + +PORT=3092 +NODE_ENV=development +LOG_LEVEL=info + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=trading_platform +DB_USER=trading_app +DB_PASSWORD=your_password +DB_SSL=false +DB_POOL_MAX=20 + +# VIP Config +VIP_TRIAL_DAYS=7 +VIP_GRACE_PERIOD_DAYS=3 + +# Wallet Service +WALLET_SERVICE_URL=http://localhost:3090 + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7a2234 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --production + +FROM node:20-alpine +WORKDIR /app +RUN addgroup -g 1001 -S nodejs && adduser -S vip -u 1001 +COPY --from=builder --chown=vip:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=vip:nodejs /app/dist ./dist +COPY --from=builder --chown=vip:nodejs /app/package.json ./ +USER vip +EXPOSE 3092 +HEALTHCHECK --interval=30s --timeout=10s CMD wget -q --spider http://localhost:3092/health || exit 1 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..89de715 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# MCP VIP Server + +VIP Subscription MCP Server for the Trading Platform. Manages Gold, Platinum, Diamond tiers with exclusive ML model access. + +## VIP Tiers + +| Tier | Price/mo | Predictions | Models | +|------|----------|-------------|--------| +| **Gold** | $199 | 50/mo | AMD, Range | +| **Platinum** | $399 | 150/mo | + TPSL, ICT/SMC | +| **Diamond** | $999 | Unlimited | + Strategy Ensemble | + +## Quick Start + +```bash +npm install +cp .env.example .env +npm run dev +``` + +## API Endpoints + +### Tiers +- `GET /api/v1/tiers` - List all tiers +- `GET /api/v1/tiers/:tier` - Get tier details + +### Subscriptions +- `POST /api/v1/subscriptions` - Create subscription +- `GET /api/v1/subscriptions/:id` - Get subscription +- `GET /api/v1/users/:userId/subscription` - Get user subscription +- `POST /api/v1/subscriptions/:id/cancel` - Cancel +- `POST /api/v1/subscriptions/:id/reactivate` - Reactivate +- `POST /api/v1/subscriptions/:id/upgrade` - Upgrade tier +- `GET /api/v1/subscriptions/:id/usage` - Usage stats +- `GET /api/v1/subscriptions/:id/models` - Model access + +### Model Access +- `GET /api/v1/users/:userId/access/:modelId` - Check access + +## MCP Tools (13) + +| Tool | Description | +|------|-------------| +| `vip_get_tiers` | List tiers | +| `vip_get_tier` | Get tier | +| `vip_create_subscription` | Create sub | +| `vip_get_subscription` | Get sub | +| `vip_cancel_subscription` | Cancel | +| `vip_reactivate_subscription` | Reactivate | +| `vip_upgrade_tier` | Upgrade | +| `vip_check_model_access` | Check access | +| `vip_get_model_access` | List access | +| `vip_record_model_usage` | Record usage | +| `vip_get_usage_stats` | Usage stats | +| `vip_update_status` | Update status | +| `vip_renew_period` | Renew period | + +## Subscription Status + +- `trialing` - In trial period +- `active` - Active and paying +- `past_due` - Payment failed +- `cancelled` - Cancelled +- `expired` - Expired + +## License + +UNLICENSED - Private diff --git a/package.json b/package.json new file mode 100644 index 0000000..13c88d3 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@trading-platform/mcp-vip", + "version": "1.0.0", + "description": "MCP Server for VIP Subscriptions - Gold, Platinum, Diamond tiers", + "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" + }, + "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/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/cors": "^2.8.17", + "@types/jsonwebtoken": "^9.0.5", + "typescript": "^5.3.2", + "ts-node-dev": "^2.0.0", + "jest": "^29.7.0", + "@types/jest": "^29.5.11", + "ts-jest": "^29.1.1" + }, + "engines": { "node": ">=18.0.0" }, + "private": true +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3a92bf2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,55 @@ +import { Pool, PoolConfig } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const serverConfig = { + port: parseInt(process.env.PORT || '3092', 10), + nodeEnv: process.env.NODE_ENV || 'development', + logLevel: process.env.LOG_LEVEL || 'info', +}; + +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), +}; + +export const vipConfig = { + trialDays: parseInt(process.env.VIP_TRIAL_DAYS || '7', 10), + gracePeriodDays: parseInt(process.env.VIP_GRACE_PERIOD_DAYS || '3', 10), +}; + +export const walletServiceConfig = { + baseUrl: process.env.WALLET_SERVICE_URL || 'http://localhost:3090', +}; + +export const stripeConfig = { + secretKey: process.env.STRIPE_SECRET_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', +}; + +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(dbConfig); + pool.on('error', (err) => console.error('DB pool error', err)); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { await pool.end(); pool = null; } +} + +export async function setTenantContext( + client: ReturnType extends Promise ? T : never, + tenantId: string +): Promise { + await client.query(`SET app.current_tenant_id = $1`, [tenantId]); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..72b97a7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,292 @@ +/** + * MCP VIP Server + * + * VIP Subscription system for trading platform. + * Manages Gold, Platinum, Diamond tiers and exclusive model access. + */ + +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 { allToolSchemas, toolHandlers, toolNames } from './tools'; +import { authMiddleware } from './middleware'; + +const app = express(); + +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); + +// ============================================================================ +// HEALTH & INFO +// ============================================================================ + +app.get('/health', async (_req: Request, res: Response) => { + try { + await getPool().query('SELECT 1'); + res.json({ status: 'healthy', service: 'mcp-vip', database: 'connected' }); + } catch { + res.status(503).json({ status: 'unhealthy', service: 'mcp-vip', database: 'disconnected' }); + } +}); + +app.get('/info', (_req: Request, res: Response) => { + res.json({ name: 'mcp-vip', version: '1.0.0', tools: toolNames, toolCount: toolNames.length }); +}); + +// ============================================================================ +// MCP ENDPOINTS +// ============================================================================ + +app.get('/mcp/tools', (_req: Request, res: Response) => { + res.json({ tools: Object.values(allToolSchemas) }); +}); + +app.post('/mcp/tools/:toolName', async (req: Request, res: Response, next: NextFunction) => { + const handler = toolHandlers[req.params.toolName]; + if (!handler) { + res.status(404).json({ error: `Tool not found: ${req.params.toolName}` }); + return; + } + try { + res.json(await handler(req.body)); + } catch (error) { + next(error); + } +}); + +app.post('/mcp/call', async (req: Request, res: Response, next: NextFunction) => { + const { name, arguments: args } = req.body; + const handler = toolHandlers[name]; + if (!handler) { + res.status(404).json({ error: `Tool not found: ${name}` }); + return; + } + try { + res.json(await handler(args || {})); + } catch (error) { + next(error); + } +}); + +// ============================================================================ +// REST API ENDPOINTS +// ============================================================================ + +// Tiers +app.get('/api/v1/tiers', async (_req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_tiers({})); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/tiers/:tier', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_tier({ tier: req.params.tier })); + } catch (error) { + next(error); + } +}); + +// Subscriptions - Protected routes +const subscriptionsRouter = express.Router(); +subscriptionsRouter.use(authMiddleware); + +// Create subscription +subscriptionsRouter.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + res.status(201).json(await toolHandlers.vip_create_subscription({ + tenantId: req.tenantId, + userId: req.userId, + ...req.body, + })); + } catch (error) { + next(error); + } +}); + +// Get subscription by ID +subscriptionsRouter.get('/:subscriptionId', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_subscription({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + })); + } catch (error) { + next(error); + } +}); + +// Cancel subscription +subscriptionsRouter.post('/:subscriptionId/cancel', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_cancel_subscription({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + cancelledBy: req.userId, + ...req.body, + })); + } catch (error) { + next(error); + } +}); + +// Reactivate subscription +subscriptionsRouter.post('/:subscriptionId/reactivate', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_reactivate_subscription({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + })); + } catch (error) { + next(error); + } +}); + +// Upgrade tier +subscriptionsRouter.post('/:subscriptionId/upgrade', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_upgrade_tier({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + newTier: req.body.newTier, + })); + } catch (error) { + next(error); + } +}); + +// Get usage stats +subscriptionsRouter.get('/:subscriptionId/usage', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_usage_stats({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + })); + } catch (error) { + next(error); + } +}); + +// Get model access +subscriptionsRouter.get('/:subscriptionId/models', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_model_access({ + tenantId: req.tenantId, + subscriptionId: req.params.subscriptionId, + })); + } catch (error) { + next(error); + } +}); + +app.use('/api/v1/subscriptions', subscriptionsRouter); + +// User-specific endpoints - Protected +const userRouter = express.Router(); +userRouter.use(authMiddleware); + +// Get user's subscription +userRouter.get('/:userId/subscription', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_subscription({ + tenantId: req.tenantId, + userId: req.params.userId, + })); + } catch (error) { + next(error); + } +}); + +// Check model access +userRouter.get('/:userId/access/:modelId', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_check_model_access({ + tenantId: req.tenantId, + userId: req.params.userId, + modelId: req.params.modelId, + })); + } catch (error) { + next(error); + } +}); + +// Get current user's subscription (convenience) +userRouter.get('/me/subscription', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_get_subscription({ + tenantId: req.tenantId, + userId: req.userId, + })); + } catch (error) { + next(error); + } +}); + +// Check current user's model access +userRouter.get('/me/access/:modelId', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await toolHandlers.vip_check_model_access({ + tenantId: req.tenantId, + userId: req.userId, + modelId: req.params.modelId, + })); + } catch (error) { + next(error); + } +}); + +app.use('/api/v1/users', userRouter); + +// ============================================================================ +// ERROR HANDLING +// ============================================================================ + +app.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Not found' }); +}); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ZodError) { + res.status(400).json({ error: 'Validation error', details: err.errors }); + return; + } + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ error: 'Internal server error' }); +}); + +// ============================================================================ +// SERVER STARTUP +// ============================================================================ + +const server = app.listen(serverConfig.port, () => { + logger.info(`MCP VIP Server started`, { port: serverConfig.port }); + console.log(` +╔════════════════════════════════════════════════════════════╗ +║ MCP VIP SERVER ║ +╠════════════════════════════════════════════════════════════╣ +║ Port: ${serverConfig.port} ║ +║ Tools: ${String(toolNames.length).padEnd(12)} ║ +║ Tiers: GOLD | PLATINUM | DIAMOND ║ +╠════════════════════════════════════════════════════════════╣ +║ /api/v1/tiers, /api/v1/subscriptions/* ║ +╚════════════════════════════════════════════════════════════╝ + `); +}); + +const shutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down...`); + server.close(async () => { await closePool(); process.exit(0); }); + setTimeout(() => process.exit(1), 30000); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +export { app }; diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..788f358 --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,99 @@ +/** + * Auth Middleware for MCP VIP + * Verifies JWT tokens and sets user context + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { logger } from '../utils/logger'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits'; + +export interface JWTPayload { + sub: string; + email: string; + tenantId: string; + isOwner: boolean; + iat: number; + exp: number; +} + +declare global { + namespace Express { + interface Request { + userId?: string; + tenantId?: string; + userEmail?: string; + isOwner?: boolean; + isAuthenticated?: boolean; + } + } +} + +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; + + 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; + + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + res.status(401).json({ error: 'Unauthorized', code: 'TOKEN_EXPIRED' }); + return; + } + if (error instanceof jwt.JsonWebTokenError) { + res.status(401).json({ error: 'Unauthorized', code: 'INVALID_TOKEN' }); + return; + } + logger.error('Auth middleware error', { error }); + res.status(500).json({ error: 'Internal server error', code: 'AUTH_ERROR' }); + } +} + +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(); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..77baa8f --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1 @@ +export { authMiddleware, optionalAuthMiddleware } from './auth.middleware'; diff --git a/src/services/vip.service.ts b/src/services/vip.service.ts new file mode 100644 index 0000000..cc4c2f4 --- /dev/null +++ b/src/services/vip.service.ts @@ -0,0 +1,541 @@ +import { Pool } from 'pg'; +import Decimal from 'decimal.js'; +import { getPool, setTenantContext, vipConfig, walletServiceConfig } from '../config'; +import { + VipTier, + VipSubscription, + SubscriptionWithTier, + ModelAccess, + VipTierType, + SubscriptionStatus, + CreateSubscriptionInput, + UsageStats, + mapRowToTier, + mapRowToSubscription, + mapRowToModelAccess, +} from '../types/vip.types'; + +export class VipService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + // ==================== TIERS ==================== + + async getAllTiers(): Promise { + const result = await this.pool.query( + 'SELECT * FROM vip.tiers WHERE is_active = TRUE ORDER BY sort_order ASC' + ); + return result.rows.map(mapRowToTier); + } + + async getTierById(tierId: string): Promise { + const result = await this.pool.query('SELECT * FROM vip.tiers WHERE id = $1', [tierId]); + return result.rows.length > 0 ? mapRowToTier(result.rows[0]) : null; + } + + async getTierByType(tier: VipTierType): Promise { + const result = await this.pool.query( + 'SELECT * FROM vip.tiers WHERE tier = $1 AND is_active = TRUE', + [tier] + ); + return result.rows.length > 0 ? mapRowToTier(result.rows[0]) : null; + } + + // ==================== SUBSCRIPTIONS ==================== + + async createSubscription(input: CreateSubscriptionInput): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + const tier = await this.getTierByType(input.tier); + if (!tier) { + throw new Error(`Tier not found: ${input.tier}`); + } + + // Check for existing active subscription + const existing = await client.query( + `SELECT id FROM vip.subscriptions + WHERE tenant_id = $1 AND user_id = $2 AND status IN ('active', 'trialing')`, + [input.tenantId, input.userId] + ); + + if (existing.rows.length > 0) { + throw new Error('User already has an active VIP subscription'); + } + + const now = new Date(); + const trialEnd = input.startTrial + ? new Date(now.getTime() + vipConfig.trialDays * 24 * 60 * 60 * 1000) + : null; + const periodEnd = new Date( + now.getTime() + (input.interval === 'year' ? 365 : 30) * 24 * 60 * 60 * 1000 + ); + + const result = await client.query( + `INSERT INTO vip.subscriptions ( + tenant_id, user_id, tier_id, + stripe_subscription_id, stripe_customer_id, + status, interval, + current_period_start, current_period_end, + trial_start, trial_end + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + input.tenantId, + input.userId, + tier.id, + input.stripeSubscriptionId || null, + input.stripeCustomerId || null, + input.startTrial ? 'trialing' : 'active', + input.interval || 'month', + now, + periodEnd, + input.startTrial ? now : null, + trialEnd, + ] + ); + + const subscription = mapRowToSubscription(result.rows[0]); + + // Grant model access + for (const modelId of tier.includedModels) { + await client.query( + `INSERT INTO vip.model_access (subscription_id, model_id, model_name) + VALUES ($1, $2, $3)`, + [subscription.id, modelId, modelId] + ); + } + + await client.query('COMMIT'); + + return { ...subscription, tier }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async getSubscription(userId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT s.*, t.*, + s.id as subscription_id, t.id as tier_id + FROM vip.subscriptions s + JOIN vip.tiers t ON s.tier_id = t.id + WHERE s.user_id = $1 AND s.status IN ('active', 'trialing', 'past_due') + LIMIT 1`, + [userId] + ); + + if (result.rows.length === 0) return null; + + const row = result.rows[0]; + const subscription = mapRowToSubscription({ ...row, id: row.subscription_id }); + const tier = mapRowToTier({ ...row, id: row.tier_id }); + + return { ...subscription, tier }; + } finally { + client.release(); + } + } + + async getSubscriptionById(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT s.*, t.*, + s.id as subscription_id, t.id as tier_id + FROM vip.subscriptions s + JOIN vip.tiers t ON s.tier_id = t.id + WHERE s.id = $1`, + [subscriptionId] + ); + + if (result.rows.length === 0) return null; + + const row = result.rows[0]; + const subscription = mapRowToSubscription({ ...row, id: row.subscription_id }); + const tier = mapRowToTier({ ...row, id: row.tier_id }); + + return { ...subscription, tier }; + } finally { + client.release(); + } + } + + async cancelSubscription( + subscriptionId: string, + tenantId: string, + reason?: string, + cancelImmediately: boolean = false + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const cancelAt = cancelImmediately ? new Date() : null; + const newStatus = cancelImmediately ? 'cancelled' : 'active'; + + const result = await client.query( + `UPDATE vip.subscriptions + SET status = $1, + cancel_at = COALESCE($2, current_period_end), + cancelled_at = NOW(), + cancel_reason = $3 + WHERE id = $4 + RETURNING *`, + [newStatus, cancelAt, reason || null, subscriptionId] + ); + + if (result.rows.length === 0) { + throw new Error('Subscription not found'); + } + + return mapRowToSubscription(result.rows[0]); + } finally { + client.release(); + } + } + + async reactivateSubscription(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE vip.subscriptions + SET status = 'active', + cancel_at = NULL, + cancelled_at = NULL, + cancel_reason = NULL + WHERE id = $1 AND status IN ('cancelled', 'past_due') + RETURNING *`, + [subscriptionId] + ); + + if (result.rows.length === 0) { + throw new Error('Subscription not found or cannot be reactivated'); + } + + return mapRowToSubscription(result.rows[0]); + } finally { + client.release(); + } + } + + async upgradeTier( + subscriptionId: string, + newTier: VipTierType, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + + const tier = await this.getTierByType(newTier); + if (!tier) { + throw new Error(`Tier not found: ${newTier}`); + } + + // Update subscription + const result = await client.query( + `UPDATE vip.subscriptions SET tier_id = $1 WHERE id = $2 RETURNING *`, + [tier.id, subscriptionId] + ); + + if (result.rows.length === 0) { + throw new Error('Subscription not found'); + } + + const subscription = mapRowToSubscription(result.rows[0]); + + // Update model access + await client.query( + 'DELETE FROM vip.model_access WHERE subscription_id = $1', + [subscriptionId] + ); + + for (const modelId of tier.includedModels) { + await client.query( + `INSERT INTO vip.model_access (subscription_id, model_id, model_name) + VALUES ($1, $2, $3)`, + [subscriptionId, modelId, modelId] + ); + } + + await client.query('COMMIT'); + + return { ...subscription, tier }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + // ==================== MODEL ACCESS ==================== + + async checkModelAccess(userId: string, modelId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT vip.check_vip_access($1, $2) as has_access`, + [userId, modelId] + ); + + return result.rows[0]?.has_access || false; + } finally { + client.release(); + } + } + + async getModelAccess(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT * FROM vip.model_access WHERE subscription_id = $1`, + [subscriptionId] + ); + + return result.rows.map(mapRowToModelAccess); + } finally { + client.release(); + } + } + + async recordModelUsage( + subscriptionId: string, + modelId: string, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE vip.model_access + SET predictions_generated = predictions_generated + 1, + daily_used = daily_used + 1, + monthly_used = monthly_used + 1, + last_used_at = NOW() + WHERE subscription_id = $1 AND model_id = $2 + RETURNING *`, + [subscriptionId, modelId] + ); + + if (result.rows.length === 0) { + throw new Error('Model access not found'); + } + + // Also update subscription usage + await client.query( + `UPDATE vip.subscriptions + SET vip_predictions_used = vip_predictions_used + 1 + WHERE id = $1`, + [subscriptionId] + ); + + return mapRowToModelAccess(result.rows[0]); + } finally { + client.release(); + } + } + + // ==================== USAGE ==================== + + async getUsageStats(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT s.*, t.limits + FROM vip.subscriptions s + JOIN vip.tiers t ON s.tier_id = t.id + WHERE s.id = $1`, + [subscriptionId] + ); + + if (result.rows.length === 0) return null; + + const row = result.rows[0]; + const limits = row.limits as { vip_predictions_per_month: number; api_calls_per_month: number }; + const periodEnd = new Date(row.current_period_end); + const now = new Date(); + const daysRemaining = Math.max(0, Math.ceil((periodEnd.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))); + + const predictionsLimit = limits.vip_predictions_per_month === -1 ? Infinity : limits.vip_predictions_per_month; + const apiLimit = limits.api_calls_per_month === -1 ? Infinity : limits.api_calls_per_month; + + return { + predictionsUsed: row.vip_predictions_used, + predictionsLimit, + predictionsRemaining: Math.max(0, predictionsLimit - row.vip_predictions_used), + apiCallsUsed: row.api_calls_this_period, + apiCallsLimit: apiLimit, + apiCallsRemaining: Math.max(0, apiLimit - row.api_calls_this_period), + periodStart: new Date(row.current_period_start), + periodEnd, + daysRemaining, + }; + } finally { + client.release(); + } + } + + async incrementApiCalls(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE vip.subscriptions + SET api_calls_this_period = api_calls_this_period + 1 + WHERE id = $1 + RETURNING api_calls_this_period`, + [subscriptionId] + ); + + return result.rows[0]?.api_calls_this_period || 0; + } finally { + client.release(); + } + } + + async resetUsage(subscriptionId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + await client.query( + `UPDATE vip.subscriptions + SET vip_predictions_used = 0, + api_calls_this_period = 0, + last_usage_reset = NOW() + WHERE id = $1`, + [subscriptionId] + ); + + await client.query( + `UPDATE vip.model_access + SET daily_used = 0, monthly_used = 0 + WHERE subscription_id = $1`, + [subscriptionId] + ); + } finally { + client.release(); + } + } + + // ==================== STATUS MANAGEMENT ==================== + + async updateStatus( + subscriptionId: string, + status: SubscriptionStatus, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `UPDATE vip.subscriptions SET status = $1 WHERE id = $2 RETURNING *`, + [status, subscriptionId] + ); + + if (result.rows.length === 0) { + throw new Error('Subscription not found'); + } + + return mapRowToSubscription(result.rows[0]); + } finally { + client.release(); + } + } + + async renewPeriod( + subscriptionId: string, + tenantId: string, + interval: 'month' | 'year' = 'month' + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const now = new Date(); + const periodEnd = new Date( + now.getTime() + (interval === 'year' ? 365 : 30) * 24 * 60 * 60 * 1000 + ); + + const result = await client.query( + `UPDATE vip.subscriptions + SET status = 'active', + current_period_start = NOW(), + current_period_end = $1, + vip_predictions_used = 0, + api_calls_this_period = 0, + last_usage_reset = NOW() + WHERE id = $2 + RETURNING *`, + [periodEnd, subscriptionId] + ); + + if (result.rows.length === 0) { + throw new Error('Subscription not found'); + } + + // Reset model access usage + await client.query( + `UPDATE vip.model_access + SET daily_used = 0, monthly_used = 0 + WHERE subscription_id = $1`, + [subscriptionId] + ); + + return mapRowToSubscription(result.rows[0]); + } finally { + client.release(); + } + } +} + +let vipServiceInstance: VipService | null = null; + +export function getVipService(): VipService { + if (!vipServiceInstance) { + vipServiceInstance = new VipService(); + } + return vipServiceInstance; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..0ab2f7e --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,66 @@ +export { + vip_get_tiers, + vip_get_tier, + vip_create_subscription, + vip_get_subscription, + vip_cancel_subscription, + vip_reactivate_subscription, + vip_upgrade_tier, + vip_check_model_access, + vip_get_model_access, + vip_record_model_usage, + vip_get_usage_stats, + vip_update_status, + vip_renew_period, + handleVipGetTiers, + handleVipGetTier, + handleVipCreateSubscription, + handleVipGetSubscription, + handleVipCancelSubscription, + handleVipReactivateSubscription, + handleVipUpgradeTier, + handleVipCheckModelAccess, + handleVipGetModelAccess, + handleVipRecordModelUsage, + handleVipGetUsageStats, + handleVipUpdateStatus, + handleVipRenewPeriod, + vipToolSchemas, +} from './vip'; + +import { vipToolSchemas } from './vip'; +import { + handleVipGetTiers, + handleVipGetTier, + handleVipCreateSubscription, + handleVipGetSubscription, + handleVipCancelSubscription, + handleVipReactivateSubscription, + handleVipUpgradeTier, + handleVipCheckModelAccess, + handleVipGetModelAccess, + handleVipRecordModelUsage, + handleVipGetUsageStats, + handleVipUpdateStatus, + handleVipRenewPeriod, +} from './vip'; + +export const allToolSchemas = vipToolSchemas; + +export const toolHandlers: Record Promise<{ content: Array<{ type: string; text: string }> }>> = { + vip_get_tiers: handleVipGetTiers, + vip_get_tier: handleVipGetTier, + vip_create_subscription: handleVipCreateSubscription, + vip_get_subscription: handleVipGetSubscription, + vip_cancel_subscription: handleVipCancelSubscription, + vip_reactivate_subscription: handleVipReactivateSubscription, + vip_upgrade_tier: handleVipUpgradeTier, + vip_check_model_access: handleVipCheckModelAccess, + vip_get_model_access: handleVipGetModelAccess, + vip_record_model_usage: handleVipRecordModelUsage, + vip_get_usage_stats: handleVipGetUsageStats, + vip_update_status: handleVipUpdateStatus, + vip_renew_period: handleVipRenewPeriod, +}; + +export const toolNames = Object.keys(allToolSchemas); diff --git a/src/tools/vip.ts b/src/tools/vip.ts new file mode 100644 index 0000000..cdda018 --- /dev/null +++ b/src/tools/vip.ts @@ -0,0 +1,511 @@ +import { z } from 'zod'; +import { getVipService } from '../services/vip.service'; +import { logger } from '../utils/logger'; +import { + VipTier, + VipSubscription, + SubscriptionWithTier, + ModelAccess, + UsageStats, + VipTierType, + SubscriptionStatus, +} from '../types/vip.types'; + +// ============================================================================ +// INPUT SCHEMAS +// ============================================================================ + +export const GetTiersInputSchema = z.object({}); + +export const GetTierInputSchema = z.object({ + tierId: z.string().uuid().optional(), + tier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const).optional(), +}).refine(data => data.tierId || data.tier, { message: 'Either tierId or tier required' }); + +export const CreateSubscriptionInputSchema = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + tier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const), + interval: z.enum(['month', 'year'] as const).default('month'), + stripeSubscriptionId: z.string().optional(), + stripeCustomerId: z.string().optional(), + startTrial: z.boolean().default(false), +}); + +export const GetSubscriptionInputSchema = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid().optional(), + subscriptionId: z.string().uuid().optional(), +}).refine(data => data.userId || data.subscriptionId, { message: 'Either userId or subscriptionId required' }); + +export const CancelSubscriptionInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), + reason: z.string().optional(), + cancelImmediately: z.boolean().default(false), +}); + +export const ReactivateSubscriptionInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), +}); + +export const UpgradeTierInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), + newTier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const), +}); + +export const CheckModelAccessInputSchema = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + modelId: z.string(), +}); + +export const GetModelAccessInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), +}); + +export const RecordModelUsageInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), + modelId: z.string(), +}); + +export const GetUsageStatsInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), +}); + +export const UpdateStatusInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), + status: z.enum(['trialing', 'active', 'past_due', 'cancelled', 'expired'] as const), +}); + +export const RenewPeriodInputSchema = z.object({ + tenantId: z.string().uuid(), + subscriptionId: z.string().uuid(), + interval: z.enum(['month', 'year'] as const).default('month'), +}); + +// ============================================================================ +// RESULT TYPE +// ============================================================================ + +interface ToolResult { + success: boolean; + data?: T; + error?: string; +} + +// ============================================================================ +// TOOL IMPLEMENTATIONS +// ============================================================================ + +export async function vip_get_tiers(): Promise> { + try { + const service = getVipService(); + const tiers = await service.getAllTiers(); + return { success: true, data: tiers }; + } catch (error) { + logger.error('vip_get_tiers failed', { error }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_get_tier(params: z.infer): Promise> { + try { + const service = getVipService(); + let tier: VipTier | null = null; + + if (params.tierId) { + tier = await service.getTierById(params.tierId); + } else if (params.tier) { + tier = await service.getTierByType(params.tier); + } + + if (!tier) return { success: false, error: 'Tier not found' }; + return { success: true, data: tier }; + } catch (error) { + logger.error('vip_get_tier failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_create_subscription( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.createSubscription(params); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_create_subscription failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_get_subscription( + params: z.infer +): Promise> { + try { + const service = getVipService(); + let subscription: SubscriptionWithTier | null = null; + + if (params.subscriptionId) { + subscription = await service.getSubscriptionById(params.subscriptionId, params.tenantId); + } else if (params.userId) { + subscription = await service.getSubscription(params.userId, params.tenantId); + } + + if (!subscription) return { success: false, error: 'Subscription not found' }; + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_get_subscription failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_cancel_subscription( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.cancelSubscription( + params.subscriptionId, + params.tenantId, + params.reason, + params.cancelImmediately + ); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_cancel_subscription failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_reactivate_subscription( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.reactivateSubscription(params.subscriptionId, params.tenantId); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_reactivate_subscription failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_upgrade_tier( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.upgradeTier( + params.subscriptionId, + params.newTier, + params.tenantId + ); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_upgrade_tier failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_check_model_access( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const hasAccess = await service.checkModelAccess(params.userId, params.modelId, params.tenantId); + return { success: true, data: { hasAccess } }; + } catch (error) { + logger.error('vip_check_model_access failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_get_model_access( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const access = await service.getModelAccess(params.subscriptionId, params.tenantId); + return { success: true, data: access }; + } catch (error) { + logger.error('vip_get_model_access failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_record_model_usage( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const access = await service.recordModelUsage( + params.subscriptionId, + params.modelId, + params.tenantId + ); + return { success: true, data: access }; + } catch (error) { + logger.error('vip_record_model_usage failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_get_usage_stats( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const stats = await service.getUsageStats(params.subscriptionId, params.tenantId); + if (!stats) return { success: false, error: 'Subscription not found' }; + return { success: true, data: stats }; + } catch (error) { + logger.error('vip_get_usage_stats failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_update_status( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.updateStatus( + params.subscriptionId, + params.status, + params.tenantId + ); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_update_status failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +export async function vip_renew_period( + params: z.infer +): Promise> { + try { + const service = getVipService(); + const subscription = await service.renewPeriod( + params.subscriptionId, + params.tenantId, + params.interval + ); + return { success: true, data: subscription }; + } catch (error) { + logger.error('vip_renew_period failed', { error, params }); + return { success: false, error: (error as Error).message }; + } +} + +// ============================================================================ +// MCP TOOL SCHEMAS +// ============================================================================ + +export const vipToolSchemas = { + vip_get_tiers: { + name: 'vip_get_tiers', + description: 'Get all available VIP tiers (Gold, Platinum, Diamond)', + inputSchema: { type: 'object', properties: {}, required: [] }, + riskLevel: 'LOW', + }, + vip_get_tier: { + name: 'vip_get_tier', + description: 'Get a specific VIP tier by ID or type', + inputSchema: { + type: 'object', + properties: { + tierId: { type: 'string' }, + tier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] }, + }, + }, + riskLevel: 'LOW', + }, + vip_create_subscription: { + name: 'vip_create_subscription', + description: 'Create a new VIP subscription for a user', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + userId: { type: 'string' }, + tier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] }, + interval: { type: 'string', enum: ['month', 'year'] }, + stripeSubscriptionId: { type: 'string' }, + stripeCustomerId: { type: 'string' }, + startTrial: { type: 'boolean' }, + }, + required: ['tenantId', 'userId', 'tier'], + }, + riskLevel: 'HIGH', + }, + vip_get_subscription: { + name: 'vip_get_subscription', + description: 'Get user VIP subscription by user ID or subscription ID', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + userId: { type: 'string' }, + subscriptionId: { type: 'string' }, + }, + required: ['tenantId'], + }, + riskLevel: 'LOW', + }, + vip_cancel_subscription: { + name: 'vip_cancel_subscription', + description: 'Cancel a VIP subscription', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + reason: { type: 'string' }, + cancelImmediately: { type: 'boolean' }, + }, + required: ['tenantId', 'subscriptionId'], + }, + riskLevel: 'HIGH', + }, + vip_reactivate_subscription: { + name: 'vip_reactivate_subscription', + description: 'Reactivate a cancelled VIP subscription', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + }, + required: ['tenantId', 'subscriptionId'], + }, + riskLevel: 'HIGH', + }, + vip_upgrade_tier: { + name: 'vip_upgrade_tier', + description: 'Upgrade subscription to a higher tier', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + newTier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] }, + }, + required: ['tenantId', 'subscriptionId', 'newTier'], + }, + riskLevel: 'HIGH', + }, + vip_check_model_access: { + name: 'vip_check_model_access', + description: 'Check if user has VIP access to a specific ML model', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + userId: { type: 'string' }, + modelId: { type: 'string' }, + }, + required: ['tenantId', 'userId', 'modelId'], + }, + riskLevel: 'LOW', + }, + vip_get_model_access: { + name: 'vip_get_model_access', + description: 'Get all model access records for a subscription', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + }, + required: ['tenantId', 'subscriptionId'], + }, + riskLevel: 'LOW', + }, + vip_record_model_usage: { + name: 'vip_record_model_usage', + description: 'Record model usage for a VIP subscription', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + modelId: { type: 'string' }, + }, + required: ['tenantId', 'subscriptionId', 'modelId'], + }, + riskLevel: 'MEDIUM', + }, + vip_get_usage_stats: { + name: 'vip_get_usage_stats', + description: 'Get usage statistics for a VIP subscription', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + }, + required: ['tenantId', 'subscriptionId'], + }, + riskLevel: 'LOW', + }, + vip_update_status: { + name: 'vip_update_status', + description: 'Update subscription status', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + status: { type: 'string', enum: ['trialing', 'active', 'past_due', 'cancelled', 'expired'] }, + }, + required: ['tenantId', 'subscriptionId', 'status'], + }, + riskLevel: 'HIGH', + }, + vip_renew_period: { + name: 'vip_renew_period', + description: 'Renew subscription period and reset usage', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string' }, + subscriptionId: { type: 'string' }, + interval: { type: 'string', enum: ['month', 'year'] }, + }, + required: ['tenantId', 'subscriptionId'], + }, + 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 const handleVipGetTiers = async () => formatMcpResponse(await vip_get_tiers()); +export const handleVipGetTier = async (p: unknown) => formatMcpResponse(await vip_get_tier(GetTierInputSchema.parse(p))); +export const handleVipCreateSubscription = async (p: unknown) => formatMcpResponse(await vip_create_subscription(CreateSubscriptionInputSchema.parse(p))); +export const handleVipGetSubscription = async (p: unknown) => formatMcpResponse(await vip_get_subscription(GetSubscriptionInputSchema.parse(p))); +export const handleVipCancelSubscription = async (p: unknown) => formatMcpResponse(await vip_cancel_subscription(CancelSubscriptionInputSchema.parse(p))); +export const handleVipReactivateSubscription = async (p: unknown) => formatMcpResponse(await vip_reactivate_subscription(ReactivateSubscriptionInputSchema.parse(p))); +export const handleVipUpgradeTier = async (p: unknown) => formatMcpResponse(await vip_upgrade_tier(UpgradeTierInputSchema.parse(p))); +export const handleVipCheckModelAccess = async (p: unknown) => formatMcpResponse(await vip_check_model_access(CheckModelAccessInputSchema.parse(p))); +export const handleVipGetModelAccess = async (p: unknown) => formatMcpResponse(await vip_get_model_access(GetModelAccessInputSchema.parse(p))); +export const handleVipRecordModelUsage = async (p: unknown) => formatMcpResponse(await vip_record_model_usage(RecordModelUsageInputSchema.parse(p))); +export const handleVipGetUsageStats = async (p: unknown) => formatMcpResponse(await vip_get_usage_stats(GetUsageStatsInputSchema.parse(p))); +export const handleVipUpdateStatus = async (p: unknown) => formatMcpResponse(await vip_update_status(UpdateStatusInputSchema.parse(p))); +export const handleVipRenewPeriod = async (p: unknown) => formatMcpResponse(await vip_renew_period(RenewPeriodInputSchema.parse(p))); diff --git a/src/types/vip.types.ts b/src/types/vip.types.ts new file mode 100644 index 0000000..bceb5cd --- /dev/null +++ b/src/types/vip.types.ts @@ -0,0 +1,178 @@ +import Decimal from 'decimal.js'; + +export type VipTierType = 'GOLD' | 'PLATINUM' | 'DIAMOND'; + +export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'expired'; + +export interface TierBenefit { + name: string; + included?: boolean; + value?: string | number; +} + +export interface TierLimits { + vip_predictions_per_month: number; + api_calls_per_month: number; + exclusive_models: string[]; + priority_support?: boolean; + custom_alerts?: number; + [key: string]: unknown; +} + +export interface VipTier { + id: string; + tier: VipTierType; + name: string; + description: string | null; + tagline: string | null; + priceMonthly: Decimal; + priceYearly: Decimal | null; + currency: string; + stripeProductId: string | null; + stripePriceMonthlyId: string | null; + stripePriceYearlyId: string | null; + benefits: TierBenefit[]; + limits: TierLimits; + includedModels: string[]; + color: string | null; + icon: string | null; + sortOrder: number; + isPopular: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface VipSubscription { + id: string; + tenantId: string; + userId: string; + tierId: string; + stripeSubscriptionId: string | null; + stripeCustomerId: string | null; + status: SubscriptionStatus; + interval: string; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + trialStart: Date | null; + trialEnd: Date | null; + cancelAt: Date | null; + cancelledAt: Date | null; + cancelReason: string | null; + vipPredictionsUsed: number; + apiCallsThisPeriod: number; + lastUsageReset: Date; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface SubscriptionWithTier extends VipSubscription { + tier: VipTier; +} + +export interface ModelAccess { + id: string; + subscriptionId: string; + modelId: string; + modelName: string | null; + predictionsGenerated: number; + lastUsedAt: Date | null; + dailyLimit: number | null; + monthlyLimit: number | null; + dailyUsed: number; + monthlyUsed: number; + grantedAt: Date; + expiresAt: Date | null; +} + +export interface CreateSubscriptionInput { + tenantId: string; + userId: string; + tier: VipTierType; + interval?: 'month' | 'year'; + stripeSubscriptionId?: string; + stripeCustomerId?: string; + startTrial?: boolean; +} + +export interface UsageStats { + predictionsUsed: number; + predictionsLimit: number; + predictionsRemaining: number; + apiCallsUsed: number; + apiCallsLimit: number; + apiCallsRemaining: number; + periodStart: Date; + periodEnd: Date; + daysRemaining: number; +} + +export function mapRowToTier(row: Record): VipTier { + return { + id: row.id as string, + tier: row.tier as VipTierType, + name: row.name as string, + description: row.description as string | null, + tagline: row.tagline as string | null, + priceMonthly: new Decimal(row.price_monthly as string), + priceYearly: row.price_yearly ? new Decimal(row.price_yearly as string) : null, + currency: row.currency as string, + stripeProductId: row.stripe_product_id as string | null, + stripePriceMonthlyId: row.stripe_price_monthly_id as string | null, + stripePriceYearlyId: row.stripe_price_yearly_id as string | null, + benefits: (row.benefits as TierBenefit[]) || [], + limits: (row.limits as TierLimits) || { vip_predictions_per_month: 0, api_calls_per_month: 0, exclusive_models: [] }, + includedModels: (row.included_models as string[]) || [], + color: row.color as string | null, + icon: row.icon as string | null, + sortOrder: row.sort_order as number, + isPopular: row.is_popular as boolean, + isActive: row.is_active as boolean, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +export function mapRowToSubscription(row: Record): VipSubscription { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + userId: row.user_id as string, + tierId: row.tier_id as string, + stripeSubscriptionId: row.stripe_subscription_id as string | null, + stripeCustomerId: row.stripe_customer_id as string | null, + status: row.status as SubscriptionStatus, + interval: row.interval as string, + currentPeriodStart: row.current_period_start ? new Date(row.current_period_start as string) : null, + currentPeriodEnd: row.current_period_end ? new Date(row.current_period_end as string) : null, + trialStart: row.trial_start ? new Date(row.trial_start as string) : null, + trialEnd: row.trial_end ? new Date(row.trial_end as string) : null, + cancelAt: row.cancel_at ? new Date(row.cancel_at as string) : null, + cancelledAt: row.cancelled_at ? new Date(row.cancelled_at as string) : null, + cancelReason: row.cancel_reason as string | null, + vipPredictionsUsed: row.vip_predictions_used as number, + apiCallsThisPeriod: row.api_calls_this_period as number, + lastUsageReset: new Date(row.last_usage_reset as string), + metadata: (row.metadata as Record) || {}, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +export function mapRowToModelAccess(row: Record): ModelAccess { + return { + id: row.id as string, + subscriptionId: row.subscription_id as string, + modelId: row.model_id as string, + modelName: row.model_name as string | null, + predictionsGenerated: row.predictions_generated as number, + lastUsedAt: row.last_used_at ? new Date(row.last_used_at as string) : null, + dailyLimit: row.daily_limit as number | null, + monthlyLimit: row.monthly_limit as number | null, + dailyUsed: row.daily_used as number, + monthlyUsed: row.monthly_used as number, + grantedAt: new Date(row.granted_at as string), + expiresAt: row.expires_at ? new Date(row.expires_at as string) : null, + }; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..c1d4473 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,28 @@ +import winston from 'winston'; +import { serverConfig } from '../config'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const consoleFormat = printf(({ level, message, timestamp, ...meta }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(meta).length > 0) msg += ` ${JSON.stringify(meta)}`; + return msg; +}); + +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-vip' }, + transports: [ + new winston.transports.Console({ + format: combine( + serverConfig.nodeEnv === 'development' ? colorize() : winston.format.uncolorize(), + consoleFormat + ), + }), + ], +}); + +export function logError(context: string, error: Error, details: Record = {}): void { + logger.error(`Error in ${context}`, { context, error: error.message, stack: error.stack, ...details }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..98d4c73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}