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

@@ -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)"}

View File

@@ -1 +1 @@
fotospiel-app-y1f
fotospiel-app-mol

View File

@@ -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',
};
}
}

View File

@@ -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, [
$session = $sessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $user->tenant,
'locale' => $validated['locale'] ?? null,
]);
]
));
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);

View File

@@ -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, [
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
]);
]
));
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);

View File

@@ -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, [
$session = $this->sessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
]);
]
));
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);

View File

@@ -26,6 +26,9 @@ class CouponRedemption extends Model
'paddle_transaction_id',
'status',
'failure_reason',
'ip_address',
'device_id',
'user_agent',
'amount_discounted',
'currency',
'metadata',

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';
}
}

View 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;
}
}

View File

@@ -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),
],
];

View File

@@ -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']);
});
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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,

View File

@@ -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);

View 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);
}
}