Add coupon fraud context and analytics tracking
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
{"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"}
|
||||
{"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-0u0","title":"Paddle migration: design Paddle data mappings for packages/products/prices","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:15.991704177+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:21.629616074+01:00","closed_at":"2026-01-01T15:57:21.629616074+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-1hh","title":"Coupon ops: expand fraud tooling with IP/device reputation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:33.355825022+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:33.355825022+01:00"}
|
||||
{"id":"fotospiel-app-1hh","title":"Coupon ops: expand fraud tooling with IP/device reputation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:33.355825022+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:08.628957239+01:00","closed_at":"2026-01-02T23:28:08.628957239+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-1md","title":"Paddle catalog sync: SyncPackageToPaddle job create/update","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:36.196972205+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:41.854731591+01:00","closed_at":"2026-01-01T16:00:41.854731591+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -75,14 +75,14 @@
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lqp","title":"Integrations health (Paddle/RevenueCat/webhooks)","description":"Health/status dashboard for payment and webhook integrations.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:25.197673148+01:00","updated_at":"2026-01-02T18:45:16.225355969+01:00","closed_at":"2026-01-02T18:45:16.225355969+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:27.722458747+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-nfi","title":"Paddle catalog sync: add Link existing Paddle entity action in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:09.164334657+01:00","created_by":"soeren","updated_at":"2026-01-02T22:15:15.030896509+01:00","closed_at":"2026-01-02T22:15:15.030896509+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-niv","title":"Paddle catalog sync: Package model casts/fillable + factory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:13.646318173+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:19.296543136+01:00","closed_at":"2026-01-01T16:00:19.296543136+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-o4n","title":"Audit PayPal SDK migration doc vs code (PayPal integration missing)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:34.316575518+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:34.316575518+01:00"}
|
||||
{"id":"fotospiel-app-o4n","title":"Audit PayPal SDK migration doc vs code (PayPal integration missing)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:34.316575518+01:00","created_by":"soeren","updated_at":"2026-01-02T22:58:57.08098896+01:00","closed_at":"2026-01-02T22:58:57.08098896+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-o96","title":"Paddle catalog sync: seed sandbox catalog via MCP","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:37.63819424+01:00","created_by":"soeren","updated_at":"2026-01-02T21:05:42.225830987+01:00","closed_at":"2026-01-02T21:05:42.225830987+01:00","close_reason":"Not needed"}
|
||||
{"id":"fotospiel-app-oix","title":"Paddle catalog sync: Playwright smoke for admin sync action","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:31.939471627+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:19.467604455+01:00","closed_at":"2026-01-02T21:11:19.467604455+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-oof","title":"Paddle migration: tenant admin billing pages (Paddle transactions/portal)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:23.581152289+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:29.178846317+01:00","closed_at":"2026-01-01T15:58:29.178846317+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-y1f
|
||||
fotospiel-app-mol
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,10 @@ return [
|
||||
'feature_flag' => env('CHECKOUT_WIZARD_FLAG', 'checkout-wizard-2025'),
|
||||
'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30),
|
||||
'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25),
|
||||
'fraud' => [
|
||||
'window_hours' => env('CHECKOUT_FRAUD_WINDOW_HOURS', 24),
|
||||
'medium_failed' => env('CHECKOUT_FRAUD_MEDIUM_FAILED', 2),
|
||||
'high_failed' => env('CHECKOUT_FRAUD_HIGH_FAILED', 5),
|
||||
'high_failed_ratio' => env('CHECKOUT_FRAUD_HIGH_FAILED_RATIO', 0.5),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('checkout_sessions', function (Blueprint $table) {
|
||||
$table->string('ip_address', 45)->nullable()->after('provider_metadata');
|
||||
$table->string('device_id', 64)->nullable()->after('ip_address');
|
||||
$table->text('user_agent')->nullable()->after('device_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('checkout_sessions', function (Blueprint $table) {
|
||||
$table->dropColumn(['user_agent', 'device_id', 'ip_address']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('coupon_redemptions', function (Blueprint $table) {
|
||||
$table->string('ip_address', 45)->nullable()->after('failure_reason');
|
||||
$table->string('device_id', 64)->nullable()->after('ip_address');
|
||||
$table->text('user_agent')->nullable()->after('device_id');
|
||||
|
||||
$table->index(['ip_address', 'created_at'], 'coupon_redemptions_ip_time_idx');
|
||||
$table->index(['device_id', 'created_at'], 'coupon_redemptions_device_time_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('coupon_redemptions', function (Blueprint $table) {
|
||||
$table->dropIndex('coupon_redemptions_ip_time_idx');
|
||||
$table->dropIndex('coupon_redemptions_device_time_idx');
|
||||
$table->dropColumn(['user_agent', 'device_id', 'ip_address']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,10 @@ import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { CheckoutWizardProvider } from '../WizardContext';
|
||||
import { PaymentStep } from '../steps/PaymentStep';
|
||||
|
||||
vi.mock('@/hooks/useAnalytics', () => ({
|
||||
useAnalytics: () => ({ trackEvent: vi.fn() }),
|
||||
}));
|
||||
|
||||
const basePackage = {
|
||||
id: 1,
|
||||
price: 49,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
||||
|
||||
@@ -184,6 +185,7 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean;
|
||||
|
||||
export const PaymentStep: React.FC = () => {
|
||||
const { t, i18n } = useTranslation('marketing');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const {
|
||||
selectedPackage,
|
||||
nextStep,
|
||||
@@ -283,6 +285,10 @@ export const PaymentStep: React.FC = () => {
|
||||
|
||||
if (RateLimitHelper.isLimited(trimmed)) {
|
||||
setCouponError(t('coupon.errors.too_many_attempts'));
|
||||
trackEvent({
|
||||
category: 'marketing_coupon',
|
||||
action: 'rate_limited',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -299,6 +305,11 @@ export const PaymentStep: React.FC = () => {
|
||||
amount: preview.pricing.formatted.discount,
|
||||
})
|
||||
);
|
||||
trackEvent({
|
||||
category: 'marketing_coupon',
|
||||
action: 'applied',
|
||||
name: preview.coupon.code,
|
||||
});
|
||||
setVoucherExpiry(preview.coupon.expires_at ?? null);
|
||||
setIsGiftVoucher(preview.coupon.code?.toUpperCase().startsWith('GIFT-') ?? false);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -308,11 +319,15 @@ export const PaymentStep: React.FC = () => {
|
||||
setCouponPreview(null);
|
||||
setCouponNotice(null);
|
||||
setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic'));
|
||||
trackEvent({
|
||||
category: 'marketing_coupon',
|
||||
action: 'apply_failed',
|
||||
});
|
||||
RateLimitHelper.bump(trimmed);
|
||||
} finally {
|
||||
setCouponLoading(false);
|
||||
}
|
||||
}, [selectedPackage, t]);
|
||||
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAutoAppliedCoupon.current) {
|
||||
@@ -675,6 +690,13 @@ export const PaymentStep: React.FC = () => {
|
||||
}, [applyCoupon, couponCode, selectedPackage]);
|
||||
|
||||
const handleRemoveCoupon = useCallback(() => {
|
||||
if (couponPreview?.coupon.code) {
|
||||
trackEvent({
|
||||
category: 'marketing_coupon',
|
||||
action: 'removed',
|
||||
name: couponPreview.coupon.code,
|
||||
});
|
||||
}
|
||||
setCouponPreview(null);
|
||||
setCouponNotice(null);
|
||||
setCouponError(null);
|
||||
@@ -682,7 +704,7 @@ export const PaymentStep: React.FC = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('preferred_coupon_code');
|
||||
}
|
||||
}, []);
|
||||
}, [couponPreview, trackEvent]);
|
||||
|
||||
const openWithdrawalModal = useCallback(async () => {
|
||||
setShowWithdrawalModal(true);
|
||||
|
||||
101
tests/Unit/CouponRedemptionServiceTest.php
Normal file
101
tests/Unit/CouponRedemptionServiceTest.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\CouponRedemption;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CouponRedemptionServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_records_request_context_and_fraud_snapshot(): void
|
||||
{
|
||||
config()->set('checkout.fraud.window_hours', 12);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 120,
|
||||
]);
|
||||
$coupon = Coupon::factory()->create([
|
||||
'code' => 'SAVE10',
|
||||
'paddle_discount_id' => 'dsc_123',
|
||||
]);
|
||||
$coupon->packages()->attach($package);
|
||||
|
||||
$session = CheckoutSession::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'package_snapshot' => [
|
||||
'id' => $package->id,
|
||||
'name' => $package->name,
|
||||
'type' => $package->type,
|
||||
'price' => (float) $package->price,
|
||||
'currency' => $package->currency ?? 'EUR',
|
||||
'features' => $package->features,
|
||||
'limits' => $package->limits,
|
||||
],
|
||||
'coupon_id' => $coupon->id,
|
||||
'coupon_code' => $coupon->code,
|
||||
'coupon_snapshot' => [
|
||||
'coupon' => [
|
||||
'id' => $coupon->id,
|
||||
'code' => $coupon->code,
|
||||
'type' => $coupon->type?->value,
|
||||
],
|
||||
'pricing' => [
|
||||
'subtotal' => 120,
|
||||
'discount' => 12,
|
||||
'total' => 108,
|
||||
],
|
||||
],
|
||||
'status' => CheckoutSession::STATUS_PROCESSING,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'currency' => 'EUR',
|
||||
'amount_subtotal' => 120,
|
||||
'amount_total' => 108,
|
||||
'amount_discount' => 12,
|
||||
'ip_address' => '203.0.113.10',
|
||||
'device_id' => 'device-123',
|
||||
'user_agent' => 'Mozilla/5.0',
|
||||
]);
|
||||
|
||||
$giftVouchers = $this->createMock(GiftVoucherService::class);
|
||||
$giftVouchers->expects($this->once())
|
||||
->method('markRedeemed')
|
||||
->with($this->callback(fn (Coupon $provided) => $provided->is($coupon)), 'txn_123');
|
||||
|
||||
$service = new CouponRedemptionService($giftVouchers);
|
||||
$service->recordSuccess($session, ['id' => 'txn_123']);
|
||||
|
||||
$this->assertDatabaseHas('coupon_redemptions', [
|
||||
'coupon_id' => $coupon->id,
|
||||
'checkout_session_id' => $session->id,
|
||||
'ip_address' => '203.0.113.10',
|
||||
'device_id' => 'device-123',
|
||||
'user_agent' => 'Mozilla/5.0',
|
||||
'status' => CouponRedemption::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
$redemption = CouponRedemption::query()
|
||||
->where('checkout_session_id', $session->id)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($redemption);
|
||||
$this->assertSame(12, $redemption->metadata['fraud']['window_hours'] ?? null);
|
||||
$this->assertSame('203.0.113.10', $redemption->metadata['fraud']['ip']['value'] ?? null);
|
||||
$this->assertSame('device-123', $redemption->metadata['fraud']['device']['value'] ?? null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user