178 lines
5.9 KiB
PHP
178 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Coupons;
|
|
|
|
use App\Models\CheckoutSession;
|
|
use App\Models\CouponRedemption;
|
|
use App\Services\GiftVouchers\GiftVoucherService;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class CouponRedemptionService
|
|
{
|
|
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
|
|
{
|
|
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<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';
|
|
}
|
|
}
|