fraudWindowHours = (int) config('checkout.fraud.window_hours', 24); $this->fraudMediumFailed = (int) config('checkout.fraud.medium_failed', 2); $this->fraudHighFailed = (int) config('checkout.fraud.high_failed', 5); $this->fraudHighFailedRatio = (float) config('checkout.fraud.high_failed_ratio', 0.5); } public function recordSuccess(CheckoutSession $session, array $payload = []): void { if (! $session->coupon_id) { return; } $transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id; $context = $this->resolveRequestContext($session); $fraudSnapshot = $this->buildFraudSnapshot($context); $values = array_merge($context, [ 'tenant_id' => $session->tenant_id, 'user_id' => $session->user_id, 'package_id' => $session->package_id, 'paddle_transaction_id' => $transactionId, 'status' => CouponRedemption::STATUS_SUCCESS, 'failure_reason' => null, 'amount_discounted' => $session->amount_discount, 'currency' => $session->currency ?? 'EUR', 'metadata' => array_filter([ 'session_snapshot' => $session->coupon_snapshot, 'payload' => $payload, 'fraud' => $fraudSnapshot, ]), 'redeemed_at' => now(), ]); CouponRedemption::query()->updateOrCreate( [ 'coupon_id' => $session->coupon_id, 'checkout_session_id' => $session->id, ], $values, ); $session->coupon?->increment('redemptions_count'); $this->giftVouchers->markRedeemed($session->coupon, $transactionId); } public function recordFailure(CheckoutSession $session, string $reason): void { if (! $session->coupon_id) { return; } $context = $this->resolveRequestContext($session); $fraudSnapshot = $this->buildFraudSnapshot($context); CouponRedemption::query()->updateOrCreate( [ 'coupon_id' => $session->coupon_id, 'checkout_session_id' => $session->id, ], array_merge($context, [ 'tenant_id' => $session->tenant_id, 'user_id' => $session->user_id, 'package_id' => $session->package_id, 'paddle_transaction_id' => $session->paddle_transaction_id, 'status' => CouponRedemption::STATUS_FAILED, 'failure_reason' => $reason, 'amount_discounted' => $session->amount_discount, 'currency' => $session->currency ?? 'EUR', 'metadata' => array_filter([ 'session_snapshot' => $session->coupon_snapshot, 'fraud' => $fraudSnapshot, ]), ]), ); } /** * @return array{ip_address?: string|null, device_id?: string|null, user_agent?: string|null} */ private function resolveRequestContext(CheckoutSession $session): array { return array_filter([ 'ip_address' => $session->ip_address, 'device_id' => $session->device_id, 'user_agent' => $session->user_agent, ], static fn ($value) => $value !== null && $value !== ''); } /** * @param array{ip_address?: string|null, device_id?: string|null, user_agent?: string|null} $context * @return array|null */ private function buildFraudSnapshot(array $context): ?array { if ($context === []) { return null; } return array_filter([ 'window_hours' => $this->fraudWindowHours, 'ip' => $this->buildReputation('ip_address', $context['ip_address'] ?? null), 'device' => $this->buildReputation('device_id', $context['device_id'] ?? null), ], static fn ($value) => $value !== null); } /** * @return array{value: string, recent_success: int, recent_failed: int, recent_total: int, risk: string, last_seen_at: string|null}|null */ private function buildReputation(string $column, ?string $value): ?array { if (! $value) { return null; } $since = now()->subHours($this->fraudWindowHours); $baseQuery = CouponRedemption::query() ->where($column, $value) ->where('created_at', '>=', $since); $success = (clone $baseQuery)->where('status', CouponRedemption::STATUS_SUCCESS)->count(); $failed = (clone $baseQuery)->where('status', CouponRedemption::STATUS_FAILED)->count(); $lastSeen = (clone $baseQuery)->latest('created_at')->first()?->created_at; return [ 'value' => $value, 'recent_success' => $success, 'recent_failed' => $failed, 'recent_total' => $success + $failed, 'risk' => $this->resolveRiskLevel($success, $failed), 'last_seen_at' => $lastSeen?->toIso8601String(), ]; } private function resolveRiskLevel(int $success, int $failed): string { $total = $success + $failed; if ($total === 0) { return 'unknown'; } $failedRatio = $failed / $total; if ($failed >= $this->fraudHighFailed || $failedRatio >= $this->fraudHighFailedRatio) { return 'high'; } if ($failed >= $this->fraudMediumFailed) { return 'medium'; } return 'low'; } }