diff --git a/Boot.php b/Boot.php index 187c3cf..3d1801e 100644 --- a/Boot.php +++ b/Boot.php @@ -74,6 +74,8 @@ class Boot extends ServiceProvider $this->app->singleton(\Core\Mod\Commerce\Services\PaymentMethodService::class); $this->app->singleton(\Core\Mod\Commerce\Services\UsageBillingService::class); $this->app->singleton(\Core\Mod\Commerce\Services\ReferralService::class); + $this->app->singleton(\Core\Mod\Commerce\Services\FraudService::class); + $this->app->singleton(\Core\Mod\Commerce\Services\CheckoutRateLimiter::class); // Payment Gateways $this->app->singleton('commerce.gateway.btcpay', function ($app) { diff --git a/Data/FraudAssessment.php b/Data/FraudAssessment.php new file mode 100644 index 0000000..98362f7 --- /dev/null +++ b/Data/FraudAssessment.php @@ -0,0 +1,82 @@ +riskLevel === 'highest' || $this->riskLevel === 'elevated'; + } + + /** + * Check if fraud detection was performed. + */ + public function wasAssessed(): bool + { + return $this->riskLevel !== 'not_assessed'; + } + + /** + * Get a human-readable risk description. + */ + public function getRiskDescription(): string + { + return match ($this->riskLevel) { + 'highest' => 'Very High Risk - Payment appears fraudulent', + 'elevated' => 'Elevated Risk - Payment requires review', + 'normal' => 'Normal Risk - Payment appears legitimate', + 'not_assessed' => 'Not Assessed - Fraud detection disabled', + default => 'Unknown Risk Level', + }; + } + + /** + * Convert to array for storage/logging. + */ + public function toArray(): array + { + return [ + 'risk_level' => $this->riskLevel, + 'signals' => $this->signals, + 'source' => $this->source, + 'stripe_risk_score' => $this->stripeRiskScore, + 'should_block' => $this->shouldBlock, + 'should_review' => $this->shouldReview, + ]; + } +} diff --git a/Exceptions/CheckoutRateLimitException.php b/Exceptions/CheckoutRateLimitException.php new file mode 100644 index 0000000..3d6790a --- /dev/null +++ b/Exceptions/CheckoutRateLimitException.php @@ -0,0 +1,44 @@ +retryAfter; + } + + /** + * Get the number of minutes until the rate limit resets (rounded up). + */ + public function getRetryAfterMinutes(): int + { + return (int) ceil($this->retryAfter / 60); + } +} diff --git a/Services/FraudService.php b/Services/FraudService.php new file mode 100644 index 0000000..42dd696 --- /dev/null +++ b/Services/FraudService.php @@ -0,0 +1,430 @@ +checkVelocity($order); + $signals = array_merge($signals, $velocitySignals); + + if (! empty($velocitySignals)) { + $riskLevel = self::RISK_ELEVATED; + } + } + + // Geo-anomaly checks + if (config('commerce.fraud.geo.enabled', true)) { + $geoSignals = $this->checkGeoAnomalies($order); + $signals = array_merge($signals, $geoSignals); + + if (! empty($geoSignals)) { + // High-risk country = highest risk + if (isset($geoSignals['high_risk_country'])) { + $riskLevel = self::RISK_HIGHEST; + } elseif ($riskLevel !== self::RISK_HIGHEST) { + $riskLevel = self::RISK_ELEVATED; + } + } + } + + $assessment = new FraudAssessment( + riskLevel: $riskLevel, + signals: $signals, + source: 'internal', + shouldBlock: $this->shouldBlockOrder($riskLevel), + shouldReview: $this->shouldReviewOrder($riskLevel) + ); + + // Log and notify if configured + $this->logAssessment($order, $assessment); + + return $assessment; + } + + /** + * Process fraud signals from Stripe Radar after payment. + * + * Called by webhook handlers when receiving payment_intent or charge events. + */ + public function processStripeRadarOutcome(Payment $payment, array $outcome): FraudAssessment + { + if (! config('commerce.fraud.stripe_radar.enabled', true)) { + return FraudAssessment::notAssessed(); + } + + $signals = []; + $riskLevel = self::RISK_NORMAL; + + // Extract Stripe Radar risk level + $stripeRiskLevel = $outcome['risk_level'] ?? null; + $stripeRiskScore = $outcome['risk_score'] ?? null; + $networkStatus = $outcome['network_status'] ?? null; + $sellerMessage = $outcome['seller_message'] ?? null; + $type = $outcome['type'] ?? null; + + // Map Stripe risk levels + if ($stripeRiskLevel === 'highest') { + $riskLevel = self::RISK_HIGHEST; + $signals['stripe_risk_highest'] = true; + } elseif ($stripeRiskLevel === 'elevated') { + $riskLevel = self::RISK_ELEVATED; + $signals['stripe_risk_elevated'] = true; + } elseif ($stripeRiskLevel === 'normal' || $stripeRiskLevel === 'not_assessed') { + $riskLevel = self::RISK_NORMAL; + } + + // Add risk score if available + if ($stripeRiskScore !== null) { + $signals['stripe_risk_score'] = $stripeRiskScore; + } + + // Check for specific Radar rules triggered + if (isset($outcome['rule'])) { + $signals['stripe_rule_triggered'] = $outcome['rule']['id'] ?? 'unknown'; + $signals['stripe_rule_action'] = $outcome['rule']['action'] ?? null; + + // Rule-based blocking overrides score + if (($outcome['rule']['action'] ?? null) === 'block') { + $riskLevel = self::RISK_HIGHEST; + } + } + + // Network status signals + if ($networkStatus === 'declined_by_network') { + $signals['network_declined'] = true; + } + + $assessment = new FraudAssessment( + riskLevel: $riskLevel, + signals: $signals, + source: 'stripe_radar', + stripeRiskScore: $stripeRiskScore, + shouldBlock: $this->shouldBlockPayment($riskLevel), + shouldReview: $this->shouldReviewPayment($riskLevel) + ); + + // Store assessment on payment if configured + if (config('commerce.fraud.stripe_radar.store_scores', true)) { + $this->storeFraudAssessment($payment, $assessment); + } + + // Log the assessment + $this->logPaymentAssessment($payment, $assessment); + + return $assessment; + } + + /** + * Check velocity-based fraud signals. + */ + protected function checkVelocity(Order $order): array + { + $signals = []; + $ip = request()->ip(); + $email = $order->billing_email; + $workspaceId = $order->orderable_id; + + $maxOrdersPerIpHourly = config('commerce.fraud.velocity.max_orders_per_ip_hourly', 5); + $maxOrdersPerEmailDaily = config('commerce.fraud.velocity.max_orders_per_email_daily', 10); + + // Check orders per IP in the last hour + if ($ip) { + $ipKey = "fraud:orders:ip:{$ip}"; + $ipCount = (int) Cache::get($ipKey, 0); + + if ($ipCount >= $maxOrdersPerIpHourly) { + $signals['velocity_ip_exceeded'] = [ + 'ip' => $ip, + 'count' => $ipCount, + 'limit' => $maxOrdersPerIpHourly, + ]; + } + + // Increment counter (expires in 1 hour) + Cache::put($ipKey, $ipCount + 1, now()->addHour()); + } + + // Check orders per email in the last 24 hours + if ($email) { + $emailKey = 'fraud:orders:email:'.hash('sha256', strtolower($email)); + $emailCount = (int) Cache::get($emailKey, 0); + + if ($emailCount >= $maxOrdersPerEmailDaily) { + $signals['velocity_email_exceeded'] = [ + 'email_hash' => substr(hash('sha256', $email), 0, 8), + 'count' => $emailCount, + 'limit' => $maxOrdersPerEmailDaily, + ]; + } + + // Increment counter (expires in 24 hours) + Cache::put($emailKey, $emailCount + 1, now()->addDay()); + } + + // Check failed payments for this workspace in the last hour + if ($workspaceId) { + $failedKey = "fraud:failed:workspace:{$workspaceId}"; + $failedCount = (int) Cache::get($failedKey, 0); + $maxFailed = config('commerce.fraud.velocity.max_failed_payments_hourly', 3); + + if ($failedCount >= $maxFailed) { + $signals['velocity_failed_exceeded'] = [ + 'workspace_id' => $workspaceId, + 'failed_count' => $failedCount, + 'limit' => $maxFailed, + ]; + } + } + + return $signals; + } + + /** + * Check geo-anomaly fraud signals. + */ + protected function checkGeoAnomalies(Order $order): array + { + $signals = []; + $billingCountry = $order->billing_address['country'] ?? $order->tax_country ?? null; + $ipCountry = $this->getIpCountry(); + + // Check for country mismatch + if (config('commerce.fraud.geo.flag_country_mismatch', true)) { + if ($billingCountry && $ipCountry && $billingCountry !== $ipCountry) { + $signals['geo_country_mismatch'] = [ + 'billing_country' => $billingCountry, + 'ip_country' => $ipCountry, + ]; + } + } + + // Check for high-risk countries + $highRiskCountries = config('commerce.fraud.geo.high_risk_countries', []); + if (! empty($highRiskCountries) && $billingCountry) { + if (in_array($billingCountry, $highRiskCountries, true)) { + $signals['high_risk_country'] = $billingCountry; + } + } + + return $signals; + } + + /** + * Get country code from IP address. + */ + protected function getIpCountry(): ?string + { + $ip = request()->ip(); + if (! $ip || $ip === '127.0.0.1' || str_starts_with($ip, '192.168.')) { + return null; + } + + // Use cached geo lookup if available + $cacheKey = "geo:ip:{$ip}"; + + return Cache::remember($cacheKey, now()->addDay(), function () use ($ip) { + // Try to use Laravel's built-in geo detection if available + // Otherwise, return null (geo check will be skipped) + try { + // This would integrate with a geo-IP service like MaxMind + // For now, return null as a placeholder + return null; + } catch (\Exception $e) { + Log::warning('Geo-IP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]); + + return null; + } + }); + } + + /** + * Determine if order should be blocked based on risk level. + */ + protected function shouldBlockOrder(string $riskLevel): bool + { + if (! config('commerce.fraud.actions.auto_block', true)) { + return false; + } + + $blockThreshold = config('commerce.fraud.stripe_radar.block_threshold', self::RISK_HIGHEST); + + return $this->riskLevelMeetsThreshold($riskLevel, $blockThreshold); + } + + /** + * Determine if order should be flagged for review. + */ + protected function shouldReviewOrder(string $riskLevel): bool + { + $reviewThreshold = config('commerce.fraud.stripe_radar.review_threshold', self::RISK_ELEVATED); + + return $this->riskLevelMeetsThreshold($riskLevel, $reviewThreshold); + } + + /** + * Determine if payment should be blocked based on Stripe Radar risk level. + */ + protected function shouldBlockPayment(string $riskLevel): bool + { + if (! config('commerce.fraud.actions.auto_block', true)) { + return false; + } + + $blockThreshold = config('commerce.fraud.stripe_radar.block_threshold', self::RISK_HIGHEST); + + return $this->riskLevelMeetsThreshold($riskLevel, $blockThreshold); + } + + /** + * Determine if payment should be flagged for review. + */ + protected function shouldReviewPayment(string $riskLevel): bool + { + $reviewThreshold = config('commerce.fraud.stripe_radar.review_threshold', self::RISK_ELEVATED); + + return $this->riskLevelMeetsThreshold($riskLevel, $reviewThreshold); + } + + /** + * Check if a risk level meets or exceeds a threshold. + */ + protected function riskLevelMeetsThreshold(string $riskLevel, string $threshold): bool + { + $levels = [ + self::RISK_NOT_ASSESSED => 0, + self::RISK_NORMAL => 1, + self::RISK_ELEVATED => 2, + self::RISK_HIGHEST => 3, + ]; + + return ($levels[$riskLevel] ?? 0) >= ($levels[$threshold] ?? 0); + } + + /** + * Store fraud assessment on payment record. + */ + protected function storeFraudAssessment(Payment $payment, FraudAssessment $assessment): void + { + $metadata = $payment->metadata ?? []; + $metadata['fraud_assessment'] = [ + 'risk_level' => $assessment->riskLevel, + 'risk_score' => $assessment->stripeRiskScore, + 'source' => $assessment->source, + 'signals' => $assessment->signals, + 'should_block' => $assessment->shouldBlock, + 'should_review' => $assessment->shouldReview, + 'assessed_at' => now()->toIso8601String(), + ]; + + $payment->update(['metadata' => $metadata]); + } + + /** + * Record a failed payment for velocity tracking. + */ + public function recordFailedPayment(Order $order): void + { + $workspaceId = $order->orderable_id; + + if ($workspaceId) { + $failedKey = "fraud:failed:workspace:{$workspaceId}"; + $failedCount = (int) Cache::get($failedKey, 0); + Cache::put($failedKey, $failedCount + 1, now()->addHour()); + } + } + + /** + * Log fraud assessment. + */ + protected function logAssessment(Order $order, FraudAssessment $assessment): void + { + if (! config('commerce.fraud.actions.log', true)) { + return; + } + + if ($assessment->riskLevel === self::RISK_NORMAL && empty($assessment->signals)) { + return; // Don't log normal orders with no signals + } + + Log::channel('fraud')->info('Order fraud assessment', [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'risk_level' => $assessment->riskLevel, + 'signals' => $assessment->signals, + 'should_block' => $assessment->shouldBlock, + 'should_review' => $assessment->shouldReview, + ]); + } + + /** + * Log payment fraud assessment. + */ + protected function logPaymentAssessment(Payment $payment, FraudAssessment $assessment): void + { + if (! config('commerce.fraud.actions.log', true)) { + return; + } + + Log::channel('fraud')->info('Payment fraud assessment (Stripe Radar)', [ + 'payment_id' => $payment->id, + 'order_id' => $payment->order_id, + 'risk_level' => $assessment->riskLevel, + 'risk_score' => $assessment->stripeRiskScore, + 'signals' => $assessment->signals, + 'should_block' => $assessment->shouldBlock, + 'should_review' => $assessment->shouldReview, + ]); + + // Notify admin if high risk and notifications enabled + if ($assessment->shouldReview && config('commerce.fraud.actions.notify_admin', true)) { + // This could dispatch a notification job + // For now, just log at warning level + Log::channel('fraud')->warning('High-risk payment requires review', [ + 'payment_id' => $payment->id, + 'risk_level' => $assessment->riskLevel, + ]); + } + } +} diff --git a/config.php b/config.php index b2d9b34..06eb738 100644 --- a/config.php +++ b/config.php @@ -280,6 +280,74 @@ return [ 'session_ttl' => 30, // Minutes before checkout session expires ], + /* + |-------------------------------------------------------------------------- + | Fraud Detection Settings + |-------------------------------------------------------------------------- + | + | Configuration for fraud detection and prevention. + | Uses Stripe Radar for Stripe payments. BTCPay payments rely on + | blockchain confirmations for security. + | + */ + + 'fraud' => [ + // Enable fraud detection + 'enabled' => env('COMMERCE_FRAUD_DETECTION', true), + + // Stripe Radar integration (requires Stripe Radar subscription) + 'stripe_radar' => [ + 'enabled' => env('COMMERCE_STRIPE_RADAR', true), + + // Block payments with risk level equal or above this threshold + // Options: 'highest', 'elevated', 'normal' (block highest only, elevated+, or all flagged) + 'block_threshold' => env('COMMERCE_STRIPE_RADAR_BLOCK_THRESHOLD', 'highest'), + + // Review payments at this risk level (manual review required) + 'review_threshold' => env('COMMERCE_STRIPE_RADAR_REVIEW_THRESHOLD', 'elevated'), + + // Store fraud scores on orders for analysis + 'store_scores' => true, + ], + + // Velocity checks (rate limiting beyond checkout rate limiter) + 'velocity' => [ + 'enabled' => env('COMMERCE_FRAUD_VELOCITY', true), + + // Maximum orders per IP per hour + 'max_orders_per_ip_hourly' => 5, + + // Maximum orders per email per day + 'max_orders_per_email_daily' => 10, + + // Maximum failed payments per workspace per hour + 'max_failed_payments_hourly' => 3, + ], + + // Geo-anomaly detection + 'geo' => [ + 'enabled' => env('COMMERCE_FRAUD_GEO', true), + + // Flag if billing country differs from IP country + 'flag_country_mismatch' => true, + + // High-risk countries (require manual review) + 'high_risk_countries' => [], + ], + + // Actions on fraud detection + 'actions' => [ + // Log all fraud signals + 'log' => true, + + // Send notification to admin on high-risk orders + 'notify_admin' => true, + + // Automatically block orders above threshold + 'auto_block' => true, + ], + ], + /* |-------------------------------------------------------------------------- | Invoice PDF Settings diff --git a/tests/Feature/CouponServiceTest.php b/tests/Feature/CouponServiceTest.php index 38d8985..c9e203a 100644 --- a/tests/Feature/CouponServiceTest.php +++ b/tests/Feature/CouponServiceTest.php @@ -48,6 +48,114 @@ beforeEach(function () { }); describe('CouponService', function () { + describe('sanitiseCode() method', function () { + it('trims whitespace from coupon codes', function () { + $result = $this->service->sanitiseCode(' SAVE20 '); + + expect($result)->toBe('SAVE20'); + }); + + it('converts lowercase to uppercase', function () { + $result = $this->service->sanitiseCode('save20'); + + expect($result)->toBe('SAVE20'); + }); + + it('handles mixed case codes', function () { + $result = $this->service->sanitiseCode('SaVe20'); + + expect($result)->toBe('SAVE20'); + }); + + it('allows hyphens in codes', function () { + $result = $this->service->sanitiseCode('SAVE-20-NOW'); + + expect($result)->toBe('SAVE-20-NOW'); + }); + + it('allows underscores in codes', function () { + $result = $this->service->sanitiseCode('SAVE_20_NOW'); + + expect($result)->toBe('SAVE_20_NOW'); + }); + + it('rejects codes shorter than minimum length', function () { + $result = $this->service->sanitiseCode('AB'); + + expect($result)->toBeNull(); + }); + + it('accepts codes at minimum length', function () { + $result = $this->service->sanitiseCode('ABC'); + + expect($result)->toBe('ABC'); + }); + + it('rejects codes longer than maximum length', function () { + $longCode = str_repeat('A', 51); + $result = $this->service->sanitiseCode($longCode); + + expect($result)->toBeNull(); + }); + + it('accepts codes at maximum length', function () { + $maxCode = str_repeat('A', 50); + $result = $this->service->sanitiseCode($maxCode); + + expect($result)->toBe($maxCode); + }); + + it('rejects codes with invalid characters', function () { + $invalidCodes = [ + 'SAVE@20', // @ symbol + 'SAVE 20', // space (after trim) + 'SAVE#20', // hash + 'SAVE!20', // exclamation + 'SAVE$20', // dollar + 'SAVE%20', // percent + 'SAVE&20', // ampersand + 'SAVE*20', // asterisk + 'SAVE.20', // period + "SAVE'20", // single quote + 'SAVE"20', // double quote + 'SAVE;20', // semicolon (SQL injection attempt) + "SAVE'--20", // SQL injection attempt + 'SAVE