Add coupon fraud context and analytics tracking
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 23:31:26 +01:00
parent 75d862748b
commit 41ed682fbe
16 changed files with 461 additions and 21 deletions

View File

@@ -32,6 +32,9 @@ class CheckoutSessionService
if ($existing) {
$this->refreshExpiration($existing);
if ($this->applyRequestContext($existing, $context)) {
$existing->save();
}
return $existing;
}
@@ -50,6 +53,7 @@ class CheckoutSessionService
$session->locale = $context['locale'] ?? app()->getLocale();
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$session->status_history = [];
$this->applyRequestContext($session, $context);
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'session_created');
$session->save();
@@ -218,6 +222,27 @@ class CheckoutSessionService
$session->status_history = $history;
}
protected function applyRequestContext(CheckoutSession $session, array $context): bool
{
$updated = false;
foreach (['ip_address', 'device_id', 'user_agent'] as $key) {
if (! array_key_exists($key, $context)) {
continue;
}
$value = $context[$key];
if (! is_string($value) || $value === '') {
continue;
}
$session->{$key} = $value;
$updated = true;
}
return $updated;
}
protected function packageSnapshot(Package $package): array
{
return [

View File

@@ -9,7 +9,21 @@ use Illuminate\Support\Arr;
class CouponRedemptionService
{
public function __construct(private readonly GiftVoucherService $giftVouchers) {}
private int $fraudWindowHours;
private int $fraudMediumFailed;
private int $fraudHighFailed;
private float $fraudHighFailedRatio;
public function __construct(private readonly GiftVoucherService $giftVouchers)
{
$this->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
{
@@ -19,7 +33,10 @@ class CouponRedemptionService
$transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id;
$values = [
$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,
@@ -31,9 +48,10 @@ class CouponRedemptionService
'metadata' => array_filter([
'session_snapshot' => $session->coupon_snapshot,
'payload' => $payload,
'fraud' => $fraudSnapshot,
]),
'redeemed_at' => now(),
];
]);
CouponRedemption::query()->updateOrCreate(
[
@@ -54,12 +72,15 @@ class CouponRedemptionService
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,
@@ -70,8 +91,87 @@ class CouponRedemptionService
'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<string, mixed>|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';
}
}