import { z } from 'zod';
import { mcpDb } from '../db/supabase.js';
import { ROIEngine } from '../core/calculators/roi-engine.js';
import { FINANCIAL_CONSTANTS } from '../core/calculators/financial-utils.js';
import { ProjectCreateSchema } from '../schemas/project.js';
import { UseCaseCreateSchema } from '../schemas/use-case.js';
import { createLogger } from '../utils/logger.js';
import { DatabaseError, CalculationError, ValidationError, ConfigurationError } from '../utils/errors.js';
import { validateUseCase, validateFinancialAmount, validateMonths } from '../utils/validators.js';
import { DutchBenchmarkValidator } from '../services/dutch-benchmark-validator.js';
// Transaction manager for atomic operations
class TransactionManager {
private logger = createLogger({ component: 'TransactionManager' });
private rollbackStack: Array<() => Promise<void>> = [];
async executeWithRollback<T>(
operation: () => Promise<T>,
rollbackOperation: () => Promise<void>
): Promise<T> {
try {
const result = await operation();
this.rollbackStack.push(rollbackOperation);
return result;
} catch (error) {
this.logger.error('Operation failed, will trigger rollback', error as Error);
throw error;
}
}
async rollbackAll(): Promise<void> {
this.logger.info('Starting rollback', { operations: this.rollbackStack.length });
// Execute rollbacks in reverse order
while (this.rollbackStack.length > 0) {
const rollback = this.rollbackStack.pop()!;
try {
await rollback();
} catch (error) {
this.logger.error('Rollback operation failed', error as Error);
// Continue with other rollbacks even if one fails
}
}
}
}
// Retry logic with exponential backoff
class RetryManager {
private logger = createLogger({ component: 'RetryManager' });
async withRetry<T>(
operation: () => Promise<T>,
options: {
maxAttempts?: number;
initialDelay?: number;
maxDelay?: number;
shouldRetry?: (error: any) => boolean;
} = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelay = 1000,
maxDelay = 10000,
shouldRetry = (error: any) => this.defaultShouldRetry(error)
} = options;
let lastError: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = Math.min(initialDelay * Math.pow(2, attempt - 1), maxDelay);
this.logger.warn(`Operation failed, retrying in ${delay}ms`, {
attempt,
maxAttempts,
error: (error as Error).message
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
private defaultShouldRetry(error: any): boolean {
// Retry on network errors or specific database errors
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return true;
}
// Retry on specific Supabase errors
if (error.message?.includes('too many requests') ||
error.message?.includes('temporarily unavailable')) {
return true;
}
// Don't retry on validation errors or business logic errors
if (error instanceof ValidationError || error instanceof CalculationError) {
return false;
}
return false;
}
}
export const PredictROISchema = z.object({
organization_id: z.string(),
project: ProjectCreateSchema,
use_cases: z.array(UseCaseCreateSchema),
implementation_costs: z.object({
software_licenses: z.number().min(0),
development_hours: z.number().min(0),
training_costs: z.number().min(0),
infrastructure: z.number().min(0),
ongoing_monthly: z.number().min(0).default(0)
}),
timeline_months: z.number().min(1).max(120),
confidence_level: z.number().min(0).max(1).default(0.95),
// Options for transaction behavior
enable_retry: z.boolean().default(true),
transaction_timeout: z.number().default(30000) // 30 seconds default
});
export type PredictROIInput = z.infer<typeof PredictROISchema>;
export async function predictROI(input: PredictROIInput) {
const toolLogger = createLogger({ tool: 'predict_roi' });
const transactionManager = new TransactionManager();
const retryManager = new RetryManager();
toolLogger.info('Starting ROI prediction', {
organization_id: input.organization_id,
project_name: input.project?.project_name,
use_case_count: input.use_cases?.length,
enable_retry: input.enable_retry
});
// Set a timeout for the entire operation
const timeoutMs = input.transaction_timeout || 30000; // Default to 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Transaction timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([
performPrediction(),
timeoutPromise
]);
} catch (error) {
if ((error as Error).message.includes('Transaction timeout')) {
toolLogger.error('Transaction timed out', error as Error);
await transactionManager.rollbackAll();
throw new DatabaseError('Operation timed out. Please try again with fewer use cases or contact support.', {
timeout: input.transaction_timeout
});
}
throw error;
}
async function performPrediction() {
let validationResult: any = null;
try {
// Step 1: Check for Perplexity API key
const perplexityApiKey = process.env.PERPLEXITY_API_KEY;
if (!perplexityApiKey) {
throw new ConfigurationError(
'PERPLEXITY_API_KEY is required for ROI predictions. Please configure it in your environment.'
);
}
// Step 2: Validate against Dutch market benchmarks (MANDATORY FIRST STEP)
toolLogger.info('Validating inputs against Dutch market benchmarks');
const dutchValidator = new DutchBenchmarkValidator(perplexityApiKey);
// Ensure project data exists before accessing it
if (!input.project) {
throw new ValidationError('Missing required project information', {
field: 'project',
message: 'Project details are required'
});
}
validationResult = await dutchValidator.validateProjectInputs({
industry: input.project.industry,
useCases: input.use_cases,
implementationCosts: input.implementation_costs,
timelineMonths: input.timeline_months
});
if (!validationResult.isValid) {
toolLogger.warn('Validation found critical issues', {
issueCount: validationResult.validationIssues.length,
errors: validationResult.validationIssues.filter((i: any) => i.severity === 'error')
});
}
// Log validation adjustments
if (validationResult.validationIssues.length > 0) {
toolLogger.info('Dutch market validation applied adjustments', {
adjustmentCount: validationResult.validationIssues.length,
warnings: validationResult.validationIssues.filter((i: any) => i.severity === 'warning').length
});
}
// Step 3: Use validated/adjusted input for further processing
const validatedInput = PredictROISchema.parse({
...input,
...validationResult.adjustedInput,
// Preserve original organization_id
organization_id: input.organization_id
});
// Step 4: Additional validation
validateInputData(validatedInput);
// Step 5: Calculate ROI projection with validated data
toolLogger.debug('Calculating ROI projection with validated inputs');
const projection = await calculateProjection(validatedInput);
// Step 3: Create database records with calculated values using transaction manager
toolLogger.debug('Creating database records with transaction management');
// Create project with retry logic
const project = await (input.enable_retry
? retryManager.withRetry.bind(retryManager)
: async (op: any) => op())(
async () => {
return await transactionManager.executeWithRollback(
async () => {
const { data, error } = await mcpDb
.from('projects')
.insert({
client_name: validatedInput.project.client_name,
project_name: validatedInput.project.project_name,
industry: validatedInput.project.industry,
description: validatedInput.project.description,
status: validatedInput.project.status || 'active'
})
.select()
.single();
if (error || !data) {
throw new DatabaseError('Failed to create project', {
error: error?.message,
code: error?.code
});
}
return data;
},
async () => {
// Rollback operation for project
if (project?.id) {
await mcpDb.from('projects').delete().eq('id', project.id);
}
}
);
}
);
toolLogger.debug('Project created', { projectId: project.id });
// Create use cases
const useCases = await transactionManager.executeWithRollback(
async () => {
const useCasesToInsert = validatedInput.use_cases.map((uc: any) => ({
...uc,
project_id: project.id
}));
const { data, error } = await mcpDb
.from('use_cases')
.insert(useCasesToInsert)
.select();
if (error || !data || data.length === 0) {
throw new DatabaseError('Failed to create use cases', {
error: error?.message,
code: error?.code,
projectId: project.id
});
}
return data;
},
async () => {
// Rollback operation for use cases
if (project.id) {
await mcpDb.from('use_cases').delete().eq('project_id', project.id);
}
}
);
toolLogger.debug('Use cases created', { count: useCases.length });
// Create projection with calculated values
const createdProjection = await transactionManager.executeWithRollback(
async () => {
// Update projection with actual project ID
const projectionToInsert = {
...projection,
project_id: project.id,
metadata: {
...projection.metadata,
calculated_at: new Date().toISOString(),
calculation_version: 'v3.1',
retry_enabled: input.enable_retry,
dutch_validation_applied: true
}
};
const { data, error } = await mcpDb
.from('projections')
.insert(projectionToInsert)
.select()
.single();
if (error || !data) {
throw new DatabaseError('Failed to create projection', {
error: error?.message,
code: error?.code,
projectId: project.id
});
}
return data;
},
async () => {
// Rollback operation for projection
if (createdProjection?.id) {
await mcpDb.from('projections').delete().eq('id', createdProjection.id);
}
}
);
toolLogger.debug('Projection created with calculated values', {
projectionId: createdProjection.id,
roi: createdProjection.calculations.five_year_roi
});
// Step 6: Return formatted response with validation results
const response = {
project_id: project.id,
projection_id: createdProjection.id,
summary: {
total_investment: createdProjection.calculations.total_investment,
expected_roi: createdProjection.calculations.five_year_roi,
payback_period_months: createdProjection.calculations.payback_period_months,
net_present_value: createdProjection.calculations.net_present_value,
break_even_date: createdProjection.calculations.break_even_date
},
financial_metrics: createdProjection.financial_metrics,
assumptions: createdProjection.metadata.assumptions,
use_cases: useCases.map(uc => ({
name: uc.name,
category: uc.category,
monthly_benefit: calculateUseCaseBenefit(uc)
})),
dutch_market_validation: {
validation_applied: true,
adjustments_made: validationResult.validationIssues.length,
validation_issues: validationResult.validationIssues,
market_insights: validationResult.marketInsights,
recommendations: validationResult.recommendations,
citations: validationResult.citations
},
metadata: {
dutch_market_validated: true,
calculation_timestamp: new Date().toISOString(),
confidence_level: input.confidence_level,
validation_version: 'v1.3.0'
}
};
toolLogger.info('Enhanced ROI prediction completed successfully', {
projectId: project.id,
roi: createdProjection.calculations.five_year_roi,
payback_months: createdProjection.calculations.payback_period_months
});
return response;
} catch (error) {
// Rollback all operations on any error
await transactionManager.rollbackAll();
// Log and re-throw with error context
toolLogger.error('ROI prediction failed', error as Error, {
operation: 'predictROI',
project_name: input.project?.project_name,
use_case_count: input.use_cases?.length
});
throw enhanceError(error);
}
}
}
function validateInputData(input: PredictROIInput) {
// Validate financial amounts
validateFinancialAmount(
input.implementation_costs.software_licenses,
'software_licenses'
);
validateFinancialAmount(
input.implementation_costs.development_hours * FINANCIAL_CONSTANTS.DEFAULT_DEVELOPER_HOURLY_RATE,
'development_costs'
);
validateFinancialAmount(
input.implementation_costs.training_costs,
'training_costs'
);
validateFinancialAmount(
input.implementation_costs.infrastructure,
'infrastructure'
);
validateMonths(input.timeline_months, 'timeline_months');
// Validate each use case
input.use_cases.forEach((useCase: any, index: number) => {
try {
validateUseCase(useCase);
} catch (error) {
throw new ValidationError(
`Invalid use case at index ${index}: ${(error as Error).message}`,
{ index, useCase }
);
}
});
}
async function calculateProjection(input: PredictROIInput) {
const roiEngine = new ROIEngine();
// Generate temporary IDs for calculation
const tempProjectId = 'temp-' + Date.now();
const tempUseCases = input.use_cases.map((uc: any, index: number) => ({
...uc,
id: `temp-use-case-${index}`,
project_id: tempProjectId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}));
try {
const projection = roiEngine.calculateProjection(
tempProjectId,
tempUseCases,
input.implementation_costs,
input.timeline_months,
'Base Case'
);
// Validate calculated values
if (!isFinite(projection.calculations.total_investment) || projection.calculations.total_investment <= 0) {
throw new CalculationError('Invalid total investment calculated', {
total_investment: projection.calculations.total_investment
});
}
if (!isFinite(projection.calculations.five_year_roi)) {
projection.calculations.five_year_roi = 0;
}
return projection;
} catch (error) {
throw new CalculationError(
'Failed to calculate ROI projection. Please check your input values.',
{
error: (error as Error).message,
use_case_count: input.use_cases.length
}
);
}
}
function calculateUseCaseBenefit(useCase: any): number {
const { current_state, future_state } = useCase;
// Time savings value
const timeSavingsValue = current_state.process_time_hours *
current_state.volume_per_month *
future_state.time_reduction_percentage *
FINANCIAL_CONSTANTS.DEFAULT_HOURLY_RATE;
// Direct cost savings
const costSavings = current_state.cost_per_transaction *
current_state.volume_per_month *
future_state.automation_percentage;
// Quality improvement value (reduced errors)
const qualityValue = current_state.cost_per_transaction *
current_state.volume_per_month *
current_state.error_rate *
future_state.error_reduction_percentage *
FINANCIAL_CONSTANTS.ERROR_COST_MULTIPLIER;
return timeSavingsValue + costSavings + qualityValue;
}
function enhanceError(error: any): Error {
if (error instanceof ValidationError || error instanceof CalculationError || error instanceof DatabaseError) {
return error;
}
// Handle specific database errors
if (error.message?.includes('duplicate key')) {
return new DatabaseError(
'A project with this name already exists. Please use a different project name.',
{ originalError: error.message }
);
}
if (error.message?.includes('violates foreign key')) {
return new DatabaseError(
'Invalid reference in data. Please ensure all IDs are valid.',
{ originalError: error.message }
);
}
if (error.message?.includes('connection')) {
return new DatabaseError(
'Unable to connect to the database. Please check your connection and try again.',
{ originalError: error.message }
);
}
// Generic error
return new Error(
`Unexpected error in ROI prediction: ${error.message}. Please try again or contact support.`
);
}