Add coupon fraud context and analytics tracking
This commit is contained in:
@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RedemptionsRelationManager extends RelationManager
|
||||
{
|
||||
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('ip_address')
|
||||
->label(__('IP'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('device_id')
|
||||
->label(__('Device'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user_agent')
|
||||
->label(__('User agent'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->wrap(),
|
||||
TextColumn::make('fraud_ip')
|
||||
->label(__('IP reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.ip')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.ip.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('fraud_device')
|
||||
->label(__('Device reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.device')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.device.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{risk?: string, recent_failed?: int, recent_total?: int}|null $snapshot
|
||||
*/
|
||||
private static function formatReputation(?array $snapshot): string
|
||||
{
|
||||
if (! $snapshot) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$risk = Str::headline($snapshot['risk'] ?? 'unknown');
|
||||
$failed = (int) ($snapshot['recent_failed'] ?? 0);
|
||||
$total = (int) ($snapshot['recent_total'] ?? 0);
|
||||
|
||||
return sprintf('%s (%d/%d)', $risk, $failed, $total);
|
||||
}
|
||||
|
||||
private static function riskColor(?string $risk): string
|
||||
{
|
||||
return match ($risk) {
|
||||
'high' => 'danger',
|
||||
'medium' => 'warning',
|
||||
'low' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -226,10 +227,13 @@ class CheckoutController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$session = $sessions->createOrResume($user, $package, [
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]);
|
||||
$session = $sessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]
|
||||
));
|
||||
|
||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -203,9 +204,12 @@ class MarketingController extends Controller
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
}
|
||||
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $tenant,
|
||||
]
|
||||
));
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -38,9 +39,12 @@ class PaddleCheckoutController extends Controller
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $tenant,
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ class CouponRedemption extends Model
|
||||
'paddle_transaction_id',
|
||||
'status',
|
||||
'failure_reason',
|
||||
'ip_address',
|
||||
'device_id',
|
||||
'user_agent',
|
||||
'amount_discounted',
|
||||
'currency',
|
||||
'metadata',
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Support/CheckoutRequestContext.php
Normal file
51
app/Support/CheckoutRequestContext.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CheckoutRequestContext
|
||||
{
|
||||
/**
|
||||
* @return array{ip_address?: string|null, device_id?: string|null, user_agent?: string|null}
|
||||
*/
|
||||
public static function fromRequest(Request $request): array
|
||||
{
|
||||
return array_filter([
|
||||
'ip_address' => $request->ip(),
|
||||
'device_id' => static::sanitizeDeviceId(
|
||||
$request->header('X-Device-Id', $request->input('device_id'))
|
||||
),
|
||||
'user_agent' => static::shortenUserAgent($request->userAgent()),
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
private static function sanitizeDeviceId(?string $deviceId): ?string
|
||||
{
|
||||
if (! $deviceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? '';
|
||||
|
||||
if ($cleaned === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::substr($cleaned, 0, 64);
|
||||
}
|
||||
|
||||
private static function shortenUserAgent(?string $userAgent): ?string
|
||||
{
|
||||
if (! $userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::length($userAgent) > 1024) {
|
||||
return Str::substr($userAgent, 0, 1024);
|
||||
}
|
||||
|
||||
return $userAgent;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user