diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9ecf2c6..9500346 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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)"} diff --git a/.beads/last-touched b/.beads/last-touched index 4f12500..e4c80e9e 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-y1f +fotospiel-app-mol diff --git a/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php b/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php index 1c6f597..86eae8a 100644 --- a/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php +++ b/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php @@ -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', + }; + } } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 86ac630..974ef1b 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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); diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index ea64ed7..3c3f1b3 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -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); diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php index 612692e..66466e2 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -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); diff --git a/app/Models/CouponRedemption.php b/app/Models/CouponRedemption.php index d0b996b..a50b659 100644 --- a/app/Models/CouponRedemption.php +++ b/app/Models/CouponRedemption.php @@ -26,6 +26,9 @@ class CouponRedemption extends Model 'paddle_transaction_id', 'status', 'failure_reason', + 'ip_address', + 'device_id', + 'user_agent', 'amount_discounted', 'currency', 'metadata', diff --git a/app/Services/Checkout/CheckoutSessionService.php b/app/Services/Checkout/CheckoutSessionService.php index da6e0b4..7746ac0 100644 --- a/app/Services/Checkout/CheckoutSessionService.php +++ b/app/Services/Checkout/CheckoutSessionService.php @@ -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 [ diff --git a/app/Services/Coupons/CouponRedemptionService.php b/app/Services/Coupons/CouponRedemptionService.php index 7aa14bf..a9b37cf 100644 --- a/app/Services/Coupons/CouponRedemptionService.php +++ b/app/Services/Coupons/CouponRedemptionService.php @@ -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|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'; + } } diff --git a/app/Support/CheckoutRequestContext.php b/app/Support/CheckoutRequestContext.php new file mode 100644 index 0000000..abe8feb --- /dev/null +++ b/app/Support/CheckoutRequestContext.php @@ -0,0 +1,51 @@ + $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; + } +} diff --git a/config/checkout.php b/config/checkout.php index 4bbae5a..5d64464 100644 --- a/config/checkout.php +++ b/config/checkout.php @@ -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), + ], ]; diff --git a/database/migrations/2026_01_02_231420_add_request_context_to_checkout_sessions_table.php b/database/migrations/2026_01_02_231420_add_request_context_to_checkout_sessions_table.php new file mode 100644 index 0000000..7cb8092 --- /dev/null +++ b/database/migrations/2026_01_02_231420_add_request_context_to_checkout_sessions_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_01_02_231438_add_request_context_to_coupon_redemptions_table.php b/database/migrations/2026_01_02_231438_add_request_context_to_coupon_redemptions_table.php new file mode 100644 index 0000000..71a32c8 --- /dev/null +++ b/database/migrations/2026_01_02_231438_add_request_context_to_coupon_redemptions_table.php @@ -0,0 +1,35 @@ +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']); + }); + } +}; diff --git a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx index 64b9b0c..1e6bb10 100644 --- a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx +++ b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx @@ -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, diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index a4d32fe..d5e894b 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -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; 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); diff --git a/tests/Unit/CouponRedemptionServiceTest.php b/tests/Unit/CouponRedemptionServiceTest.php new file mode 100644 index 0000000..f8bb6c0 --- /dev/null +++ b/tests/Unit/CouponRedemptionServiceTest.php @@ -0,0 +1,101 @@ +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); + } +}