From 33b7970404659ae84f2aab1fc5b0fc723f73c6f8 Mon Sep 17 00:00:00 2001 From: akansmafengadamu Date: Mon, 27 Jul 2026 14:00:23 +0100 Subject: [PATCH] feat: implement input validation, CORS, fallback chains, and revenue forecasting - Add Zod schema validation middleware with XSS sanitization and SQL injection prevention (#771) - Implement dynamic CORS policy management with per-tenant whitelisting (#772) - Build payment method fallback chains with automatic retry and analytics (#773) - Create revenue forecasting service with trend analysis and visualization (#774) --- backend/services/analytics/index.ts | 19 + .../analytics/revenueForecastService.ts | 659 ++++++++++++++++++ .../payment/domain/fallbackChainService.ts | 555 +++++++++++++++ backend/services/payment/index.ts | 24 + backend/services/shared/corsMiddleware.ts | 467 +++++++++++++ backend/services/shared/index.ts | 67 ++ backend/services/shared/schemas.ts | 199 ++++++ .../services/shared/validationMiddleware.ts | 389 +++++++++++ developer-portal/docs/cors-guide.md | 90 +++ docs/CORS.md | 219 ++++++ docs/FALLBACK_CHAINS.md | 192 +++++ docs/REVENUE_FORECASTING.md | 264 +++++++ docs/VALIDATION.md | 143 ++++ 13 files changed, 3287 insertions(+) create mode 100644 backend/services/analytics/revenueForecastService.ts create mode 100644 backend/services/payment/domain/fallbackChainService.ts create mode 100644 backend/services/shared/corsMiddleware.ts create mode 100644 backend/services/shared/schemas.ts create mode 100644 backend/services/shared/validationMiddleware.ts create mode 100644 developer-portal/docs/cors-guide.md create mode 100644 docs/CORS.md create mode 100644 docs/FALLBACK_CHAINS.md create mode 100644 docs/REVENUE_FORECASTING.md create mode 100644 docs/VALIDATION.md diff --git a/backend/services/analytics/index.ts b/backend/services/analytics/index.ts index 395a7407..66aebad0 100644 --- a/backend/services/analytics/index.ts +++ b/backend/services/analytics/index.ts @@ -18,3 +18,22 @@ export { RetentionCalculator, RETENTION_CURVE_DAYS } from './retentionCalculator export { cohortTableToCsv, ltvBreakdownToCsv, cohortTableToPdf, churnBreakdownToPdf, buildSimplePdf } from './cohortReportExport'; export { SubscriberRecordRepository, subscriberRecordRepository } from './subscriberRecordRepository'; export { AnalyticsDashboardApi, analyticsDashboardApi } from './analyticsDashboardApi'; +export { + analyzeTrend, + calculateAccuracy, + generateVisualizationData, + generateRevenueForecast, + generateForecastAlerts, +} from './revenueForecastService'; +export type { + RevenueDataPoint, + ForecastPoint as RevenueForecastPoint, + TrendAnalysis, + ForecastAccuracy, + RevenueForecastResult, + ForecastVisualizationData, + ForecastAlert, + ForecastGranularity, + TrendDirection, + ForecastModel, +} from './revenueForecastService'; diff --git a/backend/services/analytics/revenueForecastService.ts b/backend/services/analytics/revenueForecastService.ts new file mode 100644 index 00000000..ada49bb7 --- /dev/null +++ b/backend/services/analytics/revenueForecastService.ts @@ -0,0 +1,659 @@ +/** + * Issue #774 – Subscription Analytics with Revenue Forecasting + * + * Provides: + * - Revenue forecasting models + * - Trend analysis + * - Forecast accuracy tracking + * - Forecast visualization data + */ + +import { logger } from '../../shared/logging'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type ForecastGranularity = 'hour' | 'day' | 'week' | 'month'; + +export type TrendDirection = 'up' | 'down' | 'stable'; + +export type ForecastModel = 'linear' | 'exponential' | 'moving_average' | 'seasonal'; + +export interface RevenueDataPoint { + /** Period identifier (ISO timestamp or formatted period) */ + period: string; + /** Revenue amount */ + revenue: number; + /** Number of subscribers */ + subscriberCount: number; + /** Average revenue per user */ + arpu: number; + /** New subscribers */ + newSubscribers: number; + /** Churned subscribers */ + churnedSubscribers: number; + /** MRR (Monthly Recurring Revenue) */ + mrr: number; + /** ARR (Annual Recurring Revenue) */ + arr: number; +} + +export interface ForecastPoint { + /** Period identifier */ + period: string; + /** Predicted revenue */ + predictedRevenue: number; + /** Lower confidence bound */ + lowerBound: number; + /** Upper confidence bound */ + upperBound: number; + /** Confidence level (0-1) */ + confidence: number; + /** Model used */ + model: ForecastModel; +} + +export interface TrendAnalysis { + /** Overall trend direction */ + direction: TrendDirection; + /** Trend strength (0-1) */ + strength: number; + /** Growth rate (percentage) */ + growthRate: number; + /** Seasonality detected */ + hasSeasonality: boolean; + /** Seasonal period (if detected) */ + seasonalPeriod?: number; + /** Moving average values */ + movingAverage: number[]; + /** Trend line values */ + trendLine: number[]; +} + +export interface ForecastAccuracy { + /** Mean Absolute Error */ + mae: number; + /** Mean Absolute Percentage Error */ + mape: number; + /** Root Mean Squared Error */ + rmse: number; + /** R-squared */ + rSquared: number; + /** Forecast vs actual comparison */ + comparisons: Array<{ + period: string; + predicted: number; + actual: number; + error: number; + errorPercentage: number; + }>; +} + +export interface RevenueForecastResult { + /** Forecast points */ + forecasts: ForecastPoint[]; + /** Trend analysis */ + trend: TrendAnalysis; + /** Historical data used */ + historicalData: RevenueDataPoint[]; + /** Model used */ + model: ForecastModel; + /** Forecast accuracy (if actuals available) */ + accuracy?: ForecastAccuracy; + /** Period granularity */ + granularity: ForecastGranularity; + /** Timestamp of forecast generation */ + generatedAt: string; +} + +export interface ForecastVisualizationData { + /** Combined historical and forecast data for charting */ + series: Array<{ + period: string; + historical: number | null; + forecast: number | null; + lowerBound: number | null; + upperBound: number | null; + isForecast: boolean; + }>; + /** Summary statistics */ + summary: { + currentMrr: number; + projectedMrr: number; + growthRate: number; + confidenceLevel: number; + }; +} + +// ── Forecasting Models ─────────────────────────────────────────────────────── + +/** + * Simple linear regression forecast. + */ +function linearForecast( + historical: number[], + horizon: number, + confidence: number = 0.95 +): ForecastPoint[] { + const n = historical.length; + if (n < 2) return []; + + // Calculate slope and intercept + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (let i = 0; i < n; i++) { + sumX += i; + sumY += historical[i]; + sumXY += i * historical[i]; + sumX2 += i * i; + } + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + // Calculate standard error + const residuals = historical.map((y, i) => y - (intercept + slope * i)); + const mse = residuals.reduce((sum, r) => sum + r * r, 0) / (n - 2); + const se = Math.sqrt(mse); + + // Z-score for confidence level + const zScore = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645; + + const forecasts: ForecastPoint[] = []; + for (let i = 0; i < horizon; i++) { + const x = n + i; + const predicted = intercept + slope * x; + const margin = zScore * se * Math.sqrt(1 + 1/n + Math.pow(x - sumX/n, 2) / (sumX2 - sumX*sumX/n)); + + forecasts.push({ + period: `period-${x}`, + predictedRevenue: Math.max(0, predicted), + lowerBound: Math.max(0, predicted - margin), + upperBound: predicted + margin, + confidence, + model: 'linear', + }); + } + + return forecasts; +} + +/** + * Exponential smoothing forecast. + */ +function exponentialForecast( + historical: number[], + horizon: number, + alpha: number = 0.3, + confidence: number = 0.95 +): ForecastPoint[] { + if (historical.length < 2) return []; + + // Simple exponential smoothing + let smoothed = historical[0]; + const smoothedValues = [smoothed]; + + for (let i = 1; i < historical.length; i++) { + smoothed = alpha * historical[i] + (1 - alpha) * smoothed; + smoothedValues.push(smoothed); + } + + // Calculate error variance + const errors = historical.map((y, i) => y - smoothedValues[i]); + const mse = errors.reduce((sum, e) => sum + e * e, 0) / historical.length; + const se = Math.sqrt(mse); + + const zScore = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645; + + const forecasts: ForecastPoint[] = []; + for (let i = 0; i < horizon; i++) { + const predicted = smoothed; + const margin = zScore * se * Math.sqrt(i + 1); + + forecasts.push({ + period: `period-${historical.length + i}`, + predictedRevenue: Math.max(0, predicted), + lowerBound: Math.max(0, predicted - margin), + upperBound: predicted + margin, + confidence, + model: 'exponential', + }); + } + + return forecasts; +} + +/** + * Moving average forecast. + */ +function movingAverageForecast( + historical: number[], + horizon: number, + windowSize: number = 3, + confidence: number = 0.95 +): ForecastPoint[] { + if (historical.length < windowSize) return []; + + // Calculate moving averages + const movingAvgs: number[] = []; + for (let i = windowSize - 1; i < historical.length; i++) { + const window = historical.slice(i - windowSize + 1, i + 1); + movingAvgs.push(window.reduce((a, b) => a + b, 0) / windowSize); + } + + // Calculate error + const errors = movingAvgs.map((ma, i) => historical[i + windowSize - 1] - ma); + const mse = errors.reduce((sum, e) => sum + e * e, 0) / errors.length; + const se = Math.sqrt(mse); + + const zScore = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645; + + const lastAvg = movingAvgs[movingAvgs.length - 1]; + + const forecasts: ForecastPoint[] = []; + for (let i = 0; i < horizon; i++) { + const predicted = lastAvg; + const margin = zScore * se * Math.sqrt(i + 1); + + forecasts.push({ + period: `period-${historical.length + i}`, + predictedRevenue: Math.max(0, predicted), + lowerBound: Math.max(0, predicted - margin), + upperBound: predicted + margin, + confidence, + model: 'moving_average', + }); + } + + return forecasts; +} + +// ── Trend Analysis ─────────────────────────────────────────────────────────── + +/** + * Analyze revenue trend from historical data. + */ +export function analyzeTrend(data: RevenueDataPoint[]): TrendAnalysis { + if (data.length < 2) { + return { + direction: 'stable', + strength: 0, + growthRate: 0, + hasSeasonality: false, + movingAverage: [], + trendLine: [], + }; + } + + const revenues = data.map((d) => d.revenue); + const n = revenues.length; + + // Simple linear trend + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (let i = 0; i < n; i++) { + sumX += i; + sumY += revenues[i]; + sumXY += i * revenues[i]; + sumX2 += i * i; + } + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + // Calculate growth rate + const firstRevenue = revenues[0]; + const lastRevenue = revenues[n - 1]; + const growthRate = firstRevenue > 0 + ? ((lastRevenue - firstRevenue) / firstRevenue) * 100 + : 0; + + // Determine direction + const direction: TrendDirection = slope > 0.01 * firstRevenue + ? 'up' + : slope < -0.01 * firstRevenue + ? 'down' + : 'stable'; + + // Calculate trend strength (R²) + const mean = sumY / n; + const ssTotal = revenues.reduce((sum, y) => sum + Math.pow(y - mean, 2), 0); + const ssResidual = revenues.reduce((sum, y, i) => { + const predicted = intercept + slope * i; + return sum + Math.pow(y - predicted, 2); + }, 0); + const strength = ssTotal > 0 ? 1 - ssResidual / ssTotal : 0; + + // Simple seasonality detection (check for periodic patterns) + const hasSeasonality = detectSeasonality(revenues); + + // Calculate moving average + const windowSize = Math.min(3, n); + const movingAverage: number[] = []; + for (let i = windowSize - 1; i < n; i++) { + const window = revenues.slice(i - windowSize + 1, i + 1); + movingAverage.push(window.reduce((a, b) => a + b, 0) / windowSize); + } + + // Calculate trend line + const trendLine = revenues.map((_, i) => intercept + slope * i); + + return { + direction, + strength: Math.max(0, Math.min(1, strength)), + growthRate, + hasSeasonality, + movingAverage, + trendLine, + }; +} + +/** + * Simple seasonality detection. + */ +function detectSeasonality(data: number[]): boolean { + if (data.length < 8) return false; + + // Check for 7-period (weekly) and 12-period (monthly) patterns + const periods = [7, 12, 4]; + + for (const period of periods) { + if (data.length < period * 2) continue; + + const correlations: number[] = []; + for (let lag = 1; lag <= period; lag++) { + let sum = 0; + let count = 0; + for (let i = lag; i < data.length; i++) { + sum += data[i] * data[i - lag]; + count++; + } + correlations.push(count > 0 ? sum / count : 0); + } + + // Check if any lag shows strong correlation + const maxCorr = Math.max(...correlations); + const avgCorr = correlations.reduce((a, b) => a + b, 0) / correlations.length; + + if (maxCorr > avgCorr * 1.5 && maxCorr > 0) { + return true; + } + } + + return false; +} + +// ── Forecast Accuracy ──────────────────────────────────────────────────────── + +/** + * Calculate forecast accuracy metrics. + */ +export function calculateAccuracy( + forecasts: ForecastPoint[], + actuals: RevenueDataPoint[] +): ForecastAccuracy { + const comparisons: ForecastAccuracy['comparisons'] = []; + let sumAbsError = 0; + let sumAbsPercentError = 0; + let sumSquaredError = 0; + + for (let i = 0; i < Math.min(forecasts.length, actuals.length); i++) { + const predicted = forecasts[i].predictedRevenue; + const actual = actuals[i].revenue; + const error = predicted - actual; + const errorPercentage = actual > 0 ? Math.abs(error / actual) * 100 : 0; + + comparisons.push({ + period: forecasts[i].period, + predicted, + actual, + error, + errorPercentage, + }); + + sumAbsError += Math.abs(error); + sumAbsPercentError += errorPercentage; + sumSquaredError += error * error; + } + + const n = comparisons.length; + const mae = n > 0 ? sumAbsError / n : 0; + const mape = n > 0 ? sumAbsPercentError / n : 0; + const rmse = n > 0 ? Math.sqrt(sumSquaredError / n) : 0; + + // R-squared + const actualMean = actuals.slice(0, n).reduce((sum, a) => sum + a.revenue, 0) / n; + const ssTotal = actuals.slice(0, n).reduce((sum, a) => sum + Math.pow(a.revenue - actualMean, 2), 0); + const rSquared = ssTotal > 0 ? 1 - sumSquaredError / ssTotal : 0; + + return { + mae, + mape, + rmse, + rSquared: Math.max(0, Math.min(1, rSquared)), + comparisons, + }; +} + +// ── Visualization Data ─────────────────────────────────────────────────────── + +/** + * Generate visualization data combining historical and forecast. + */ +export function generateVisualizationData( + historical: RevenueDataPoint[], + forecasts: ForecastPoint[], + confidence: number = 0.95 +): ForecastVisualizationData { + const historicalSeries = historical.map((d) => ({ + period: d.period, + historical: d.revenue, + forecast: null, + lowerBound: null, + upperBound: null, + isForecast: false, + })); + + const forecastSeries = forecasts.map((f) => ({ + period: f.period, + historical: null, + forecast: f.predictedRevenue, + lowerBound: f.lowerBound, + upperBound: f.upperBound, + isForecast: true, + })); + + const currentMrr = historical.length > 0 ? historical[historical.length - 1].mrr : 0; + const projectedMrr = forecasts.length > 0 ? forecasts[0].predictedRevenue : 0; + + const growthRate = currentMrr > 0 + ? ((projectedMrr - currentMrr) / currentMrr) * 100 + : 0; + + return { + series: [...historicalSeries, ...forecastSeries], + summary: { + currentMrr, + projectedMrr, + growthRate, + confidenceLevel: confidence, + }, + }; +} + +// ── Main Forecast Function ─────────────────────────────────────────────────── + +/** + * Generate revenue forecast from historical data. + */ +export function generateRevenueForecast( + historicalData: RevenueDataPoint[], + options: { + horizon?: number; + granularity?: ForecastGranularity; + model?: ForecastModel; + confidence?: number; + } = {} +): RevenueForecastResult { + const { + horizon = 6, + granularity = 'month', + model = 'linear', + confidence = 0.95, + } = options; + + const revenues = historicalData.map((d) => d.revenue); + + // Select and run forecast model + let forecasts: ForecastPoint[]; + switch (model) { + case 'exponential': + forecasts = exponentialForecast(revenues, horizon, 0.3, confidence); + break; + case 'moving_average': + forecasts = movingAverageForecast(revenues, horizon, 3, confidence); + break; + case 'linear': + default: + forecasts = linearForecast(revenues, horizon, confidence); + break; + } + + // Generate period labels based on granularity + const lastPeriod = historicalData.length > 0 + ? new Date(historicalData[historicalData.length - 1].period) + : new Date(); + + forecasts = forecasts.map((f, i) => { + const periodDate = new Date(lastPeriod); + switch (granularity) { + case 'hour': + periodDate.setHours(periodDate.getHours() + i + 1); + break; + case 'day': + periodDate.setDate(periodDate.getDate() + i + 1); + break; + case 'week': + periodDate.setDate(periodDate.getDate() + (i + 1) * 7); + break; + case 'month': + default: + periodDate.setMonth(periodDate.getMonth() + i + 1); + break; + } + return { ...f, period: periodDate.toISOString() }; + }); + + // Analyze trend + const trend = analyzeTrend(historicalData); + + return { + forecasts, + trend, + historicalData, + model, + granularity, + generatedAt: new Date().toISOString(), + }; +} + +// ── Alert Generation ───────────────────────────────────────────────────────── + +export interface ForecastAlert { + id: string; + type: 'revenue_decline' | 'growth_spike' | 'forecast_deviation' | 'seasonal_pattern'; + severity: 'info' | 'warning' | 'critical'; + title: string; + message: string; + threshold: number; + actualValue: number; + timestamp: string; +} + +/** + * Generate alerts based on forecast analysis. + */ +export function generateForecastAlerts( + forecast: RevenueForecastResult, + thresholds: { + declinePercent?: number; + growthPercent?: number; + deviationPercent?: number; + } = {} +): ForecastAlert[] { + const { + declinePercent = -10, + growthPercent = 20, + deviationPercent = 15, + } = thresholds; + + const alerts: ForecastAlert[] = []; + + // Check for revenue decline trend + if (forecast.trend.direction === 'down' && forecast.trend.growthRate < declinePercent) { + alerts.push({ + id: `alert-${Date.now()}-decline`, + type: 'revenue_decline', + severity: 'critical', + title: 'Revenue Decline Detected', + message: `Revenue is declining at ${forecast.trend.growthRate.toFixed(1)}% per period`, + threshold: declinePercent, + actualValue: forecast.trend.growthRate, + timestamp: new Date().toISOString(), + }); + } + + // Check for growth spike + if (forecast.trend.direction === 'up' && forecast.trend.growthRate > growthPercent) { + alerts.push({ + id: `alert-${Date.now()}-growth`, + type: 'growth_spike', + severity: 'info', + title: 'Strong Growth Detected', + message: `Revenue is growing at ${forecast.trend.growthRate.toFixed(1)}% per period`, + threshold: growthPercent, + actualValue: forecast.trend.growthRate, + timestamp: new Date().toISOString(), + }); + } + + // Check forecast confidence + if (forecast.forecasts.length > 0) { + const avgWidth = forecast.forecasts.reduce( + (sum, f) => sum + (f.upperBound - f.lowerBound), + 0 + ) / forecast.forecasts.length; + const avgPrediction = forecast.forecasts.reduce( + (sum, f) => sum + f.predictedRevenue, + 0 + ) / forecast.forecasts.length; + + const relativeWidth = avgPrediction > 0 ? (avgWidth / avgPrediction) * 100 : 0; + + if (relativeWidth > deviationPercent) { + alerts.push({ + id: `alert-${Date.now()}-deviation`, + type: 'forecast_deviation', + severity: 'warning', + title: 'High Forecast Uncertainty', + message: `Confidence interval width is ${relativeWidth.toFixed(1)}% of predicted value`, + threshold: deviationPercent, + actualValue: relativeWidth, + timestamp: new Date().toISOString(), + }); + } + } + + // Seasonality alert + if (forecast.trend.hasSeasonality) { + alerts.push({ + id: `alert-${Date.now()}-seasonal`, + type: 'seasonal_pattern', + severity: 'info', + title: 'Seasonal Pattern Detected', + message: 'Revenue shows seasonal patterns. Consider using seasonal forecasting model.', + threshold: 0, + actualValue: 0, + timestamp: new Date().toISOString(), + }); + } + + return alerts; +} diff --git a/backend/services/payment/domain/fallbackChainService.ts b/backend/services/payment/domain/fallbackChainService.ts new file mode 100644 index 00000000..90877c65 --- /dev/null +++ b/backend/services/payment/domain/fallbackChainService.ts @@ -0,0 +1,555 @@ +/** + * Issue #773 – Subscription Payment Method Fallback Chains + * + * Provides: + * - Fallback chain configuration per merchant + * - Automatic fallback on gateway failure + * - Fallback analytics and history + * - Fallback notifications + * - Fallback API + */ + +import { logger } from '../../shared/logging'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type GatewayName = 'stripe' | 'circle' | 'stellar'; + +export type FallbackStatus = 'success' | 'failed' | 'timeout' | 'skipped'; + +export interface FallbackChainEntry { + /** Gateway identifier */ + gateway: GatewayName; + /** Priority (lower = tried first) */ + priority: number; + /** Whether this entry is enabled */ + enabled: boolean; + /** Timeout in milliseconds for this gateway */ + timeoutMs: number; +} + +export interface FallbackChain { + /** Unique chain identifier */ + id: string; + /** Merchant this chain belongs to */ + merchantId: string; + /** Ordered list of gateways to try */ + chain: FallbackChainEntry[]; + /** Number of retry attempts per gateway */ + retryAttempts: number; + /** Delay between retries in milliseconds */ + retryDelayMs: number; + /** Whether this chain is active */ + active: boolean; + /** ISO timestamp of creation */ + createdAt: string; + /** ISO timestamp of last update */ + updatedAt: string; +} + +export interface FallbackAttempt { + /** Unique attempt identifier */ + id: string; + /** Chain identifier */ + chainId: string; + /** Merchant identifier */ + merchantId: string; + /** Gateway that was attempted */ + gateway: GatewayName; + /** Priority of this gateway in the chain */ + priority: number; + /** Attempt status */ + status: FallbackStatus; + /** Error message if failed */ + error?: string; + /** Duration of the attempt in milliseconds */ + durationMs: number; + /** Payment amount */ + amount: number; + /** Payment currency */ + currency: string; + /** Customer identifier */ + customerId: string; + /** ISO timestamp of attempt */ + timestamp: string; + /** Whether this was a retry attempt */ + isRetry: boolean; + /** Retry attempt number (0 = initial) */ + retryNumber: number; +} + +export interface FallbackResult { + /** Whether the payment succeeded */ + success: boolean; + /** Gateway that succeeded (if any) */ + successfulGateway?: GatewayName; + /** All attempts made */ + attempts: FallbackAttempt[]; + /** Total duration of all attempts */ + totalDurationMs: number; + /** Final error if all gateways failed */ + error?: string; +} + +export interface FallbackAnalytics { + /** Total fallback attempts */ + totalAttempts: number; + /** Successful attempts */ + successfulAttempts: number; + /** Failed attempts */ + failedAttempts: number; + /** Timeout attempts */ + timeoutAttempts: number; + /** Success rate by gateway */ + successRateByGateway: Record; + /** Average attempts per payment */ + averageAttemptsPerPayment: number; + /** Average total fallback duration */ + averageTotalDurationMs: number; + /** Most common failure reasons */ + commonFailureReasons: Array<{ reason: string; count: number }>; + /** Fallback rate (percentage of payments that needed fallback) */ + fallbackRate: number; + /** Period start */ + periodStart: string; + /** Period end */ + periodEnd: string; +} + +export interface FallbackNotification { + /** Notification identifier */ + id: string; + /** Merchant identifier */ + merchantId: string; + /** Notification type */ + type: 'fallback_triggered' | 'fallback_succeeded' | 'fallback_failed' | 'chain_disabled'; + /** Notification title */ + title: string; + /** Notification message */ + message: string; + /** Related payment attempt IDs */ + attemptIds: string[]; + /** ISO timestamp */ + timestamp: string; + /** Whether notification has been sent */ + sent: boolean; +} + +// ── Store (in-memory; replace with DB in production) ───────────────────────── + +const chains = new Map(); +const attempts: FallbackAttempt[] = []; +const notifications: FallbackNotification[] = []; + +// ── Chain Management ───────────────────────────────────────────────────────── + +/** + * Create or update a fallback chain for a merchant. + */ +export function upsertFallbackChain( + merchantId: string, + config: Omit +): FallbackChain { + const existing = Array.from(chains.values()).find((c) => c.merchantId === merchantId); + + const now = new Date().toISOString(); + const chain: FallbackChain = { + id: existing?.id ?? `chain-${merchantId}-${Date.now()}`, + merchantId, + ...config, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + chains.set(chain.id, chain); + return chain; +} + +/** + * Get the fallback chain for a merchant. + */ +export function getFallbackChain(merchantId: string): FallbackChain | undefined { + return Array.from(chains.values()).find((c) => c.merchantId === merchantId && c.active); +} + +/** + * Get all fallback chains. + */ +export function getAllFallbackChains(): FallbackChain[] { + return Array.from(chains.values()); +} + +/** + * Delete a fallback chain. + */ +export function deleteFallbackChain(merchantId: string): boolean { + const chain = Array.from(chains.values()).find((c) => c.merchantId === merchantId); + if (!chain) return false; + chains.delete(chain.id); + return true; +} + +/** + * Disable a fallback chain. + */ +export function disableFallbackChain(merchantId: string): boolean { + const chain = Array.from(chains.values()).find((c) => c.merchantId === merchantId); + if (!chain) return false; + chain.active = false; + chain.updatedAt = new Date().toISOString(); + return true; +} + +// ── Gateway Execution ──────────────────────────────────────────────────────── + +type GatewayExecutor = ( + request: { amount: number; currency: string; customerId: string; paymentMethodId: string } +) => Promise<{ success: boolean; error?: string; gatewayUsed?: string }>; + +const gatewayExecutors = new Map(); + +/** + * Register a gateway executor function. + */ +export function registerGatewayExecutor( + gateway: GatewayName, + executor: GatewayExecutor +): void { + gatewayExecutors.set(gateway, executor); +} + +// ── Fallback Execution ─────────────────────────────────────────────────────── + +/** + * Execute a payment with automatic fallback through the chain. + */ +export async function executeWithFallback( + merchantId: string, + request: { + amount: number; + currency: string; + customerId: string; + paymentMethodId: string; + } +): Promise { + const chain = getFallbackChain(merchantId); + + // Default chain if none configured + const entries: FallbackChainEntry[] = chain?.chain ?? [ + { gateway: 'stripe', priority: 0, enabled: true, timeoutMs: 5000 }, + { gateway: 'circle', priority: 1, enabled: true, timeoutMs: 5000 }, + { gateway: 'stellar', priority: 2, enabled: true, timeoutMs: 5000 }, + ]; + + const retryAttempts = chain?.retryAttempts ?? 1; + const retryDelayMs = chain?.retryDelayMs ?? 1000; + + const sortedEntries = [...entries] + .filter((e) => e.enabled) + .sort((a, b) => a.priority - b.priority); + + const allAttempts: FallbackAttempt[] = []; + const startTime = Date.now(); + + for (const entry of sortedEntries) { + const executor = gatewayExecutors.get(entry.gateway); + if (!executor) { + logger.warn('No executor registered for gateway', { gateway: entry.gateway }); + continue; + } + + for (let retry = 0; retry <= retryAttempts; retry++) { + const attemptStart = Date.now(); + const attemptId = `attempt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const isRetry = retry > 0; + + try { + // Add timeout wrapper + const result = await Promise.race([ + executor(request), + new Promise<{ success: false; error: string }>((resolve) => + setTimeout( + () => resolve({ success: false, error: `Gateway timeout after ${entry.timeoutMs}ms` }), + entry.timeoutMs + ) + ), + ]); + + const durationMs = Date.now() - attemptStart; + const status: FallbackStatus = result.success ? 'success' : 'failed'; + + const attempt: FallbackAttempt = { + id: attemptId, + chainId: chain?.id ?? 'default', + merchantId, + gateway: entry.gateway, + priority: entry.priority, + status, + error: result.error, + durationMs, + amount: request.amount, + currency: request.currency, + customerId: request.customerId, + timestamp: new Date().toISOString(), + isRetry, + retryNumber: retry, + }; + + allAttempts.push(attempt); + attempts.push(attempt); + + if (result.success) { + // Notify success + if (isRetry) { + createNotification(merchantId, 'fallback_succeeded', `Payment succeeded on ${entry.gateway} after ${retry} retries`, [attemptId]); + } + + return { + success: true, + successfulGateway: entry.gateway, + attempts: allAttempts, + totalDurationMs: Date.now() - startTime, + }; + } + + // Log failure + logger.warn('Gateway failed in fallback chain', { + gateway: entry.gateway, + retry, + error: result.error, + }); + + } catch (err) { + const durationMs = Date.now() - attemptStart; + const error = err instanceof Error ? err.message : String(err); + + const attempt: FallbackAttempt = { + id: attemptId, + chainId: chain?.id ?? 'default', + merchantId, + gateway: entry.gateway, + priority: entry.priority, + status: error.includes('timeout') ? 'timeout' : 'failed', + error, + durationMs, + amount: request.amount, + currency: request.currency, + customerId: request.customerId, + timestamp: new Date().toISOString(), + isRetry, + retryNumber: retry, + }; + + allAttempts.push(attempt); + attempts.push(attempt); + + logger.warn('Gateway error in fallback chain', { + gateway: entry.gateway, + retry, + error, + }); + } + + // Delay before retry + if (retry < retryAttempts) { + await new Promise((r) => setTimeout(r, retryDelayMs)); + } + } + } + + // All gateways failed + const error = `All gateways failed: ${allAttempts.map((a) => `${a.gateway}: ${a.error}`).join('; ')}`; + + createNotification( + merchantId, + 'fallback_failed', + `Payment failed after trying ${allAttempts.length} gateway attempts`, + allAttempts.map((a) => a.id) + ); + + return { + success: false, + attempts: allAttempts, + totalDurationMs: Date.now() - startTime, + error, + }; +} + +// ── Notifications ──────────────────────────────────────────────────────────── + +function createNotification( + merchantId: string, + type: FallbackNotification['type'], + message: string, + attemptIds: string[] +): FallbackNotification { + const titles: Record = { + fallback_triggered: 'Payment Fallback Triggered', + fallback_succeeded: 'Fallback Payment Succeeded', + fallback_failed: 'All Payment Gateways Failed', + chain_disabled: 'Fallback Chain Disabled', + }; + + const notification: FallbackNotification = { + id: `notif-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + merchantId, + type, + title: titles[type], + message, + attemptIds, + timestamp: new Date().toISOString(), + sent: false, + }; + + notifications.push(notification); + return notification; +} + +/** + * Get notifications for a merchant. + */ +export function getNotifications( + merchantId: string, + options: { limit?: number; unsentOnly?: boolean } = {} +): FallbackNotification[] { + const { limit = 50, unsentOnly = false } = options; + + let filtered = notifications.filter((n) => n.merchantId === merchantId); + if (unsentOnly) filtered = filtered.filter((n) => !n.sent); + + return filtered.slice(-limit); +} + +/** + * Mark a notification as sent. + */ +export function markNotificationSent(notificationId: string): boolean { + const notification = notifications.find((n) => n.id === notificationId); + if (!notification) return false; + notification.sent = true; + return true; +} + +// ── History ────────────────────────────────────────────────────────────────── + +/** + * Get fallback attempt history. + */ +export function getFallbackHistory(options: { + merchantId?: string; + gateway?: GatewayName; + status?: FallbackStatus; + limit?: number; + offset?: number; + startDate?: string; + endDate?: string; +} = {}): FallbackAttempt[] { + const { merchantId, gateway, status, limit = 100, offset = 0, startDate, endDate } = options; + + let filtered = [...attempts]; + + if (merchantId) filtered = filtered.filter((a) => a.merchantId === merchantId); + if (gateway) filtered = filtered.filter((a) => a.gateway === gateway); + if (status) filtered = filtered.filter((a) => a.status === status); + if (startDate) filtered = filtered.filter((a) => a.timestamp >= startDate); + if (endDate) filtered = filtered.filter((a) => a.timestamp <= endDate); + + return filtered.slice(offset, offset + limit); +} + +// ── Analytics ──────────────────────────────────────────────────────────────── + +/** + * Get fallback analytics for a time period. + */ +export function getFallbackAnalytics(options: { + startDate?: string; + endDate?: string; + merchantId?: string; +} = {}): FallbackAnalytics { + const { startDate, endDate, merchantId } = options; + + let filtered = [...attempts]; + + if (merchantId) filtered = filtered.filter((a) => a.merchantId === merchantId); + if (startDate) filtered = filtered.filter((a) => a.timestamp >= startDate); + if (endDate) filtered = filtered.filter((a) => a.timestamp <= endDate); + + const totalAttempts = filtered.length; + const successfulAttempts = filtered.filter((a) => a.status === 'success').length; + const failedAttempts = filtered.filter((a) => a.status === 'failed').length; + const timeoutAttempts = filtered.filter((a) => a.status === 'timeout').length; + + // Success rate by gateway + const successRateByGateway: Record = { + stripe: 0, + circle: 0, + stellar: 0, + }; + + for (const gateway of Object.keys(successRateByGateway) as GatewayName[]) { + const gatewayAttempts = filtered.filter((a) => a.gateway === gateway); + const gatewaySuccess = gatewayAttempts.filter((a) => a.status === 'success').length; + successRateByGateway[gateway] = gatewayAttempts.length > 0 + ? gatewaySuccess / gatewayAttempts.length + : 0; + } + + // Average attempts per unique payment (group by customerId + timestamp) + const uniquePayments = new Set(filtered.map((a) => `${a.customerId}-${a.amount}-${a.currency}`)); + const averageAttemptsPerPayment = uniquePayments.size > 0 + ? totalAttempts / uniquePayments.size + : 0; + + // Average total duration + const totalDurations = new Map(); + for (const a of filtered) { + const key = `${a.customerId}-${a.amount}-${a.currency}`; + totalDurations.set(key, (totalDurations.get(key) ?? 0) + a.durationMs); + } + const averageTotalDurationMs = totalDurations.size > 0 + ? Array.from(totalDurations.values()).reduce((a, b) => a + b, 0) / totalDurations.size + : 0; + + // Common failure reasons + const failureReasons = new Map(); + for (const a of filtered.filter((a) => a.status !== 'success' && a.error)) { + const reason = a.error!.slice(0, 100); + failureReasons.set(reason, (failureReasons.get(reason) ?? 0) + 1); + } + const commonFailureReasons = Array.from(failureReasons.entries()) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Fallback rate + const paymentsNeedingFallback = uniquePayments.size; + const totalPayments = new Set( + filtered.filter((a) => a.status === 'success').map((a) => `${a.customerId}-${a.amount}-${a.currency}`) + ).size; + const fallbackRate = totalPayments > 0 + ? (paymentsNeedingFallback - totalPayments) / paymentsNeedingFallback + : 0; + + return { + totalAttempts, + successfulAttempts, + failedAttempts, + timeoutAttempts, + successRateByGateway, + averageAttemptsPerPayment, + averageTotalDurationMs, + commonFailureReasons, + fallbackRate, + periodStart: filtered.length > 0 ? filtered[0].timestamp : new Date().toISOString(), + periodEnd: filtered.length > 0 ? filtered[filtered.length - 1].timestamp : new Date().toISOString(), + }; +} + +/** + * Reset analytics (for testing). + */ +export function resetFallbackAnalytics(): void { + attempts.length = 0; + notifications.length = 0; +} diff --git a/backend/services/payment/index.ts b/backend/services/payment/index.ts index a16d1228..2787d740 100644 --- a/backend/services/payment/index.ts +++ b/backend/services/payment/index.ts @@ -6,3 +6,27 @@ export { BasePaymentGateway } from './domain/gateways/PaymentGateway'; export { GatewayConfigController, gatewayConfigController } from './controller/gatewayConfigController'; export type { IPaymentGateway, IPaymentRouter, PaymentRequest, PaymentResult, RefundRequest, RefundResult, CustomerResult, PaymentMethodResult, PayoutRequest, PayoutResult, GatewayConfig } from './interfaces'; export { PaymentError, PaymentErrorCode } from './errors'; +export { + upsertFallbackChain, + getFallbackChain, + getAllFallbackChains, + deleteFallbackChain, + disableFallbackChain, + registerGatewayExecutor, + executeWithFallback, + getNotifications as getFallbackNotifications, + markNotificationSent as markFallbackNotificationSent, + getFallbackHistory, + getFallbackAnalytics, + resetFallbackAnalytics, +} from './domain/fallbackChainService'; +export type { + FallbackChain, + FallbackChainEntry, + FallbackAttempt, + FallbackResult, + FallbackAnalytics, + FallbackNotification, + GatewayName, + FallbackStatus, +} from './domain/fallbackChainService'; diff --git a/backend/services/shared/corsMiddleware.ts b/backend/services/shared/corsMiddleware.ts new file mode 100644 index 00000000..e525ec47 --- /dev/null +++ b/backend/services/shared/corsMiddleware.ts @@ -0,0 +1,467 @@ +/** + * Issue #772 – CORS Policy Management with Dynamic Origin Whitelisting + * + * Provides: + * - Dynamic CORS origin configuration + * - Per-tenant whitelisting + * - Preflight request caching + * - CORS violation logging + * - CORS analytics + * - Origin validation with wildcards + * - CORS policy testing + */ + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface CorsOrigin { + /** Origin URL or pattern (supports wildcards like *.example.com) */ + origin: string; + /** Whether this is a wildcard pattern */ + isWildcard: boolean; +} + +export interface CorsPolicy { + /** Unique policy identifier */ + id: string; + /** Tenant this policy belongs to */ + tenantId: string; + /** Allowed origins */ + allowedOrigins: CorsOrigin[]; + /** Whether to include Access-Control-Allow-Credentials */ + allowCredentials: boolean; + /** Headers to expose to the browser */ + exposedHeaders: string[]; + /** Max age in seconds for preflight cache */ + maxAge: number; + /** Allowed HTTP methods */ + allowMethods: string[]; + /** Allowed request headers */ + allowHeaders: string[]; + /** Whether this policy is active */ + active: boolean; + /** ISO timestamp of creation */ + createdAt: string; + /** ISO timestamp of last update */ + updatedAt: string; +} + +export interface CorsViolation { + /** Request ID */ + requestId: string; + /** Origin that was blocked */ + origin: string; + /** Requested method */ + method: string; + /** Requested headers */ + requestedHeaders: string[]; + /** Tenant ID */ + tenantId: string; + /** ISO timestamp */ + timestamp: string; + /** Request path */ + path: string; + /** User agent */ + userAgent: string; + /** IP address */ + ip: string; +} + +export interface CorsAnalytics { + /** Total requests processed */ + totalRequests: number; + /** Successful CORS requests */ + allowedRequests: number; + /** Blocked CORS requests (violations) */ + blockedRequests: number; + /** Number of unique origins seen */ + uniqueOrigins: number; + /** Violations by origin */ + violationsByOrigin: Record; + /** Violations by tenant */ + violationsByTenant: Record; + /** Requests by method */ + requestsByMethod: Record; + /** Preflight cache hit rate */ + preflightCacheHitRate: number; + /** Timestamp of analytics snapshot */ + timestamp: string; +} + +export interface CorsTestResult { + /** Whether the origin would be allowed */ + allowed: boolean; + /** Matched policy */ + policyId: string | null; + /** Matched origin pattern */ + matchedPattern: string | null; + /** Reason for denial if blocked */ + reason: string | null; +} + +// ── Policy Store (in-memory; replace with DB in production) ────────────────── + +const policies = new Map(); +const violations: CorsViolation[] = []; +const preflightCache = new Map(); + +// Analytics counters +let totalRequests = 0; +let allowedRequests = 0; +let blockedRequests = 0; +let preflightCacheHits = 0; +let preflightCacheMisses = 0; +const requestsByMethod: Record = {}; + +// ── Wildcard Matching ──────────────────────────────────────────────────────── + +function matchWildcard(origin: string, pattern: string): boolean { + if (!pattern.includes('*')) { + return origin === pattern; + } + + // Convert wildcard pattern to regex + const regexStr = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*'); + const regex = new RegExp(`^${regexStr}$`, 'i'); + return regex.test(origin); +} + +function parseOrigin(originStr: string): CorsOrigin { + const isWildcard = originStr.includes('*'); + return { origin: originStr, isWildcard }; +} + +// ── Core CORS Logic ────────────────────────────────────────────────────────── + +function findPolicyForRequest( + origin: string | undefined, + tenantId?: string +): CorsPolicy | undefined { + if (!origin) return undefined; + + for (const policy of policies.values()) { + if (!policy.active) continue; + if (tenantId && policy.tenantId !== tenantId) continue; + + for (const allowed of policy.allowedOrigins) { + if (matchWildcard(origin, allowed.origin)) { + return policy; + } + } + } + + return undefined; +} + +export interface CorsHeadersResult { + 'Access-Control-Allow-Origin': string | null; + 'Access-Control-Allow-Methods': string | null; + 'Access-Control-Allow-Headers': string | null; + 'Access-Control-Allow-Credentials': string | null; + 'Access-Control-Expose-Headers': string | null; + 'Access-Control-Max-Age': string | null; + 'Vary': string | null; +} + +function buildCorsHeaders( + origin: string | undefined, + method: string | undefined, + requestHeaders: string | undefined, + policy: CorsPolicy | undefined +): CorsHeadersResult { + const headers: CorsHeadersResult = { + 'Access-Control-Allow-Origin': null, + 'Access-Control-Allow-Methods': null, + 'Access-Control-Allow-Headers': null, + 'Access-Control-Allow-Credentials': null, + 'Access-Control-Expose-Headers': null, + 'Access-Control-Max-Age': null, + 'Vary': null, + }; + + if (!policy || !origin) return headers; + + headers['Access-Control-Allow-Origin'] = origin; + headers['Vary'] = 'Origin'; + + if (policy.allowCredentials) { + headers['Access-Control-Allow-Credentials'] = 'true'; + } + + if (policy.exposedHeaders.length > 0) { + headers['Access-Control-Expose-Headers'] = policy.exposedHeaders.join(', '); + } + + if (method === 'OPTIONS') { + // Preflight response + headers['Access-Control-Allow-Methods'] = policy.allowMethods.join(', '); + headers['Access-Control-Allow-Headers'] = policy.allowHeaders.join(', '); + headers['Access-Control-Max-Age'] = String(policy.maxAge); + } + + return headers; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Create or update a CORS policy for a tenant. + */ +export function upsertPolicy( + tenantId: string, + config: Omit +): CorsPolicy { + const existing = Array.from(policies.values()).find((p) => p.tenantId === tenantId); + + const now = new Date().toISOString(); + const policy: CorsPolicy = { + id: existing?.id ?? `cors-${tenantId}-${Date.now()}`, + tenantId, + ...config, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + policies.set(policy.id, policy); + return policy; +} + +/** + * Get the CORS policy for a tenant. + */ +export function getPolicy(tenantId: string): CorsPolicy | undefined { + return Array.from(policies.values()).find((p) => p.tenantId === tenantId && p.active); +} + +/** + * Get all CORS policies. + */ +export function getAllPolicies(): CorsPolicy[] { + return Array.from(policies.values()); +} + +/** + * Delete a CORS policy. + */ +export function deletePolicy(tenantId: string): boolean { + const policy = Array.from(policies.values()).find((p) => p.tenantId === tenantId); + if (!policy) return false; + policies.delete(policy.id); + return true; +} + +/** + * Test whether an origin would be allowed for a tenant. + */ +export function testOrigin(origin: string, tenantId?: string): CorsTestResult { + const policy = findPolicyForRequest(origin, tenantId); + + if (!policy) { + return { + allowed: false, + policyId: null, + matchedPattern: null, + reason: 'No matching CORS policy found', + }; + } + + const matched = policy.allowedOrigins.find((o) => matchWildcard(origin, o.origin)); + + return { + allowed: true, + policyId: policy.id, + matchedPattern: matched?.origin ?? null, + reason: null, + }; +} + +/** + * Process a CORS request and return the appropriate headers. + * Handles both preflight (OPTIONS) and simple requests. + */ +export function processCorsRequest(options: { + origin?: string; + method?: string; + requestHeaders?: string; + tenantId?: string; +}): { headers: CorsHeadersResult; allowed: boolean } { + const { origin, method, requestHeaders, tenantId } = options; + + totalRequests++; + requestsByMethod[method ?? 'UNKNOWN'] = (requestsByMethod[method ?? 'UNKNOWN'] ?? 0) + 1; + + // Check preflight cache + if (method === 'OPTIONS' && origin) { + const cacheKey = `${origin}:${tenantId ?? 'default'}`; + const cached = preflightCache.get(cacheKey); + + if (cached && cached.expiresAt > Date.now()) { + preflightCacheHits++; + return { headers: cached.response, allowed: true }; + } + preflightCacheMisses++; + } + + const policy = findPolicyForRequest(origin, tenantId); + const headers = buildCorsHeaders(origin, method, requestHeaders, policy); + + const allowed = headers['Access-Control-Allow-Origin'] !== null; + + if (allowed) { + allowedRequests++; + } else if (origin) { + blockedRequests++; + recordViolation({ + requestId: `req-${Date.now()}`, + origin, + method: method ?? 'UNKNOWN', + requestedHeaders: requestHeaders ? requestHeaders.split(',').map((h) => h.trim()) : [], + tenantId: tenantId ?? 'unknown', + timestamp: new Date().toISOString(), + path: '/', + userAgent: '', + ip: '', + }); + } + + // Cache preflight response + if (method === 'OPTIONS' && policy) { + const cacheKey = `${origin}:${tenantId ?? 'default'}`; + preflightCache.set(cacheKey, { + response: headers, + expiresAt: Date.now() + policy.maxAge * 1000, + }); + } + + return { headers, allowed }; +} + +/** + * Record a CORS violation for analytics. + */ +export function recordViolation(violation: CorsViolation): void { + violations.push(violation); + + // Keep last 10000 violations + if (violations.length > 10000) { + violations.splice(0, violations.length - 10000); + } +} + +/** + * Get CORS analytics snapshot. + */ +export function getCorsAnalytics(): CorsAnalytics { + const violationsByOrigin: Record = {}; + const violationsByTenant: Record = {}; + const uniqueOrigins = new Set(); + + for (const v of violations) { + violationsByOrigin[v.origin] = (violationsByOrigin[v.origin] ?? 0) + 1; + violationsByTenant[v.tenantId] = (violationsByTenant[v.tenantId] ?? 0) + 1; + uniqueOrigins.add(v.origin); + } + + const totalPreflight = preflightCacheHits + preflightCacheMisses; + + return { + totalRequests, + allowedRequests, + blockedRequests, + uniqueOrigins: uniqueOrigins.size, + violationsByOrigin, + violationsByTenant, + requestsByMethod, + preflightCacheHitRate: totalPreflight > 0 ? preflightCacheHits / totalPreflight : 0, + timestamp: new Date().toISOString(), + }; +} + +/** + * Get recent violations, optionally filtered. + */ +export function getViolations(options: { + tenantId?: string; + origin?: string; + limit?: number; + since?: string; +} = {}): CorsViolation[] { + const { tenantId, origin, limit = 100, since } = options; + let filtered = violations; + + if (tenantId) filtered = filtered.filter((v) => v.tenantId === tenantId); + if (origin) filtered = filtered.filter((v) => v.origin === origin); + if (since) filtered = filtered.filter((v) => v.timestamp >= since); + + return filtered.slice(-limit); +} + +/** + * Clear the preflight cache. + */ +export function clearPreflightCache(): void { + preflightCache.clear(); +} + +/** + * Express/Connect-style CORS middleware factory. + */ +export function createCorsMiddleware(defaultTenantId?: string) { + return function corsMiddleware( + req: { + headers?: Record; + method?: string; + url?: string; + tenantId?: string; + }, + res: { + setHeader(name: string, value: string | number | string[]): void; + }, + next: () => void + ): void { + const origin = typeof req.headers?.['origin'] === 'string' + ? req.headers['origin'] + : undefined; + const method = req.method; + const requestHeaders = typeof req.headers?.['access-control-request-headers'] === 'string' + ? req.headers['access-control-request-headers'] + : undefined; + const tenantId = req.tenantId ?? defaultTenantId; + + const { headers, allowed } = processCorsRequest({ + origin, + method, + requestHeaders, + tenantId, + }); + + // Apply CORS headers + for (const [name, value] of Object.entries(headers)) { + if (value !== null) { + res.setHeader(name, value); + } + } + + // Handle preflight + if (method === 'OPTIONS' && allowed) { + res.setHeader('Content-Length', '0'); + return; + } + + next(); + }; +} + +/** + * Reset analytics counters (for testing). + */ +export function resetAnalytics(): void { + totalRequests = 0; + allowedRequests = 0; + blockedRequests = 0; + preflightCacheHits = 0; + preflightCacheMisses = 0; + Object.keys(requestsByMethod).forEach((k) => delete requestsByMethod[k]); + violations.length = 0; +} diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index b94ed9d1..c15ecdb6 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -70,3 +70,70 @@ export { createUnifiedAuthMiddleware, } from './authStrategies'; export type { IAuthStrategy, AuthUser } from './authStrategies'; +export { + sanitizeXss, + detectSqlInjection, + validateRequest, + createValidationMiddleware, + validateFileUpload, + readBodyWithLimit, + getBodySizeLimit, + commonSchemas, + BODY_SIZE_LIMITS, + DEFAULT_FILE_CONFIG, +} from './validationMiddleware'; +export type { + ValidationSchemas, + ValidationOptions, + ValidationResult, + ValidationErrorDetail, + FileUploadConfig, + FileValidationResult, + BodySizeLimit, +} from './validationMiddleware'; +export { + createPlanSchema, + updatePlanSchema, + planQuerySchema, + createSubscriptionSchema, + updateSubscriptionSchema, + cancelSubscriptionSchema, + pauseSubscriptionSchema, + subscriptionQuerySchema, + paymentRequestSchema, + refundRequestSchema, + paymentQuerySchema, + createWebhookSchema, + createApiKeySchema, + analyticsQuerySchema, + forecastRequestSchema, + customerQuerySchema, + fallbackChainSchema, + fallbackQuerySchema, + corsPolicySchema, + idParamSchema, + subscriptionIdParamSchema, + planIdParamSchema, +} from './schemas'; +export { + upsertPolicy as upsertCorsPolicy, + getPolicy as getCorsPolicy, + getAllPolicies as getAllCorsPolicies, + deletePolicy as deleteCorsPolicy, + testOrigin as testCorsOrigin, + processCorsRequest, + recordViolation as recordCorsViolation, + getCorsAnalytics, + getViolations as getCorsViolations, + clearPreflightCache, + createCorsMiddleware, + resetAnalytics as resetCorsAnalytics, +} from './corsMiddleware'; +export type { + CorsPolicy, + CorsOrigin, + CorsViolation, + CorsAnalytics, + CorsTestResult, + CorsHeadersResult, +} from './corsMiddleware'; diff --git a/backend/services/shared/schemas.ts b/backend/services/shared/schemas.ts new file mode 100644 index 00000000..63ef873d --- /dev/null +++ b/backend/services/shared/schemas.ts @@ -0,0 +1,199 @@ +/** + * Issue #771 – Common Zod Validation Schemas + * + * Reusable Zod schemas for all request bodies, query parameters, + * and path parameters across the SubTrackr API. + */ + +import { z } from 'zod'; +import { commonSchemas } from './validationMiddleware'; + +// ── Plan Schemas ───────────────────────────────────────────────────────────── + +export const createPlanSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + amount: commonSchemas.amount, + currency: commonSchemas.currency, + interval: z.enum(['day', 'week', 'month', 'year']), + intervalCount: z.number().int().min(1).max(12).default(1), + trialPeriodDays: z.number().int().min(0).max(365).optional(), + metadata: commonSchemas.metadata, + active: z.boolean().default(true), +}); + +export const updatePlanSchema = createPlanSchema.partial(); + +export const planQuerySchema = commonSchemas.pagination.extend({ + active: z.coerce.boolean().optional(), + currency: z.string().length(3).optional(), + minAmount: z.coerce.number().optional(), + maxAmount: z.coerce.number().optional(), +}); + +// ── Subscription Schemas ───────────────────────────────────────────────────── + +export const createSubscriptionSchema = z.object({ + customerId: commonSchemas.ethereumAddress, + planId: commonSchemas.planId, + paymentMethodId: z.string().min(1), + trialEnd: commonSchemas.timestamp.optional(), + metadata: commonSchemas.metadata, +}); + +export const updateSubscriptionSchema = z.object({ + planId: commonSchemas.planId.optional(), + paymentMethodId: z.string().min(1).optional(), + cancelAtPeriodEnd: z.boolean().optional(), + metadata: commonSchemas.metadata, +}); + +export const cancelSubscriptionSchema = z.object({ + reason: z.string().max(500).optional(), + feedback: z.string().max(2000).optional(), + immediate: z.boolean().default(false), +}); + +export const pauseSubscriptionSchema = z.object({ + reason: z.string().max(500).optional(), + resumeAt: commonSchemas.timestamp.optional(), + pauseDurationDays: z.number().int().min(1).max(365).optional(), +}); + +export const subscriptionQuerySchema = commonSchemas.pagination.extend({ + status: z.enum(['active', 'paused', 'cancelled', 'past_due', 'trialing']).optional(), + customerId: commonSchemas.ethereumAddress.optional(), + planId: commonSchemas.planId.optional(), + sortBy: z.enum(['createdAt', 'updatedAt', 'currentPeriodEnd']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}); + +// ── Payment Schemas ────────────────────────────────────────────────────────── + +export const paymentRequestSchema = z.object({ + amount: commonSchemas.amount, + currency: commonSchemas.currency, + customerId: commonSchemas.ethereumAddress, + paymentMethodId: z.string().min(1), + idempotencyKey: z.string().min(1).max(255), + metadata: commonSchemas.metadata, + gatewayPreference: z.enum(['stripe', 'circle', 'stellar']).optional(), +}); + +export const refundRequestSchema = z.object({ + chargeId: z.string().min(1), + amount: commonSchemas.amount.optional(), + reason: z.string().max(500).optional(), + metadata: commonSchemas.metadata, +}); + +export const paymentQuerySchema = commonSchemas.pagination.extend({ + customerId: commonSchemas.ethereumAddress.optional(), + status: z.enum(['succeeded', 'failed', 'pending']).optional(), + startDate: commonSchemas.timestamp.optional(), + endDate: commonSchemas.timestamp.optional(), +}); + +// ── Webhook Schemas ────────────────────────────────────────────────────────── + +export const createWebhookSchema = z.object({ + url: z.string().url('Invalid webhook URL'), + events: z.array(z.string()).min(1, 'At least one event type required'), + secret: z.string().min(16).max(255).optional(), + active: z.boolean().default(true), + metadata: commonSchemas.metadata, +}); + +// ── API Key Schemas ────────────────────────────────────────────────────────── + +export const createApiKeySchema = z.object({ + name: z.string().min(1).max(100), + tier: z.enum(['free', 'basic', 'pro', 'enterprise']).default('free'), + rateLimit: z.number().int().min(1).max(100000).optional(), + expiresAt: commonSchemas.timestamp.optional(), + metadata: commonSchemas.metadata, +}); + +// ── Analytics Schemas ──────────────────────────────────────────────────────── + +export const analyticsQuerySchema = z.object({ + startDate: commonSchemas.timestamp.optional(), + endDate: commonSchemas.timestamp.optional(), + granularity: z.enum(['hour', 'day', 'week', 'month']).default('day'), + metrics: z.array(z.enum([ + 'revenue', + 'subscribers', + 'churn_rate', + 'mrr', + 'arr', + 'ltv', + 'conversion_rate', + ])).optional(), + groupBy: z.enum(['plan', 'currency', 'gateway', 'country']).optional(), +}); + +export const forecastRequestSchema = z.object({ + horizon: z.number().int().min(1).max(24).default(3), + granularity: z.enum(['day', 'week', 'month']).default('month'), + includeConfidence: z.boolean().default(true), + confidenceLevel: z.number().min(0.5).max(0.99).default(0.95), +}); + +// ── Customer Schemas ───────────────────────────────────────────────────────── + +export const customerQuerySchema = commonSchemas.pagination.extend({ + email: commonSchemas.email.optional(), + search: commonSchemas.searchQuery, + sortBy: z.enum(['createdAt', 'totalSpent', 'subscriptionCount']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}); + +// ── Fallback Chain Schemas ─────────────────────────────────────────────────── + +export const fallbackChainSchema = z.object({ + merchantId: z.string().min(1), + chain: z.array(z.object({ + gateway: z.enum(['stripe', 'circle', 'stellar']), + priority: z.number().int().min(0), + enabled: z.boolean().default(true), + timeoutMs: z.number().int().min(100).max(30000).default(5000), + })).min(2).max(5), + retryAttempts: z.number().int().min(0).max(5).default(1), + retryDelayMs: z.number().int().min(100).max(10000).default(1000), +}); + +export const fallbackQuerySchema = commonSchemas.pagination.extend({ + merchantId: z.string().optional(), + gateway: z.enum(['stripe', 'circle', 'stellar']).optional(), + status: z.enum(['success', 'failed', 'timeout']).optional(), + startDate: commonSchemas.timestamp.optional(), + endDate: commonSchemas.timestamp.optional(), +}); + +// ── CORS Schemas ───────────────────────────────────────────────────────────── + +export const corsPolicySchema = z.object({ + tenantId: z.string().min(1), + allowedOrigins: z.array(z.string().url()).min(1), + allowCredentials: z.boolean().default(false), + exposedHeaders: z.array(z.string()).optional(), + maxAge: z.number().int().min(0).max(86400).default(86400), + allowMethods: z.array(z.enum([ + 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', + ])).default(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + allowHeaders: z.array(z.string()).default(['Content-Type', 'Authorization']), +}); + +// ── Generic ID Param Schema ────────────────────────────────────────────────── + +export const idParamSchema = z.object({ + id: commonSchemas.uuid, +}); + +export const subscriptionIdParamSchema = z.object({ + subscriptionId: commonSchemas.subscriptionId, +}); + +export const planIdParamSchema = z.object({ + planId: commonSchemas.planId, +}); diff --git a/backend/services/shared/validationMiddleware.ts b/backend/services/shared/validationMiddleware.ts new file mode 100644 index 00000000..5ba8515a --- /dev/null +++ b/backend/services/shared/validationMiddleware.ts @@ -0,0 +1,389 @@ +/** + * Issue #771 – Input Validation with Zod Schemas and Sanitization + * + * Provides: + * - Zod schema validation for request bodies, query params, and path params + * - XSS sanitization for string inputs + * - SQL injection prevention patterns + * - File upload validation + * - Request body size limits + * - Standardized validation error formatting + */ + +import { z, type ZodSchema, type ZodError } from 'zod'; +import type { IncomingMessage } from 'http'; +import { ErrorCode } from './apiResponse'; + +// ── XSS Sanitization ───────────────────────────────────────────────────────── + +const HTML_TAG_REGEX = /<[^>]*>/g; +const JS_EVENT_REGEX = /\bon\w+\s*=/gi; +const SCRIPT_REGEX = /javascript\s*:/gi; +const DATA_URI_REGEX = /data\s*:[^,]*base64/gi; + +export function sanitizeXss(input: string): string { + if (typeof input !== 'string') return input; + + let sanitized = input; + sanitized = sanitized.replace(HTML_TAG_REGEX, ''); + sanitized = sanitized.replace(JS_EVENT_REGEX, ''); + sanitized = sanitized.replace(SCRIPT_REGEX, ''); + sanitized = sanitized.replace(DATA_URI_REGEX, ''); + return sanitized; +} + +// ── SQL Injection Prevention ────────────────────────────────────────────────── + +const SQL_INJECTION_PATTERNS = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE|UNION|FETCH|DECLARE|TRUNCATE)\b)/i, + /(--|;|\/\*|\*\/|xp_|sp_)/i, + /(\b(OR|AND)\b\s+\d+\s*=\s*\d+)/i, + /['"`]\s*(OR|AND)\s+['"`]/i, + /;\s*(DROP|ALTER|CREATE|EXEC)/i, +]; + +export function detectSqlInjection(input: string): boolean { + if (typeof input !== 'string') return false; + return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(input)); +} + +// ── File Upload Validation ─────────────────────────────────────────────────── + +export interface FileUploadConfig { + maxFileSizeBytes: number; + allowedMimeTypes: string[]; + allowedExtensions: string[]; + maxFiles: number; +} + +export const DEFAULT_FILE_CONFIG: FileUploadConfig = { + maxFileSizeBytes: 10 * 1024 * 1024, // 10MB + allowedMimeTypes: ['application/json', 'text/csv', 'application/pdf', 'image/png', 'image/jpeg'], + allowedExtensions: ['.json', '.csv', '.pdf', '.png', '.jpg', '.jpeg'], + maxFiles: 5, +}; + +export interface FileValidationResult { + valid: boolean; + errors: string[]; +} + +export function validateFileUpload( + file: { name: string; size: number; type: string }, + config: Partial = {} +): FileValidationResult { + const cfg = { ...DEFAULT_FILE_CONFIG, ...config }; + const errors: string[] = []; + + if (file.size > cfg.maxFileSizeBytes) { + errors.push(`File size ${file.size} exceeds maximum ${cfg.maxFileSizeBytes} bytes`); + } + + if (!cfg.allowedMimeTypes.includes(file.type)) { + errors.push(`MIME type '${file.type}' is not allowed. Allowed: ${cfg.allowedMimeTypes.join(', ')}`); + } + + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!cfg.allowedExtensions.includes(ext)) { + errors.push(`Extension '${ext}' is not allowed. Allowed: ${cfg.allowedExtensions.join(', ')}`); + } + + return { valid: errors.length === 0, errors }; +} + +// ── Request Body Size Limits ───────────────────────────────────────────────── + +export const BODY_SIZE_LIMITS = { + small: 1024, // 1KB – simple forms + medium: 100 * 1024, // 100KB – standard JSON + large: 1024 * 1024, // 1MB – file metadata + max: 10 * 1024 * 1024, // 10MB – bulk operations +} as const; + +export type BodySizeLimit = keyof typeof BODY_SIZE_LIMITS; + +export function getBodySizeLimit(limit: BodySizeLimit): number { + return BODY_SIZE_LIMITS[limit]; +} + +// ── Zod Validation Middleware ───────────────────────────────────────────────── + +export interface ValidationSchemas { + body?: ZodSchema; + query?: ZodSchema; + params?: ZodSchema; +} + +export interface ValidationOptions { + /** Strip unknown properties instead of rejecting them. Default: true */ + stripUnknown?: boolean; + /** Sanitize XSS in string fields. Default: true */ + sanitizeXss?: boolean; + /** Detect SQL injection in string fields. Default: true */ + detectSqlInjection?: boolean; + /** Max body size in bytes. Default: 100KB */ + maxBodySize?: number; +} + +export interface ValidationResult { + success: boolean; + data?: { + body?: unknown; + query?: unknown; + params?: unknown; + }; + errors?: ValidationErrorDetail[]; +} + +export interface ValidationErrorDetail { + field: string; + code: string; + message: string; + path: (string | number)[]; +} + +function formatZodError(error: ZodError, source: string): ValidationErrorDetail[] { + return error.issues.map((issue) => ({ + field: `${source}.${issue.path.join('.')}`, + code: issue.code, + message: issue.message, + path: [source, ...issue.path], + })); +} + +function sanitizeObjectStrings(obj: unknown): unknown { + if (typeof obj === 'string') return sanitizeXss(obj); + if (Array.isArray(obj)) return obj.map(sanitizeObjectStrings); + if (obj && typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = sanitizeObjectStrings(value); + } + return result; + } + return obj; +} + +function checkSqlInjection(obj: unknown, path: string): string[] { + const errors: string[] = []; + if (typeof obj === 'string') { + if (detectSqlInjection(obj)) { + errors.push(`Potential SQL injection detected at ${path}`); + } + } else if (Array.isArray(obj)) { + obj.forEach((item, i) => { + errors.push(...checkSqlInjection(item, `${path}[${i}]`)); + }); + } else if (obj && typeof obj === 'object') { + for (const [key, value] of Object.entries(obj)) { + errors.push(...checkSqlInjection(value, `${path}.${key}`)); + } + } + return errors; +} + +export function validateRequest( + data: { body?: unknown; query?: unknown; params?: unknown }, + schemas: ValidationSchemas, + options: ValidationOptions = {} +): ValidationResult { + const opts: Required = { + stripUnknown: true, + sanitizeXss: true, + detectSqlInjection: true, + maxBodySize: BODY_SIZE_LIMITS.medium, + ...options, + }; + + const allErrors: ValidationErrorDetail[] = []; + + // Validate body + if (schemas.body && data.body !== undefined) { + const result = schemas.body.safeParse(data.body); + if (!result.success) { + allErrors.push(...formatZodError(result.error, 'body')); + } else { + data.body = result.data; + } + } + + // Validate query + if (schemas.query && data.query !== undefined) { + const result = schemas.query.safeParse(data.query); + if (!result.success) { + allErrors.push(...formatZodError(result.error, 'query')); + } else { + data.query = result.data; + } + } + + // Validate params + if (schemas.params && data.params !== undefined) { + const result = schemas.params.safeParse(data.params); + if (!result.success) { + allErrors.push(...formatZodError(result.error, 'params')); + } else { + data.params = result.data; + } + } + + if (allErrors.length > 0) { + return { success: false, errors: allErrors }; + } + + // Sanitize XSS + if (opts.sanitizeXss) { + if (data.body) data.body = sanitizeObjectStrings(data.body); + if (data.query) data.query = sanitizeObjectStrings(data.query); + if (data.params) data.params = sanitizeObjectStrings(data.params); + } + + // Detect SQL injection + if (opts.detectSqlInjection) { + const sqlErrors: string[] = []; + if (data.body) sqlErrors.push(...checkSqlInjection(data.body, 'body')); + if (data.query) sqlErrors.push(...checkSqlInjection(data.query, 'query')); + if (data.params) sqlErrors.push(...checkSqlInjection(data.params, 'params')); + + if (sqlErrors.length > 0) { + return { + success: false, + errors: sqlErrors.map((msg) => ({ + field: 'security', + code: 'SQL_INJECTION_DETECTED', + message: msg, + path: [], + })), + }; + } + } + + return { success: true, data }; +} + +// ── Express-style Middleware Factory ────────────────────────────────────────── + +export function createValidationMiddleware( + schemas: ValidationSchemas, + options: ValidationOptions = {} +) { + return function validationMiddleware( + req: { body?: unknown; query?: unknown; params?: unknown; method?: string; url?: string }, + res: { writeHead: (status: number, headers?: Record) => void; end: (body: string) => void }, + next: () => void + ): void { + // Check body size for POST/PUT/PATCH + if (req.body && ['POST', 'PUT', 'PATCH'].includes(req.method ?? '')) { + const bodyStr = JSON.stringify(req.body); + if (bodyStr.length > (options.maxBodySize ?? BODY_SIZE_LIMITS.medium)) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds maximum size of ${options.maxBodySize ?? BODY_SIZE_LIMITS.medium} bytes`, + }, + })); + return; + } + } + + const result = validateRequest( + { body: req.body, query: req.query, params: req.params }, + schemas, + options + ); + + if (!result.success) { + res.writeHead(422, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: { + code: ErrorCode.VALIDATION_ERROR, + message: 'Request validation failed', + details: result.errors?.reduce((acc, e) => { + acc[e.field] = e.message; + return acc; + }, {} as Record), + }, + })); + return; + } + + // Update request with parsed/validated data + if (result.data?.body !== undefined) req.body = result.data.body; + if (result.data?.query !== undefined) req.query = result.data.query; + if (result.data?.params !== undefined) req.params = result.data.params; + + next(); + }; +} + +// ── Common Reusable Schemas ────────────────────────────────────────────────── + +export const commonSchemas = { + pagination: z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + cursor: z.string().optional(), + }), + + uuid: z.string().uuid(), + + ethereumAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address'), + + email: z.string().email('Invalid email address'), + + currency: z.string().length(3, 'Currency must be 3 characters').toUpperCase(), + + amount: z.number().positive('Amount must be positive'), + + timestamp: z.string().datetime('Invalid ISO timestamp'), + + searchQuery: z.string().max(500).optional(), + + subscriptionId: z.string().min(1).max(255), + + planId: z.string().min(1).max(255), + + metadata: z.record(z.string(), z.string()).optional(), +}; + +// ── HTTP Request Body Reader ───────────────────────────────────────────────── + +export function readBodyWithLimit( + req: IncomingMessage, + maxBytes: number = BODY_SIZE_LIMITS.medium +): Promise<{ body: unknown; truncated: boolean }> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + let truncated = false; + + req.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + truncated = true; + req.destroy(); + reject(new Error(`Request body exceeds ${maxBytes} byte limit`)); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + resolve({ body: {}, truncated }); + return; + } + try { + resolve({ body: JSON.parse(raw), truncated }); + } catch { + reject(new Error('Invalid JSON in request body')); + } + }); + + req.on('error', reject); + }); +} diff --git a/developer-portal/docs/cors-guide.md b/developer-portal/docs/cors-guide.md new file mode 100644 index 00000000..38e73992 --- /dev/null +++ b/developer-portal/docs/cors-guide.md @@ -0,0 +1,90 @@ +# CORS Policy Management + +## Overview + +SubTrackr supports dynamic CORS (Cross-Origin Resource Sharing) policy management for multi-tenant deployments. This allows each tenant to configure their own allowed origins without server restarts. + +## Quick Start + +### Configure CORS for Your Tenant + +```bash +curl -X POST https://api.subtrackr.com/v1/cors/policies \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "tenantId": "your-tenant-id", + "allowedOrigins": [ + "https://app.yourdomain.com", + "*.dev.yourdomain.com" + ], + "allowCredentials": true, + "maxAge": 86400 + }' +``` + +### Test an Origin + +```bash +curl -X GET "https://api.subtrackr.com/v1/cors/test?origin=https://app.yourdomain.com&tenantId=your-tenant-id" \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `allowedOrigins` | string[] | required | List of allowed origins (supports wildcards) | +| `allowCredentials` | boolean | false | Include Access-Control-Allow-Credentials header | +| `exposedHeaders` | string[] | [] | Headers to expose to the browser | +| `maxAge` | number | 86400 | Preflight cache duration in seconds | +| `allowMethods` | string[] | [GET,POST,PUT,PATCH,DELETE] | Allowed HTTP methods | +| `allowHeaders` | string[] | [Content-Type,Authorization] | Allowed request headers | + +## Wildcard Patterns + +Use `*` for subdomain matching: + +| Pattern | Matches | +|---------|---------| +| `*.example.com` | `app.example.com`, `api.example.com` | +| `https://*.dev.example.com` | `https://staging.dev.example.com` | +| `*.app.example.com` | `staging.app.example.com` | + +## JavaScript SDK + +```typescript +import { SubTrackr } from '@subtrackr/sdk'; + +const client = new SubTrackr({ + apiKey: 'YOUR_API_KEY', + tenantId: 'your-tenant-id', +}); + +// Configure CORS +await client.cors.updatePolicy({ + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, +}); + +// Test an origin +const result = await client.cors.testOrigin('https://app.example.com'); +// { allowed: true, matchedPattern: 'https://app.example.com' } +``` + +## Error Handling + +Blocked requests receive empty CORS headers, which causes the browser to block the response. Monitor violations via the analytics endpoint: + +```typescript +const analytics = await client.cors.getAnalytics(); +console.log(`Blocked requests: ${analytics.blockedRequests}`); +``` + +## Security Best Practices + +1. **Be Specific**: Use exact origins instead of wildcards when possible +2. **No Credentials with Wildcards**: Don't combine `allowCredentials: true` with `*` +3. **Monitor Violations**: Regularly check CORS analytics for suspicious activity +4. **Limit Methods**: Only allow methods your API actually uses +5. **HTTPS Only**: Always use HTTPS origins in production diff --git a/docs/CORS.md b/docs/CORS.md new file mode 100644 index 00000000..0f409835 --- /dev/null +++ b/docs/CORS.md @@ -0,0 +1,219 @@ +# CORS Policy Management with Dynamic Origin Whitelisting + +## Overview + +Issue #772 implements dynamic CORS (Cross-Origin Resource Sharing) policy management for SubTrackr, supporting per-tenant origin whitelisting, preflight caching, violation logging, and analytics. + +## Architecture + +``` +backend/services/shared/ +├── corsMiddleware.ts # CORS engine +└── index.ts # Exports +``` + +## Features + +### 1. Dynamic CORS Management + +CORS policies are managed at runtime without server restarts: + +```typescript +import { upsertCorsPolicy } from '../shared'; + +upsertCorsPolicy('tenant-123', { + allowedOrigins: [ + { origin: 'https://app.example.com', isWildcard: false }, + { origin: '*.dev.example.com', isWildcard: true }, + ], + allowCredentials: true, + exposedHeaders: ['X-Request-ID'], + maxAge: 86400, + allowMethods: ['GET', 'POST', 'PUT', 'DELETE'], + allowHeaders: ['Content-Type', 'Authorization'], + active: true, +}); +``` + +### 2. Per-Tenant Whitelisting + +Each tenant can have its own CORS policy: + +```typescript +import { getCorsPolicy, testCorsOrigin } from '../shared'; + +// Test if an origin is allowed +const result = testCorsOrigin('https://app.example.com', 'tenant-123'); +// { allowed: true, policyId: 'cors-tenant-123-...', matchedPattern: 'https://app.example.com' } +``` + +### 3. Wildcard Patterns + +Supports wildcard patterns for subdomain matching: + +```typescript +upsertCorsPolicy('tenant-123', { + allowedOrigins: [ + { origin: '*.example.com', isWildcard: true }, // matches any subdomain + { origin: 'https://*.dev.example.com', isWildcard: true }, + ], + // ... +}); +``` + +### 4. Preflight Caching + +Preflight responses are cached to reduce latency: + +```typescript +import { processCorsRequest, clearPreflightCache } from '../shared'; + +// Handles preflight caching automatically +const { headers, allowed } = processCorsRequest({ + origin: 'https://app.example.com', + method: 'OPTIONS', + tenantId: 'tenant-123', +}); +``` + +### 5. CORS Violation Logging + +All CORS violations are recorded for security monitoring: + +```typescript +import { getCorsViolations } from '../shared'; + +// Get recent violations for a tenant +const violations = getCorsViolations({ + tenantId: 'tenant-123', + since: '2026-01-01T00:00:00Z', + limit: 50, +}); +``` + +### 6. CORS Analytics + +Track CORS usage and violations: + +```typescript +import { getCorsAnalytics } from '../shared'; + +const analytics = getCorsAnalytics(); +// { +// totalRequests: 15000, +// allowedRequests: 14500, +// blockedRequests: 500, +// uniqueOrigins: 25, +// violationsByOrigin: { 'https://evil.com': 500 }, +// violationsByTenant: { 'tenant-123': 300 }, +// preflightCacheHitRate: 0.85, +// } +``` + +### 7. Express-style Middleware + +```typescript +import { createCorsMiddleware } from '../shared'; + +// Apply CORS middleware to all routes +app.use(createCorsMiddleware('default-tenant')); + +// Or with per-route tenant +app.use('/api/v1', (req, res, next) => { + req.tenantId = req.headers['x-tenant-id']; + createCorsMiddleware()(req, res, next); +}); +``` + +## API Reference + +### Policy Management + +| Function | Description | +|----------|-------------| +| `upsertCorsPolicy(tenantId, config)` | Create or update a policy | +| `getCorsPolicy(tenantId)` | Get policy for a tenant | +| `getAllCorsPolicies()` | List all policies | +| `deleteCorsPolicy(tenantId)` | Delete a tenant's policy | + +### Request Processing + +| Function | Description | +|----------|-------------| +| `processCorsRequest(options)` | Process CORS request and return headers | +| `testCorsOrigin(origin, tenantId?)` | Test if an origin is allowed | +| `createCorsMiddleware(tenantId?)` | Create Express-style middleware | + +### Monitoring + +| Function | Description | +|----------|-------------| +| `getCorsAnalytics()` | Get CORS analytics snapshot | +| `getCorsViolations(options)` | Get recent violations | +| `clearPreflightCache()` | Clear preflight response cache | + +## Request Flow + +``` +Client Request + │ + ▼ +┌─────────────────┐ +│ CORS Middleware │ +└─────────────────┘ + │ + ├─ Preflight (OPTIONS) + │ │ + │ ▼ + │ ┌───────────────┐ + │ │ Check Cache │ + │ └───────────────┘ + │ │ + │ ├─ Cache Hit → Return cached headers + │ │ + │ └─ Cache Miss + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │ Find Policy │ + │ └─────────────────┘ + │ │ + │ ├─ Policy Found → Build headers, cache, return + │ │ + │ └─ No Policy → Return empty headers (blocked) + │ + └─ Simple Request + │ + ▼ + ┌─────────────────┐ + │ Find Policy │ + └─────────────────┘ + │ + ├─ Policy Found → Apply headers, continue + │ + └─ No Policy → Record violation, block +``` + +## Error Response + +Blocked requests receive an empty CORS headers set, which causes the browser to block the response. Violations are logged for monitoring: + +```json +{ + "requestId": "req-1703980800000", + "origin": "https://evil.com", + "method": "GET", + "tenantId": "tenant-123", + "timestamp": "2026-01-01T00:00:00.000Z", + "path": "/api/v1/subscriptions", + "reason": "Origin not in allowlist" +} +``` + +## Security Considerations + +1. **No `Access-Control-Allow-Origin: *`**: The middleware never uses the wildcard header; it always echoes the specific origin +2. **Credentials**: `Access-Control-Allow-Credentials: true` is only set when explicitly configured +3. **Preflight Validation**: All OPTIONS requests are validated against the policy +4. **Violation Logging**: All blocked attempts are recorded for security analysis +5. **Cache Invalidation**: Policies changes immediately affect new requests diff --git a/docs/FALLBACK_CHAINS.md b/docs/FALLBACK_CHAINS.md new file mode 100644 index 00000000..8b80695b --- /dev/null +++ b/docs/FALLBACK_CHAINS.md @@ -0,0 +1,192 @@ +# Subscription Payment Method Fallback Chains + +## Overview + +Issue #773 implements automatic payment method fallback chains for SubTrackr, ensuring payment reliability by automatically trying alternative gateways when the primary gateway fails. + +## Architecture + +``` +backend/services/payment/ +├── domain/ +│ ├── fallbackChainService.ts # Fallback chain engine +│ ├── PaymentRouter.ts # Existing payment router +│ └── gateways/ # Gateway adapters +└── index.ts # Exports +``` + +## Features + +### 1. Fallback Chain Configuration + +Configure ordered fallback chains per merchant: + +```typescript +import { upsertFallbackChain } from '../services/payment'; + +upsertFallbackChain('merchant-123', { + chain: [ + { gateway: 'stripe', priority: 0, enabled: true, timeoutMs: 5000 }, + { gateway: 'circle', priority: 1, enabled: true, timeoutMs: 5000 }, + { gateway: 'stellar', priority: 2, enabled: true, timeoutMs: 10000 }, + ], + retryAttempts: 2, + retryDelayMs: 1000, + active: true, +}); +``` + +### 2. Automatic Fallback on Failure + +Payments automatically try alternative gateways: + +```typescript +import { executeWithFallback, registerGatewayExecutor } from '../services/payment'; + +// Register gateway executors +registerGatewayExecutor('stripe', async (request) => { + // Call Stripe API + return { success: true, gatewayUsed: 'stripe' }; +}); + +registerGatewayExecutor('circle', async (request) => { + // Call Circle API + return { success: true, gatewayUsed: 'circle' }; +}); + +// Execute payment with automatic fallback +const result = await executeWithFallback('merchant-123', { + amount: 99.99, + currency: 'USD', + customerId: '0x1234...', + paymentMethodId: 'pm_abc123', +}); + +if (result.success) { + console.log(`Payment succeeded on ${result.successfulGateway}`); + console.log(`Total attempts: ${result.attempts.length}`); + console.log(`Duration: ${result.totalDurationMs}ms`); +} else { + console.error(`All gateways failed: ${result.error}`); +} +``` + +### 3. Fallback Analytics + +Track fallback performance: + +```typescript +import { getFallbackAnalytics } from '../services/payment'; + +const analytics = getFallbackAnalytics({ + startDate: '2026-01-01T00:00:00Z', + endDate: '2026-01-31T23:59:59Z', + merchantId: 'merchant-123', +}); + +console.log(`Success rate: ${(analytics.successRateByGateway.stripe * 100).toFixed(1)}%`); +console.log(`Fallback rate: ${(analytics.fallbackRate * 100).toFixed(1)}%`); +console.log(`Average attempts: ${analytics.averageAttemptsPerPayment.toFixed(2)}`); +``` + +### 4. Fallback History + +Query payment attempt history: + +```typescript +import { getFallbackHistory } from '../services/payment'; + +const history = getFallbackHistory({ + merchantId: 'merchant-123', + gateway: 'stripe', + status: 'failed', + limit: 100, + startDate: '2026-01-01T00:00:00Z', +}); +``` + +### 5. Fallback Notifications + +Receive notifications on fallback events: + +```typescript +import { getFallbackNotifications, markFallbackNotificationSent } from '../services/payment'; + +// Get unsent notifications +const notifications = getFallbackNotifications('merchant-123', { unsentOnly: true }); + +for (const notif of notifications) { + console.log(`${notif.title}: ${notif.message}`); + // Send email/webhook... + markFallbackNotificationSent(notif.id); +} +``` + +## Flow Diagram + +``` +Payment Request + │ + ▼ +┌─────────────────────────┐ +│ Get Fallback Chain │ +│ for Merchant │ +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Sort by Priority │ +│ (0 = first) │ +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Try Gateway 1 (stripe) │ +│ + Timeout + Retries │ +└─────────────────────────┘ + │ + ├─ Success → Return result + │ + ├─ Failed → Try Gateway 2 (circle) + │ │ + │ ├─ Success → Return result + │ │ + │ └─ Failed → Try Gateway 3 (stellar) + │ │ + │ ├─ Success → Return result + │ │ + │ └─ Failed → Return failure + │ + └─ Timeout → Try next gateway +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `chain` | Array | `[{stripe}, {circle}, {stellar}]` | Ordered list of gateways | +| `retryAttempts` | number | `1` | Retries per gateway | +| `retryDelayMs` | number | `1000` | Delay between retries | +| `timeoutMs` | number | `5000` | Per-gateway timeout | +| `active` | boolean | `true` | Whether chain is enabled | + +## Analytics Metrics + +| Metric | Description | +|--------|-------------| +| `totalAttempts` | Total gateway attempts | +| `successfulAttempts` | Successful payments | +| `failedAttempts` | Failed attempts | +| `timeoutAttempts` | Timed out attempts | +| `successRateByGateway` | Success rate per gateway | +| `averageAttemptsPerPayment` | Avg attempts per payment | +| `fallbackRate` | % of payments needing fallback | +| `commonFailureReasons` | Top failure reasons | + +## Security Considerations + +1. **Idempotency**: Each payment attempt uses unique idempotency keys +2. **Timeout Protection**: Gateway timeouts prevent hanging +3. **Retry Limits**: Configurable retry limits prevent cascading failures +4. **Audit Trail**: All attempts are logged for debugging +5. **Notification Alerts**: Merchants are notified of failures diff --git a/docs/REVENUE_FORECASTING.md b/docs/REVENUE_FORECASTING.md new file mode 100644 index 00000000..ce183752 --- /dev/null +++ b/docs/REVENUE_FORECASTING.md @@ -0,0 +1,264 @@ +# Subscription Analytics with Revenue Forecasting + +## Overview + +Issue #774 implements comprehensive revenue forecasting and trend analysis for SubTrackr, enabling data-driven decision making with accurate predictions and visualization data. + +## Architecture + +``` +backend/services/analytics/ +├── revenueForecastService.ts # Forecasting engine +├── predictionService.ts # Existing ML-based predictions +└── index.ts # Exports +``` + +## Features + +### 1. Revenue Forecasting Models + +Multiple forecasting models are available: + +```typescript +import { generateRevenueForecast } from '../services/analytics'; + +// Linear regression forecast +const linearForecast = generateRevenueForecast(historicalData, { + horizon: 6, + granularity: 'month', + model: 'linear', + confidence: 0.95, +}); + +// Exponential smoothing forecast +const expForecast = generateRevenueForecast(historicalData, { + horizon: 12, + model: 'exponential', +}); + +// Moving average forecast +const maForecast = generateRevenueForecast(historicalData, { + horizon: 6, + model: 'moving_average', +}); +``` + +### 2. Trend Analysis + +Analyze revenue trends with multiple metrics: + +```typescript +import { analyzeTrend } from '../services/analytics'; + +const trend = analyzeTrend(historicalData); +// { +// direction: 'up', +// strength: 0.85, +// growthRate: 12.5, +// hasSeasonality: true, +// movingAverage: [...], +// trendLine: [...], +// } +``` + +### 3. Forecast Accuracy Tracking + +Measure forecast accuracy against actuals: + +```typescript +import { calculateAccuracy } from '../services/analytics'; + +const accuracy = calculateAccuracy(forecasts, actuals); +// { +// mae: 1250.50, // Mean Absolute Error +// mape: 8.3, // Mean Absolute Percentage Error +// rmse: 1580.25, // Root Mean Squared Error +// rSquared: 0.92, // R-squared (goodness of fit) +// comparisons: [...] +// } +``` + +### 4. Forecast Visualization + +Generate chart-ready data: + +```typescript +import { generateVisualizationData } from '../services/analytics'; + +const vizData = generateVisualizationData(historical, forecasts, 0.95); +// { +// series: [ +// { period: '2026-01', historical: 50000, forecast: null, ... }, +// { period: '2026-02', historical: 55000, forecast: null, ... }, +// { period: '2026-07', historical: null, forecast: 62000, ... }, +// ], +// summary: { +// currentMrr: 55000, +// projectedMrr: 62000, +// growthRate: 12.7, +// confidenceLevel: 0.95, +// } +// } +``` + +### 5. Forecast Alerts + +Automatic alerts based on forecast analysis: + +```typescript +import { generateForecastAlerts } from '../services/analytics'; + +const alerts = generateForecastAlerts(forecast, { + declinePercent: -10, + growthPercent: 20, + deviationPercent: 15, +}); +// [ +// { +// type: 'revenue_decline', +// severity: 'critical', +// title: 'Revenue Decline Detected', +// message: 'Revenue is declining at -12.5% per period', +// } +// ] +``` + +## Forecasting Models + +### Linear Regression + +Best for: Steady growth/decline patterns + +``` +Revenue = intercept + slope × time +``` + +### Exponential Smoothing + +Best for: Recent data is more relevant + +``` +Smoothed = α × actual + (1 - α) × previous_smoothed +``` + +### Moving Average + +Best for: Smoothing out noise + +``` +Forecast = average(last N periods) +``` + +## Output Format + +### Forecast Point + +```typescript +{ + period: '2026-07-01T00:00:00.000Z', + predictedRevenue: 62500, + lowerBound: 58000, + upperBound: 67000, + confidence: 0.95, + model: 'linear' +} +``` + +### Trend Analysis + +```typescript +{ + direction: 'up', // 'up' | 'down' | 'stable' + strength: 0.85, // 0-1 (R² value) + growthRate: 12.5, // Percentage + hasSeasonality: true, + movingAverage: [...], + trendLine: [...] +} +``` + +### Accuracy Metrics + +| Metric | Description | Good Value | +|--------|-------------|------------| +| MAE | Mean Absolute Error | < 5% of mean | +| MAPE | Mean Absolute Percentage Error | < 10% | +| RMSE | Root Mean Squared Error | < 10% of mean | +| R² | Goodness of fit | > 0.8 | + +## Usage Examples + +### Monthly MRR Forecast + +```typescript +const historicalData: RevenueDataPoint[] = [ + { period: '2026-01', revenue: 50000, subscriberCount: 1000, arpu: 50, ... }, + { period: '2026-02', revenue: 55000, subscriberCount: 1100, arpu: 50, ... }, + // ... +]; + +const forecast = generateRevenueForecast(historicalData, { + horizon: 6, + granularity: 'month', + model: 'linear', + confidence: 0.95, +}); + +console.log('Next 6 months forecast:'); +forecast.forecasts.forEach(f => { + console.log(`${f.period}: $${f.predictedRevenue.toFixed(2)} (±${(f.upperBound - f.lowerBound).toFixed(2)})`); +}); +``` + +### Compare Models + +```typescript +const models: ForecastModel[] = ['linear', 'exponential', 'moving_average']; + +for (const model of models) { + const forecast = generateRevenueForecast(data, { model, horizon: 6 }); + if (data.length > 6) { + const accuracy = calculateAccuracy(forecast.forecasts, data.slice(-6)); + console.log(`${model}: MAPE=${accuracy.mape.toFixed(2)}%, R²=${accuracy.rSquared.toFixed(3)}`); + } +} +``` + +## Alert Thresholds + +| Alert Type | Default Threshold | Severity | +|------------|-------------------|----------| +| Revenue Decline | -10% growth rate | Critical | +| Growth Spike | +20% growth rate | Info | +| High Deviation | >15% CI width | Warning | +| Seasonal Pattern | Detected | Info | + +## API Integration + +### REST Endpoint + +```typescript +// GET /analytics/forecast?horizon=6&model=linear&confidence=0.95 +app.get('/analytics/forecast', (req, res) => { + const forecast = generateRevenueForecast(historicalData, { + horizon: parseInt(req.query.horizon) || 6, + model: req.query.model || 'linear', + confidence: parseFloat(req.query.confidence) || 0.95, + }); + + const vizData = generateVisualizationData(historicalData, forecast.forecasts); + + res.json({ + forecast, + visualization: vizData, + alerts: generateForecastAlerts(forecast), + }); +}); +``` + +## Performance Considerations + +1. **Caching**: Forecasts should be cached for 1-5 minutes +2. **Data Limits**: Keep last 24 months for monthly forecasts +3. **Model Selection**: Auto-select best model based on accuracy +4. **Background Jobs**: Generate forecasts in background, store results diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 00000000..4b43b494 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,143 @@ +# Input Validation with Zod Schemas and Sanitization + +## Overview + +Issue #771 implements comprehensive input validation across the SubTrackr backend using Zod schemas, XSS sanitization, SQL injection prevention, file upload validation, and request body size limits. + +## Architecture + +``` +backend/services/shared/ +├── validationMiddleware.ts # Core validation engine +├── schemas.ts # Zod schemas for all API endpoints +└── index.ts # Exports +``` + +## Features + +### 1. Zod Schema Validation + +All request bodies, query parameters, and path parameters are validated against typed Zod schemas: + +```typescript +import { validateRequest, createPlanSchema } from '../shared'; + +const result = validateRequest( + { body: req.body, query: req.query, params: req.params }, + { body: createPlanSchema, query: planQuerySchema } +); + +if (!result.success) { + // Return structured validation errors +} +``` + +### 2. XSS Sanitization + +String inputs are automatically sanitized to prevent stored XSS attacks: + +- HTML tag stripping (`