middleware('admin'); } /** * Entwickler-Ansicht mit allen Incidents, Live-Transaktionen und Logs. */ public function index(): View { $stats = $this->getIncidentStats(); $transactionStats = $this->getTransactionStats(7); $openIncidents = PaymentIncident::open()->with('activities')->orderBy('detected_at', 'desc')->get(); $allIncidents = PaymentIncident::with('activities')->orderBy('detected_at', 'desc')->paginate(20); $providerStats = $this->getProviderStats(); $uptimeStats = $this->getUptimeStats(); return view('admin.payment-dashboard.index', compact( 'stats', 'transactionStats', 'openIncidents', 'allIncidents', 'providerStats', 'uptimeStats', )); } /** * GF-Ansicht: vereinfacht, nur lesen. Nur für Super-Admins (admin >= 3). */ public function management(): View { abort_unless(auth()->user()->isSuperAdmin(), 403); $stats = $this->getIncidentStats(); $transactionStats = $this->getTransactionStats(30); $openIncidents = PaymentIncident::open()->orderBy('detected_at', 'desc')->get(); $recentIncidents = PaymentIncident::orderBy('detected_at', 'desc')->take(10)->get(); $providerStats = $this->getProviderStats(); return view('admin.payment-dashboard.management', compact( 'stats', 'transactionStats', 'openIncidents', 'recentIncidents', 'providerStats', )); } /** * Incident-Detail mit Timeline. */ public function show(PaymentIncident $incident): View { $incident->load('activities'); return view('admin.payment-dashboard.show', compact('incident')); } /** * Neuen Incident anlegen. */ public function store(StorePaymentIncidentRequest $request): RedirectResponse { $incident = PaymentIncident::create($request->validated()); IncidentActivity::create([ 'incident_id' => $incident->id, 'type' => 'note', 'title' => 'Incident eröffnet', 'content' => $request->validated('description'), 'author' => auth()->user()->name ?? 'System', ]); if ($incident->severity === 'critical') { MyLog::writeLog( 'payment', 'error', 'Kritischer Zahlungs-Incident eröffnet: '.$incident->title, $request->validated(), ); Mail::to(config('app.exception_mail')) ->queue(new PaymentIncidentAlert($incident)); } $this->invalidateIncidentCache(); return redirect()->route('admin.payment-dashboard.index') ->with('success', 'Incident erfolgreich angelegt.'); } /** * Aktivität zu einem Incident hinzufügen. */ public function addActivity(AddIncidentActivityRequest $request, PaymentIncident $incident): RedirectResponse { IncidentActivity::create([ 'incident_id' => $incident->id, 'type' => $request->validated('type'), 'title' => $request->validated('title'), 'content' => $request->validated('content'), 'author' => auth()->user()->name ?? 'System', ]); if ($incident->status === 'open') { $incident->update(['status' => 'in_progress']); $this->invalidateIncidentCache(); } return back()->with('success', 'Aktivität hinzugefügt.'); } /** * Status eines Incidents ändern. */ public function updateStatus(UpdateIncidentStatusRequest $request, PaymentIncident $incident): RedirectResponse { $oldLabel = $incident->status_label; $incident->update([ 'status' => $request->status, 'resolved_at' => in_array($request->status, ['resolved', 'closed']) ? now() : null, ]); IncidentActivity::create([ 'incident_id' => $incident->id, 'type' => 'status_change', 'title' => 'Status geändert: '.$oldLabel.' → '.$incident->fresh()->status_label, 'author' => auth()->user()->name ?? 'System', ]); $this->invalidateIncidentCache(); return back()->with('success', 'Status aktualisiert.'); } /** * Live-Transaktionsdaten aus payment_transactions. */ public function transactions(): View { $days = request()->integer('days', 7); $txactionFilter = request()->string('txaction', ''); $query = PaymentTransaction::with('shopping_payment') ->orderBy('created_at', 'desc'); if ($days > 0) { $query->where('created_at', '>=', now()->subDays($days)); } if ($txactionFilter && $txactionFilter !== '') { $query->where('txaction', $txactionFilter); } $transactions = $query->paginate(30)->withQueryString(); $transactionStats = $this->getTransactionStats($days > 0 ? $days : 30); return view('admin.payment-dashboard.transactions', compact('transactions', 'transactionStats', 'days', 'txactionFilter')); } /** * Checkout-Funnel-Tracking: Übersicht der 5 Funnel-Schritte. */ public function funnel(): View { $days = request()->integer('days', 30); $since = $days > 0 ? now()->subDays($days) : null; $filterEvent = request()->string('event', ''); $filterStatus = request()->string('return_status', ''); $filterSource = request()->string('source', ''); // ── Funnel-Schritte ────────────────────────────────────────────────── $steps = ['checkout_visited', 'form_submitted', 'payment_initiated', 'payment_returned', 'payment_confirmed']; $counts = CheckoutFunnelEvent::query() ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->selectRaw('event, COUNT(*) as count') ->groupBy('event') ->pluck('count', 'event'); $funnelSteps = array_map(fn (string $step) => [ 'event' => $step, 'label' => CheckoutFunnelEvent::eventLabels()[$step], 'count' => (int) ($counts[$step] ?? 0), ], $steps); foreach ($funnelSteps as $i => &$step) { if ($i === 0 || $funnelSteps[$i - 1]['count'] === 0) { $step['conversion'] = null; } else { $step['conversion'] = round($step['count'] / $funnelSteps[$i - 1]['count'] * 100, 1); } } unset($step); // ── Rückkehr-Status ─────────────────────────────────────────────────── $returnStats = CheckoutFunnelEvent::query() ->where('event', 'payment_returned') ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->selectRaw('return_status, COUNT(*) as count') ->groupBy('return_status') ->pluck('count', 'return_status'); // ── Quelle (Source) Breakdown ───────────────────────────────────────── // Klassifizierung über Domain-Präfix direkt in SQL $sourceRaw = CheckoutFunnelEvent::query() ->where('event', 'checkout_visited') ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->selectRaw(" CASE WHEN domain LIKE 'my.%' THEN 'salescenter' WHEN domain LIKE 'in.%' THEN 'beraterzugang' WHEN domain LIKE '%.test' THEN 'testserver' WHEN domain LIKE '%.care' OR domain LIKE '%.shop' THEN 'kundenshop' ELSE 'unbekannt' END AS source_type, COUNT(*) as count ") ->groupBy('source_type') ->pluck('count', 'source_type'); $sourceBreakdown = collect(CheckoutFunnelEvent::sourceLabels()) ->map(fn ($label, $key) => [ 'label' => $label, 'count' => (int) ($sourceRaw[$key] ?? 0), ]) ->filter(fn ($s) => $s['count'] > 0); // ── Ereignisse (gefiltert, paginiert) ───────────────────────────────── $eventsQuery = CheckoutFunnelEvent::with(['shopping_order', 'consultant']) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->when($filterEvent !== '', fn ($q) => $q->where('event', $filterEvent)) ->when($filterStatus !== '', fn ($q) => $q->where('return_status', $filterStatus)) ->when($filterSource !== '', function ($q) use ($filterSource) { return match ($filterSource) { 'salescenter' => $q->where('domain', 'like', 'my.%'), 'beraterzugang' => $q->where('domain', 'like', 'in.%'), 'testserver' => $q->where('domain', 'like', '%.test'), 'kundenshop' => $q->where(fn ($s) => $s->where('domain', 'like', '%.care')->orWhere('domain', 'like', '%.shop')), default => $q, }; }) ->latest(); $recentEvents = $eventsQuery->paginate(50)->withQueryString(); return view('admin.payment-dashboard.funnel', compact( 'funnelSteps', 'returnStats', 'sourceBreakdown', 'recentEvents', 'days', 'filterEvent', 'filterStatus', 'filterSource', )); } /** * Abbruch-Analyse: Orders ohne Payment, abgebrochene und nicht bestätigte Payments. */ public function abandoned(): View { $days = request()->integer('days', 30); $since = $days > 0 ? now()->subDays($days) : null; // Tab 1: Orders mit txaction='prev' ohne ShoppingPayment – Zahlung nie gestartet // Mindestens 30 Minuten alt, damit laufende Checkouts nicht erscheinen $ordersWithoutPayment = ShoppingOrder::with(['auth_user', 'shopping_user']) ->where('txaction', 'prev') ->doesntHave('shopping_payments') ->where('created_at', '<=', now()->subMinutes(30)) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->orderBy('created_at', 'desc') ->paginate(20, ['*'], 'page_no_payment') ->withQueryString(); // Tab 2: ShoppingPayments mit status = 'cancel' oder 'error' $cancelledPayments = ShoppingPayment::with([ 'shopping_order.auth_user', 'shopping_order.shopping_user', 'payment_transactions', ]) ->whereIn('status', ['cancel', 'error']) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->orderBy('created_at', 'desc') ->paginate(20, ['*'], 'page_cancelled') ->withQueryString(); // Tab 3: ShoppingPayments ohne status (kein Callback) – mindestens 2 Stunden alt $pendingPayments = ShoppingPayment::with([ 'shopping_order.auth_user', 'shopping_order.shopping_user', 'payment_transactions', ]) ->whereNull('status') ->doesntHave('payment_transactions') ->where('created_at', '<=', now()->subHours(2)) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->orderBy('created_at', 'desc') ->paginate(20, ['*'], 'page_pending') ->withQueryString(); $abandonedStats = [ 'no_payment' => ShoppingOrder::where('txaction', 'prev') ->doesntHave('shopping_payments') ->where('created_at', '<=', now()->subMinutes(30)) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->count(), 'cancelled' => ShoppingPayment::whereIn('status', ['cancel', 'error']) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->count(), 'no_callback' => ShoppingPayment::whereNull('status') ->doesntHave('payment_transactions') ->where('created_at', '<=', now()->subHours(2)) ->when($since, fn ($q) => $q->where('created_at', '>=', $since)) ->count(), ]; return view('admin.payment-dashboard.abandoned', compact( 'ordersWithoutPayment', 'cancelledPayments', 'pendingPayments', 'abandonedStats', 'days', )); } /** * Zahlung-zentrierte Übersicht: ShoppingPayments mit Transaktionen und Bestellung. */ public function payments(): View { $days = request()->integer('days', 7); $statusFilter = request()->string('txaction', '')->toString(); $modeFilter = request()->string('mode', '')->toString(); $query = ShoppingPayment::with([ 'payment_transactions' => fn ($q) => $q->orderBy('created_at'), 'shopping_order.auth_user', 'shopping_order.shopping_user', ])->orderBy('created_at', 'desc'); if ($days > 0) { $query->where('created_at', '>=', now()->subDays($days)); } if ($statusFilter !== '') { $query->where('txaction', $statusFilter); } if ($modeFilter !== '') { $query->where('mode', $modeFilter); } $payments = $query->paginate(25)->withQueryString(); $paymentStats = $this->getPaymentStats($days > 0 ? $days : 30); return view('admin.payment-dashboard.payments', compact('payments', 'paymentStats', 'days', 'statusFilter', 'modeFilter')); } /** * PAYONE Log-Viewer. */ public function logs(): View { $availableDates = $this->getAvailableLogDates(); $selectedDate = request()->string('date', now()->format('Y-m-d'))->toString(); if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDate) || ! in_array($selectedDate, $availableDates)) { $selectedDate = ! empty($availableDates) ? $availableDates[0] : now()->format('Y-m-d'); } $logFile = storage_path('logs/payone-'.$selectedDate.'.log'); $entries = []; if (file_exists($logFile)) { $lines = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); foreach (array_slice($lines, 0, 300) as $line) { if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[^\]]*)\] \w+\.(\w+): (.+)$/', $line, $m)) { $entries[] = [ 'timestamp' => $m[1], 'level' => $m[2], 'message' => $m[3], ]; } } } return view('admin.payment-dashboard.logs', compact('entries', 'availableDates', 'selectedDate')); } // ─── Private Helpers ────────────────────────────────────────────────────── private function getIncidentStats(): array { return [ 'open_incidents' => PaymentIncident::whereIn('status', ['open', 'waiting_provider'])->count(), 'in_progress' => PaymentIncident::where('status', 'in_progress')->count(), 'resolved_this_month' => PaymentIncident::where('status', 'resolved') ->whereMonth('resolved_at', now()->month)->count(), 'total_affected_revenue' => PaymentIncident::open()->sum('affected_revenue'), 'payone_incidents_30d' => PaymentIncident::payone()->lastDays(30)->count(), 'critical_open' => PaymentIncident::open()->where('severity', 'critical')->count(), ]; } private function getTransactionStats(int $days): array { $since = now()->subDays($days); $total = PaymentTransaction::where('created_at', '>=', $since)->count(); $paid = PaymentTransaction::where('txaction', 'paid')->where('created_at', '>=', $since)->count(); $failed = PaymentTransaction::where('txaction', 'failed')->where('created_at', '>=', $since)->count(); $successRate = $total > 0 ? round(($paid / $total) * 100, 1) : 0; $errorDistribution = PaymentTransaction::whereNotNull('errorcode') ->where('created_at', '>=', $since) ->select('errorcode', 'errormessage', DB::raw('count(*) as count')) ->groupBy('errorcode', 'errormessage') ->orderByDesc('count') ->get(); $lastFailed = PaymentTransaction::where('txaction', 'failed')->latest()->first(); return [ 'total' => $total, 'paid' => $paid, 'failed' => $failed, 'success_rate' => $successRate, 'error_distribution' => $errorDistribution, 'last_failed' => $lastFailed, 'days' => $days, ]; } private function getProviderStats(): array { $providers = [ 'payone', // 'stripe', // aktuell nicht aktiv 'paypal', // 'mollie', // aktuell nicht aktiv 'other', ]; $stats = []; foreach ($providers as $provider) { $stats[$provider] = [ 'label' => strtoupper($provider), 'open_incidents' => PaymentIncident::where('provider', $provider)->open()->count(), 'total_30d' => PaymentIncident::where('provider', $provider)->lastDays(30)->count(), 'last_incident' => PaymentIncident::where('provider', $provider) ->orderBy('detected_at', 'desc') ->first(), ]; } return $stats; } /** * @return array{total: int, paid: int, failed: int, pending: int, total_amount: float, failed_amount: float} */ private function getPaymentStats(int $days): array { $since = now()->subDays($days); $base = ShoppingPayment::where('created_at', '>=', $since); return [ 'total' => (clone $base)->count(), 'paid' => (clone $base)->where('txaction', 'paid')->count(), 'failed' => (clone $base)->whereHas('payment_transactions', fn ($q) => $q->where('txaction', 'failed'))->count(), 'pending' => (clone $base)->whereNotIn('txaction', ['paid', 'failed'])->count(), 'total_amount' => round((clone $base)->sum('amount') / 100, 2), 'failed_amount' => round( (clone $base)->whereHas('payment_transactions', fn ($q) => $q->where('txaction', 'failed'))->sum('amount') / 100, 2 ), 'days' => $days, ]; } private function getAvailableLogDates(): array { $logPath = storage_path('logs'); $files = glob($logPath.'/payone-*.log'); $dates = []; foreach ($files as $file) { if (preg_match('/payone-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) { $dates[] = $m[1]; } } rsort($dates); return $dates; } /** * @return array */ private function getUptimeStats(): array { $providers = [ 'payone', // 'stripe', // aktuell nicht aktiv 'paypal', // 'mollie', // aktuell nicht aktiv ]; $stats = []; $since = now()->subHours(24); foreach ($providers as $provider) { $recentLogs = ProviderUptimeLog::where('provider', $provider) ->where('checked_at', '>=', $since) ->orderBy('checked_at', 'desc') ->get(); $total = $recentLogs->count(); $upCount = $recentLogs->where('is_up', true)->count(); $stats[$provider] = [ 'last_check' => ProviderUptimeLog::where('provider', $provider)->latest('checked_at')->first(), 'uptime_24h' => $total > 0 ? round(($upCount / $total) * 100, 1) : null, 'checks_24h' => $total, 'failures_24h' => $total - $upCount, ]; } return $stats; } private function invalidateIncidentCache(): void { Cache::forget('open_incident_count'); } }