security: complete rate limiting and fraud service implementation (P1-040)
Add missing files from P1-040/P1-041 implementation: - CheckoutRateLimitException for 429 responses when rate limit exceeded - FraudAssessment data object for fraud scoring results - FraudService for velocity checks and Stripe Radar integration - Register services in Boot.php - Add fraud detection configuration in config.php - Add CouponServiceTest for input sanitisation The CheckoutRateLimiter (already tracked) is now properly integrated with the exception handling, and the FraudService provides defence-in-depth with velocity-based and geo-anomaly detection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
26e30cca83
commit
2e5cd499b9
6 changed files with 805 additions and 0 deletions
2
Boot.php
2
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\PaymentMethodService::class);
|
||||||
$this->app->singleton(\Core\Mod\Commerce\Services\UsageBillingService::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\ReferralService::class);
|
||||||
|
$this->app->singleton(\Core\Mod\Commerce\Services\FraudService::class);
|
||||||
|
$this->app->singleton(\Core\Mod\Commerce\Services\CheckoutRateLimiter::class);
|
||||||
|
|
||||||
// Payment Gateways
|
// Payment Gateways
|
||||||
$this->app->singleton('commerce.gateway.btcpay', function ($app) {
|
$this->app->singleton('commerce.gateway.btcpay', function ($app) {
|
||||||
|
|
|
||||||
82
Data/FraudAssessment.php
Normal file
82
Data/FraudAssessment.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Commerce\Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraud assessment result.
|
||||||
|
*
|
||||||
|
* Contains risk level, signals, and recommended actions based on
|
||||||
|
* fraud detection analysis from Stripe Radar and internal checks.
|
||||||
|
*/
|
||||||
|
class FraudAssessment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $riskLevel,
|
||||||
|
public readonly array $signals,
|
||||||
|
public readonly string $source,
|
||||||
|
public readonly ?int $stripeRiskScore = null,
|
||||||
|
public readonly bool $shouldBlock = false,
|
||||||
|
public readonly bool $shouldReview = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a not-assessed result (fraud detection disabled).
|
||||||
|
*/
|
||||||
|
public static function notAssessed(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
riskLevel: 'not_assessed',
|
||||||
|
signals: [],
|
||||||
|
source: 'none',
|
||||||
|
shouldBlock: false,
|
||||||
|
shouldReview: false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a high-risk assessment.
|
||||||
|
*/
|
||||||
|
public function isHighRisk(): bool
|
||||||
|
{
|
||||||
|
return $this->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Exceptions/CheckoutRateLimitException.php
Normal file
44
Exceptions/CheckoutRateLimitException.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Commerce\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when checkout rate limit is exceeded.
|
||||||
|
*
|
||||||
|
* Prevents card testing attacks by limiting checkout session creation.
|
||||||
|
*/
|
||||||
|
class CheckoutRateLimitException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new checkout rate limit exception.
|
||||||
|
*
|
||||||
|
* @param string $message The error message
|
||||||
|
* @param int $retryAfter Seconds until rate limit resets
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Too many checkout attempts. Please wait before trying again.',
|
||||||
|
protected int $retryAfter = 0
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of seconds until the rate limit resets.
|
||||||
|
*/
|
||||||
|
public function getRetryAfter(): int
|
||||||
|
{
|
||||||
|
return $this->retryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of minutes until the rate limit resets (rounded up).
|
||||||
|
*/
|
||||||
|
public function getRetryAfterMinutes(): int
|
||||||
|
{
|
||||||
|
return (int) ceil($this->retryAfter / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
430
Services/FraudService.php
Normal file
430
Services/FraudService.php
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Commerce\Services;
|
||||||
|
|
||||||
|
use Core\Mod\Commerce\Data\FraudAssessment;
|
||||||
|
use Core\Mod\Commerce\Models\Order;
|
||||||
|
use Core\Mod\Commerce\Models\Payment;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraud detection and scoring service.
|
||||||
|
*
|
||||||
|
* Integrates with Stripe Radar for card payments and provides
|
||||||
|
* velocity-based and geo-based fraud detection for all payment types.
|
||||||
|
*/
|
||||||
|
class FraudService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Risk level constants.
|
||||||
|
*/
|
||||||
|
public const RISK_HIGHEST = 'highest';
|
||||||
|
|
||||||
|
public const RISK_ELEVATED = 'elevated';
|
||||||
|
|
||||||
|
public const RISK_NORMAL = 'normal';
|
||||||
|
|
||||||
|
public const RISK_NOT_ASSESSED = 'not_assessed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assess fraud risk for an order before checkout.
|
||||||
|
*
|
||||||
|
* This performs velocity checks and geo-anomaly detection.
|
||||||
|
* Stripe Radar assessment happens after payment attempt.
|
||||||
|
*/
|
||||||
|
public function assessOrder(Order $order): FraudAssessment
|
||||||
|
{
|
||||||
|
if (! config('commerce.fraud.enabled', true)) {
|
||||||
|
return FraudAssessment::notAssessed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$signals = [];
|
||||||
|
$riskLevel = self::RISK_NORMAL;
|
||||||
|
|
||||||
|
// Velocity checks
|
||||||
|
if (config('commerce.fraud.velocity.enabled', true)) {
|
||||||
|
$velocitySignals = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
config.php
68
config.php
|
|
@ -280,6 +280,74 @@ return [
|
||||||
'session_ttl' => 30, // Minutes before checkout session expires
|
'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
|
| Invoice PDF Settings
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,114 @@ beforeEach(function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CouponService', 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<script>', // XSS attempt
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($invalidCodes as $code) {
|
||||||
|
$result = $this->service->sanitiseCode($code);
|
||||||
|
expect($result)->toBeNull("Expected null for code: {$code}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty string', function () {
|
||||||
|
$result = $this->service->sanitiseCode('');
|
||||||
|
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only string', function () {
|
||||||
|
$result = $this->service->sanitiseCode(' ');
|
||||||
|
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidCodeFormat() method', function () {
|
||||||
|
it('returns true for valid codes', function () {
|
||||||
|
expect($this->service->isValidCodeFormat('SAVE20'))->toBeTrue()
|
||||||
|
->and($this->service->isValidCodeFormat('save-20-now'))->toBeTrue()
|
||||||
|
->and($this->service->isValidCodeFormat('CODE_123'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for invalid codes', function () {
|
||||||
|
expect($this->service->isValidCodeFormat('AB'))->toBeFalse() // too short
|
||||||
|
->and($this->service->isValidCodeFormat('SAVE@20'))->toBeFalse() // invalid char
|
||||||
|
->and($this->service->isValidCodeFormat(''))->toBeFalse(); // empty
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findByCode() method', function () {
|
describe('findByCode() method', function () {
|
||||||
it('finds coupon by code (case insensitive)', function () {
|
it('finds coupon by code (case insensitive)', function () {
|
||||||
$coupon = $this->service->findByCode('save20');
|
$coupon = $this->service->findByCode('save20');
|
||||||
|
|
@ -61,6 +169,20 @@ describe('CouponService', function () {
|
||||||
|
|
||||||
expect($coupon)->toBeNull();
|
expect($coupon)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid code format without hitting database', function () {
|
||||||
|
// These should return null due to invalid format, not because they don't exist
|
||||||
|
expect($this->service->findByCode('AB'))->toBeNull() // too short
|
||||||
|
->and($this->service->findByCode('CODE@123'))->toBeNull(); // invalid char
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitises code before lookup', function () {
|
||||||
|
// Should find the coupon even with whitespace and different case
|
||||||
|
$coupon = $this->service->findByCode(' save20 ');
|
||||||
|
|
||||||
|
expect($coupon)->not->toBeNull()
|
||||||
|
->and($coupon->code)->toBe('SAVE20');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validate() method', function () {
|
describe('validate() method', function () {
|
||||||
|
|
@ -191,6 +313,63 @@ describe('CouponService', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateByCode() method', function () {
|
||||||
|
it('validates coupon by code with sanitisation', function () {
|
||||||
|
$result = $this->service->validateByCode(
|
||||||
|
' save20 ', // lowercase with whitespace
|
||||||
|
$this->workspace,
|
||||||
|
$this->package
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isValid())->toBeTrue()
|
||||||
|
->and($result->getCoupon()->code)->toBe('SAVE20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid result for code that is too short', function () {
|
||||||
|
$result = $this->service->validateByCode(
|
||||||
|
'AB',
|
||||||
|
$this->workspace,
|
||||||
|
$this->package
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isValid())->toBeFalse()
|
||||||
|
->and($result->getMessage())->toBe('Invalid coupon code format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid result for code with invalid characters', function () {
|
||||||
|
$result = $this->service->validateByCode(
|
||||||
|
'CODE@123',
|
||||||
|
$this->workspace,
|
||||||
|
$this->package
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isValid())->toBeFalse()
|
||||||
|
->and($result->getMessage())->toBe('Invalid coupon code format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid result for SQL injection attempt', function () {
|
||||||
|
$result = $this->service->validateByCode(
|
||||||
|
"'; DROP TABLE coupons; --",
|
||||||
|
$this->workspace,
|
||||||
|
$this->package
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isValid())->toBeFalse()
|
||||||
|
->and($result->getMessage())->toBe('Invalid coupon code format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid result for non-existent but valid format code', function () {
|
||||||
|
$result = $this->service->validateByCode(
|
||||||
|
'NONEXISTENT',
|
||||||
|
$this->workspace,
|
||||||
|
$this->package
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isValid())->toBeFalse()
|
||||||
|
->and($result->getMessage())->toBe('Invalid coupon code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('recordUsage() method', function () {
|
describe('recordUsage() method', function () {
|
||||||
it('records coupon usage', function () {
|
it('records coupon usage', function () {
|
||||||
$order = Order::create([
|
$order = Order::create([
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue