From 0f82fea88aebb333a8ecca696865c9f8c483bcdb Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 14 Apr 2026 18:07:45 +0200 Subject: [PATCH] 14-04-2026 --- app/Console/Commands/CheckPaymentUptime.php | 125 ++++ app/Console/Kernel.php | 8 + .../Admin/PaymentDashboardController.php | 568 ++++++++++++++++++ app/Http/Controllers/Api/PayoneController.php | 18 + app/Http/Controllers/Pay/PayoneController.php | 10 + .../Controllers/RevenueReportController.php | 294 +++++++-- .../Controllers/User/IncentiveController.php | 9 +- .../Controllers/Web/CheckoutController.php | 63 +- app/Http/Controllers/WizardController.php | 5 + .../AddIncidentActivityRequest.php | 31 + .../StorePaymentIncidentRequest.php | 44 ++ .../UpdateIncidentStatusRequest.php | 28 + app/Mail/PaymentIncidentAlert.php | 31 + app/Models/CheckoutFunnelEvent.php | 123 ++++ app/Models/IncidentActivity.php | 46 ++ app/Models/PaymentIncident.php | 156 +++++ app/Models/PaymentTransaction.php | 56 +- app/Models/ProviderUptimeLog.php | 24 + app/Providers/AppServiceProvider.php | 7 +- app/Services/CheckoutFunnelTracker.php | 128 ++++ ..._172135_create_payment_incidents_table.php | 62 ++ ...54_create_checkout_funnel_events_table.php | 43 ++ dev/payment-dashboard/ENTWICKLUNGSPLAN.md | 503 ++++++++++++++++ dev/payment-dashboard/README.md | 100 +++ .../PaymentDashboardController.php | 152 +++++ .../app/Models/IncidentActivity.php | 40 ++ .../app/Models/PaymentIncident.php | 74 +++ ..._000001_create_payment_incidents_table.php | 54 ++ .../views/dashboard/developer.blade.php | 264 ++++++++ .../views/dashboard/management.blade.php | 120 ++++ .../resources/views/dashboard/show.blade.php | 150 +++++ .../views/layouts/dashboard.blade.php | 402 +++++++++++++ .../routes/payment-dashboard.php | 35 ++ resources/lang/de/incentive.php | 14 +- resources/lang/de/navigation.php | 2 + resources/lang/de/pagination.php | 6 + resources/lang/de/payment.php | 28 +- resources/lang/en/incentive.php | 14 +- resources/lang/en/navigation.php | 2 + resources/lang/en/pagination.php | 6 + resources/lang/en/payment.php | 28 +- resources/lang/es/incentive.php | 14 +- resources/lang/es/navigation.php | 2 + resources/lang/es/pagination.php | 6 + resources/lang/es/payment.php | 28 +- .../_partials/activity-timeline.blade.php | 45 ++ .../_partials/create-incident-modal.blade.php | 100 +++ .../_partials/incident-table.blade.php | 71 +++ .../_partials/stats-cards.blade.php | 64 ++ .../payment-dashboard/abandoned.blade.php | 457 ++++++++++++++ .../admin/payment-dashboard/funnel.blade.php | 302 ++++++++++ .../admin/payment-dashboard/index.blade.php | 143 +++++ .../admin/payment-dashboard/logs.blade.php | 86 +++ .../payment-dashboard/management.blade.php | 111 ++++ .../payment-dashboard/payments.blade.php | 328 ++++++++++ .../admin/payment-dashboard/show.blade.php | 165 +++++ .../payment-dashboard/transactions.blade.php | 183 ++++++ resources/views/admin/revenue/index.blade.php | 142 +++++ .../views/dashboard/_incentive.blade.php | 147 +++++ .../emails/payment-incident-alert.blade.php | 114 ++++ .../layouts/includes/layout-sidenav.blade.php | 19 + resources/views/user/incentive/show.blade.php | 67 ++- .../web/templates/checkout-error.blade.php | 189 ++++-- .../views/web/templates/checkout.blade.php | 12 +- routes/domains/crm.php | 15 + .../IncentiveParticipantRankOrderingTest.php | 15 +- .../CheckPaymentUptimeCommandTest.php | 112 ++++ .../CheckoutFunnelTrackingTest.php | 101 ++++ .../PaymentDashboardAccessTest.php | 183 ++++++ .../PaymentIncidentCrudTest.php | 298 +++++++++ tests/Feature/WizardPaymentCartTest.php | 169 ++++++ tests/Pest.php | 1 + 72 files changed, 7414 insertions(+), 148 deletions(-) create mode 100644 app/Console/Commands/CheckPaymentUptime.php create mode 100644 app/Http/Controllers/Admin/PaymentDashboardController.php create mode 100644 app/Http/Requests/PaymentIncident/AddIncidentActivityRequest.php create mode 100644 app/Http/Requests/PaymentIncident/StorePaymentIncidentRequest.php create mode 100644 app/Http/Requests/PaymentIncident/UpdateIncidentStatusRequest.php create mode 100644 app/Mail/PaymentIncidentAlert.php create mode 100644 app/Models/CheckoutFunnelEvent.php create mode 100644 app/Models/IncidentActivity.php create mode 100644 app/Models/PaymentIncident.php create mode 100644 app/Models/ProviderUptimeLog.php create mode 100644 app/Services/CheckoutFunnelTracker.php create mode 100644 database/migrations/2026_04_13_172135_create_payment_incidents_table.php create mode 100644 database/migrations/2026_04_13_184854_create_checkout_funnel_events_table.php create mode 100644 dev/payment-dashboard/ENTWICKLUNGSPLAN.md create mode 100644 dev/payment-dashboard/README.md create mode 100644 dev/payment-dashboard/app/Http/Controllers/PaymentDashboardController.php create mode 100644 dev/payment-dashboard/app/Models/IncidentActivity.php create mode 100644 dev/payment-dashboard/app/Models/PaymentIncident.php create mode 100644 dev/payment-dashboard/database/migrations/2024_01_01_000001_create_payment_incidents_table.php create mode 100644 dev/payment-dashboard/resources/views/dashboard/developer.blade.php create mode 100644 dev/payment-dashboard/resources/views/dashboard/management.blade.php create mode 100644 dev/payment-dashboard/resources/views/dashboard/show.blade.php create mode 100644 dev/payment-dashboard/resources/views/layouts/dashboard.blade.php create mode 100644 dev/payment-dashboard/routes/payment-dashboard.php create mode 100644 resources/lang/de/pagination.php create mode 100644 resources/lang/en/pagination.php create mode 100644 resources/lang/es/pagination.php create mode 100644 resources/views/admin/payment-dashboard/_partials/activity-timeline.blade.php create mode 100644 resources/views/admin/payment-dashboard/_partials/create-incident-modal.blade.php create mode 100644 resources/views/admin/payment-dashboard/_partials/incident-table.blade.php create mode 100644 resources/views/admin/payment-dashboard/_partials/stats-cards.blade.php create mode 100644 resources/views/admin/payment-dashboard/abandoned.blade.php create mode 100644 resources/views/admin/payment-dashboard/funnel.blade.php create mode 100644 resources/views/admin/payment-dashboard/index.blade.php create mode 100644 resources/views/admin/payment-dashboard/logs.blade.php create mode 100644 resources/views/admin/payment-dashboard/management.blade.php create mode 100644 resources/views/admin/payment-dashboard/payments.blade.php create mode 100644 resources/views/admin/payment-dashboard/show.blade.php create mode 100644 resources/views/admin/payment-dashboard/transactions.blade.php create mode 100644 resources/views/emails/payment-incident-alert.blade.php create mode 100644 tests/Feature/PaymentDashboard/CheckPaymentUptimeCommandTest.php create mode 100644 tests/Feature/PaymentDashboard/CheckoutFunnelTrackingTest.php create mode 100644 tests/Feature/PaymentDashboard/PaymentDashboardAccessTest.php create mode 100644 tests/Feature/PaymentDashboard/PaymentIncidentCrudTest.php create mode 100644 tests/Feature/WizardPaymentCartTest.php diff --git a/app/Console/Commands/CheckPaymentUptime.php b/app/Console/Commands/CheckPaymentUptime.php new file mode 100644 index 0000000..4cb1bd9 --- /dev/null +++ b/app/Console/Commands/CheckPaymentUptime.php @@ -0,0 +1,125 @@ + + */ + private array $endpoints = [ + 'payone' => 'https://api.pay1.de/post-gateway/', + ]; + + public function handle(): int + { + Log::channel('payment')->info('COMMAND [payment:check-uptime] started.'); + + foreach ($this->endpoints as $provider => $url) { + $this->checkProvider($provider, $url); + } + + Log::channel('payment')->info('COMMAND [payment:check-uptime] finished.'); + + return self::SUCCESS; + } + + private function checkProvider(string $provider, string $url): void + { + $startMs = now()->valueOf(); + + try { + $response = Http::timeout(10)->head($url); + $responseTimeMs = now()->valueOf() - $startMs; + $isUp = $response->successful() || $response->status() < 500; + $errorMessage = $isUp ? null : 'HTTP '.$response->status(); + } catch (\Exception $e) { + $responseTimeMs = now()->valueOf() - $startMs; + $isUp = false; + $errorMessage = $e->getMessage(); + } + + ProviderUptimeLog::create([ + 'provider' => $provider, + 'is_up' => $isUp, + 'response_time_ms' => (int) $responseTimeMs, + 'error_message' => $errorMessage, + 'checked_at' => now(), + ]); + + $status = $isUp ? 'UP' : 'DOWN'; + $this->line("[{$provider}] {$status} ({$responseTimeMs}ms)"); + Log::channel('payment')->info("[payment:check-uptime] {$provider} {$status}", [ + 'response_time_ms' => $responseTimeMs, + 'error_message' => $errorMessage, + ]); + + if (! $isUp) { + $this->handleOutage($provider, $errorMessage); + } else { + $this->handleRecovery($provider); + } + } + + private function handleOutage(string $provider, ?string $errorMessage): void + { + $incident = PaymentIncident::firstOrCreate( + [ + 'provider' => $provider, + 'type' => 'outage', + 'status' => 'open', + ], + [ + 'title' => 'Automatisch: '.strtoupper($provider).' nicht erreichbar', + 'description' => 'Uptime-Check hat einen Ausfall erkannt.', + 'severity' => 'critical', + 'detected_at' => now()->startOfHour(), + ] + ); + + IncidentActivity::create([ + 'incident_id' => $incident->id, + 'type' => 'note', + 'title' => 'Uptime-Check: '.strtoupper($provider).' DOWN', + 'content' => $errorMessage, + 'author' => 'System', + ]); + + Log::channel('payment')->error("[payment:check-uptime] Outage-Incident #{$incident->id} für {$provider}."); + } + + private function handleRecovery(string $provider): void + { + $openOutages = PaymentIncident::where('provider', $provider) + ->where('type', 'outage') + ->whereIn('status', ['open', 'in_progress']) + ->get(); + + foreach ($openOutages as $incident) { + $incident->update([ + 'status' => 'resolved', + 'resolved_at' => now(), + ]); + + IncidentActivity::create([ + 'incident_id' => $incident->id, + 'type' => 'status_change', + 'title' => 'Automatisch aufgelöst: '.strtoupper($provider).' ist wieder erreichbar', + 'author' => 'System', + ]); + + Log::channel('payment')->info("[payment:check-uptime] Incident #{$incident->id} für {$provider} automatisch aufgelöst."); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index ceca551..113f653 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,6 +5,7 @@ namespace App\Console; use App\Console\Commands\BusinessStore; use App\Console\Commands\BusinessStoreOptimized; use App\Console\Commands\CheckPaymentsAccount; +use App\Console\Commands\CheckPaymentUptime; use App\Console\Commands\DhlUpdateTracking; use App\Console\Commands\UserCleanup; use App\Console\Commands\UserMakeAboOrder; @@ -21,6 +22,7 @@ class Kernel extends ConsoleKernel protected $commands = [ BusinessStore::class, BusinessStoreOptimized::class, + CheckPaymentUptime::class, CheckPaymentsAccount::class, UserMakeAboOrder::class, UserCleanup::class, @@ -34,6 +36,12 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { + // Uptime-Check: PAYONE-Erreichbarkeit alle 5 Minuten prüfen + $schedule->command('payment:check-uptime') + ->everyFiveMinutes() + ->withoutOverlapping() + ->runInBackground(); + // Job 1: Überprüft täglich um 02:00 Uhr die Zahlungskonten. $schedule->command('payments:check-accounts')->dailyAt('02:00'); // Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript. diff --git a/app/Http/Controllers/Admin/PaymentDashboardController.php b/app/Http/Controllers/Admin/PaymentDashboardController.php new file mode 100644 index 0000000..5211eca --- /dev/null +++ b/app/Http/Controllers/Admin/PaymentDashboardController.php @@ -0,0 +1,568 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/PayoneController.php b/app/Http/Controllers/Api/PayoneController.php index bfa5c5a..fe5087d 100644 --- a/app/Http/Controllers/Api/PayoneController.php +++ b/app/Http/Controllers/Api/PayoneController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\PaymentTransaction; use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; +use App\Services\CheckoutFunnelTracker; use App\Services\MyLog; use App\Services\Payment; use App\Services\ShoppingUserService; @@ -152,6 +153,9 @@ class PayoneController extends Controller 'txaction' => $data['txaction'], 'transmitted_data' => Util::utf8ize($data), 'mode' => $data['mode'], + 'errorcode' => $data['errorcode'] ?? null, + 'errormessage' => $data['errormessage'] ?? null, + 'customermessage' => $data['customermessage'] ?? null, ]); // Define txaction priority (higher number = higher priority) @@ -219,6 +223,20 @@ class PayoneController extends Controller ); throw $e; } + + CheckoutFunnelTracker::confirmedPayment( + shoppingPaymentId: $shopping_payment->id, + txaction: 'paid', + metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null], + ); + } + + if ($data['txaction'] === 'appointed') { + CheckoutFunnelTracker::confirmedPayment( + shoppingPaymentId: $shopping_payment->id, + txaction: 'appointed', + metadata: ['mode' => $data['mode'] ?? null], + ); } $data['send_link'] = $send_link; diff --git a/app/Http/Controllers/Pay/PayoneController.php b/app/Http/Controllers/Pay/PayoneController.php index f2d5ed2..6d74cc8 100644 --- a/app/Http/Controllers/Pay/PayoneController.php +++ b/app/Http/Controllers/Pay/PayoneController.php @@ -26,6 +26,7 @@ namespace App\Http\Controllers\Pay; use App\Http\Controllers\Controller; use App\Models\PaymentTransaction; use App\Models\ShoppingPayment; +use App\Services\CheckoutFunnelTracker; use App\Services\MyLog; use App\Services\Payone; use Util; @@ -189,6 +190,15 @@ class PayoneController extends Controller 'mode' => $this->shopping_order->mode, ]); + CheckoutFunnelTracker::initiatedPayment( + shoppingUserId: $this->shopping_user->id, + shoppingOrderId: $this->shopping_order->id, + shoppingPaymentId: $this->shopping_payment->id, + consultantUserId: $this->shopping_user->auth_user_id ?? null, + paymentMethod: $payment_method, + amountCents: $amount, + ); + $this->default['mode'] = $this->shopping_order->mode; return $this->reference; diff --git a/app/Http/Controllers/RevenueReportController.php b/app/Http/Controllers/RevenueReportController.php index 1422eab..faccc85 100644 --- a/app/Http/Controllers/RevenueReportController.php +++ b/app/Http/Controllers/RevenueReportController.php @@ -2,15 +2,13 @@ namespace App\Http\Controllers; -use Auth; -use Request; -use App\Models\UserInvoice; -use App\Models\UserCredit; -use App\Services\HTMLHelper; use App\Exports\UserTeamExport; -use App\Http\Controllers\Controller; +use App\Models\UserCredit; +use App\Models\UserInvoice; +use App\Services\HTMLHelper; use Illuminate\Support\Facades\DB; use Maatwebsite\Excel\Facades\Excel; +use Request; class RevenueReportController extends Controller { @@ -22,42 +20,42 @@ class RevenueReportController extends Controller public function index() { $this->setFilterVars(); - + $data = [ 'filter_months' => HTMLHelper::getTransMonths(), 'filter_years' => HTMLHelper::getYearRange(2022), 'revenue_summary' => $this->getRevenueSummary(), - 'credit_summary' => $this->getCreditSummary() + 'credit_summary' => $this->getCreditSummary(), ]; - + return view('admin.revenue.index', $data); } public function export() { $this->setFilterVars(); - + $filter_year = session('revenue_filter_year'); - + // Get data like in the HTML view $revenue_summary = $this->getRevenueSummary(); $credit_summary = $this->getCreditSummary(); - + $filename = "umsatz-gutschrift-bericht-{$filter_year}"; - + $columns = []; - + // Umsätze Section Header $columns[] = ['Typ' => 'UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - + // Yearly Revenue Summary - if(isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) { - foreach($revenue_summary['yearly'] as $item) { + if (isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) { + foreach ($revenue_summary['yearly'] as $item) { $columns[] = [ 'Typ' => $item->period_label, 'Netto' => number_format($item->total_net, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'), - 'Brutto' => number_format($item->total_gross, 2, ',', '.') + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), ]; } } else { @@ -65,22 +63,22 @@ class RevenueReportController extends Controller 'Typ' => "Jahr {$filter_year}", 'Netto' => '0,00', 'Steuer' => '0,00', - 'Brutto' => '0,00' + 'Brutto' => '0,00', ]; } - + // Empty row $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - + // Monthly Revenue Breakdown $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - if(isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) { - foreach($revenue_summary['monthly'] as $item) { + if (isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) { + foreach ($revenue_summary['monthly'] as $item) { $columns[] = [ 'Typ' => $item->period_label, 'Netto' => number_format($item->total_net, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'), - 'Brutto' => number_format($item->total_gross, 2, ',', '.') + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), ]; } } else { @@ -88,25 +86,25 @@ class RevenueReportController extends Controller 'Typ' => 'Keine monatlichen Umsätze gefunden', 'Netto' => '', 'Steuer' => '', - 'Brutto' => '' + 'Brutto' => '', ]; } - + // Two empty rows for separation $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - + // Gutschriften Section Header $columns[] = ['Typ' => 'GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - + // Yearly Credit Summary - if(isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) { - foreach($credit_summary['yearly'] as $item) { + if (isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) { + foreach ($credit_summary['yearly'] as $item) { $columns[] = [ 'Typ' => $item->period_label, 'Netto' => number_format($item->total_net, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'), - 'Brutto' => number_format($item->total_gross, 2, ',', '.') + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), ]; } } else { @@ -114,22 +112,22 @@ class RevenueReportController extends Controller 'Typ' => "Jahr {$filter_year}", 'Netto' => '0,00', 'Steuer' => '0,00', - 'Brutto' => '0,00' + 'Brutto' => '0,00', ]; } - + // Empty row $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - + // Monthly Credit Breakdown $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; - if(isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) { - foreach($credit_summary['monthly'] as $item) { + if (isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) { + foreach ($credit_summary['monthly'] as $item) { $columns[] = [ 'Typ' => $item->period_label, 'Netto' => number_format($item->total_net, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'), - 'Brutto' => number_format($item->total_gross, 2, ',', '.') + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), ]; } } else { @@ -137,24 +135,106 @@ class RevenueReportController extends Controller 'Typ' => 'Keine monatlichen Gutschriften gefunden', 'Netto' => '', 'Steuer' => '', - 'Brutto' => '' + 'Brutto' => '', ]; } - + + // Two empty rows for separation + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + + // Umsätze nach Ländern - Jährlich + $columns[] = ['Typ' => 'UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + if (isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0) { + foreach ($revenue_summary['country_yearly'] as $item) { + $columns[] = [ + 'Typ' => $item->country_name, + 'Netto' => number_format($item->total_net, 2, ',', '.'), + 'Steuer' => number_format($item->total_tax, 2, ',', '.'), + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), + ]; + } + } else { + $columns[] = ['Typ' => 'Keine Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + } + + // Empty row + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + + // Umsätze nach Ländern - Monatlich + $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + if (isset($revenue_summary['country_monthly']) && $revenue_summary['country_monthly']->count() > 0) { + $revenueByMonth = $revenue_summary['country_monthly']->groupBy('month'); + foreach ($revenueByMonth as $countries) { + $columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + foreach ($countries as $item) { + $columns[] = [ + 'Typ' => ' '.$item->country_name, + 'Netto' => number_format($item->total_net, 2, ',', '.'), + 'Steuer' => number_format($item->total_tax, 2, ',', '.'), + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), + ]; + } + } + } else { + $columns[] = ['Typ' => 'Keine monatlichen Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + } + + // Two empty rows for separation + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + + // Gutschriften nach Ländern - Jährlich + $columns[] = ['Typ' => 'GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + if (isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0) { + foreach ($credit_summary['country_yearly'] as $item) { + $columns[] = [ + 'Typ' => $item->country_name, + 'Netto' => number_format($item->total_net, 2, ',', '.'), + 'Steuer' => number_format($item->total_tax, 2, ',', '.'), + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), + ]; + } + } else { + $columns[] = ['Typ' => 'Keine Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + } + + // Empty row + $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + + // Gutschriften nach Ländern - Monatlich + $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + if (isset($credit_summary['country_monthly']) && $credit_summary['country_monthly']->count() > 0) { + $creditByMonth = $credit_summary['country_monthly']->groupBy('month'); + foreach ($creditByMonth as $countries) { + $columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + foreach ($countries as $item) { + $columns[] = [ + 'Typ' => ' '.$item->country_name, + 'Netto' => number_format($item->total_net, 2, ',', '.'), + 'Steuer' => number_format($item->total_tax, 2, ',', '.'), + 'Brutto' => number_format($item->total_gross, 2, ',', '.'), + ]; + } + } + } else { + $columns[] = ['Typ' => 'Keine monatlichen Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; + } + $headers = ['Zeitraum', 'Netto (€)', 'Steuer (€)', 'Brutto (€)']; - - return Excel::download(new UserTeamExport($columns, $headers), $filename . '.xlsx'); + + return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xlsx'); } private function setFilterVars() { - if (!session('revenue_filter_month')) { + if (! session('revenue_filter_month')) { session(['revenue_filter_month' => intval(date('m'))]); } - if (!session('revenue_filter_year')) { + if (! session('revenue_filter_year')) { session(['revenue_filter_year' => intval(date('Y'))]); } - if(!session('revenue_filter_type')){ + if (! session('revenue_filter_type')) { session(['revenue_filter_type' => 'year']); } if (Request::get('revenue_filter_month')) { @@ -171,20 +251,24 @@ class RevenueReportController extends Controller private function getRevenueSummary() { $year = session('revenue_filter_year'); - + return [ 'yearly' => $this->getRevenueByYear($year), - 'monthly' => $this->getRevenueByMonthsInYear($year) + 'monthly' => $this->getRevenueByMonthsInYear($year), + 'country_yearly' => $this->getRevenueByCountryYear($year), + 'country_monthly' => $this->getRevenueByCountryMonthly($year), ]; } private function getCreditSummary() { $year = session('revenue_filter_year'); - + return [ 'yearly' => $this->getCreditByYear($year), - 'monthly' => $this->getCreditByMonthsInYear($year) + 'monthly' => $this->getCreditByMonthsInYear($year), + 'country_yearly' => $this->getCreditByCountryYear($year), + 'country_monthly' => $this->getCreditByCountryMonthly($year), ]; } @@ -340,4 +424,116 @@ class RevenueReportController extends Controller ->orderBy('month') ->get(); } -} \ No newline at end of file + + private function getRevenueByCountryYear(int $year) + { + return UserInvoice::join('shopping_orders', 'user_invoices.shopping_order_id', '=', 'shopping_orders.id') + ->join('shipping_countries', 'shopping_orders.country_id', '=', 'shipping_countries.id') + ->join('countries', 'shipping_countries.country_id', '=', 'countries.id') + ->selectRaw(' + countries.id as country_id, + countries.de as country_name, + countries.code as country_code, + SUM(shopping_orders.subtotal_ws) as total_net, + SUM(shopping_orders.tax) as total_tax, + SUM(shopping_orders.total_shipping) as total_gross + ') + ->where('user_invoices.year', $year) + ->where('user_invoices.cancellation', false) + ->groupBy('countries.id', 'countries.de', 'countries.code') + ->orderByDesc('total_gross') + ->get(); + } + + private function getRevenueByCountryMonthly(int $year) + { + return UserInvoice::join('shopping_orders', 'user_invoices.shopping_order_id', '=', 'shopping_orders.id') + ->join('shipping_countries', 'shopping_orders.country_id', '=', 'shipping_countries.id') + ->join('countries', 'shipping_countries.country_id', '=', 'countries.id') + ->selectRaw(' + user_invoices.month, + CONCAT(CASE user_invoices.month + WHEN 1 THEN \'Januar\' + WHEN 2 THEN \'Februar\' + WHEN 3 THEN \'März\' + WHEN 4 THEN \'April\' + WHEN 5 THEN \'Mai\' + WHEN 6 THEN \'Juni\' + WHEN 7 THEN \'Juli\' + WHEN 8 THEN \'August\' + WHEN 9 THEN \'September\' + WHEN 10 THEN \'Oktober\' + WHEN 11 THEN \'November\' + WHEN 12 THEN \'Dezember\' + END, \' \', user_invoices.year) as month_label, + countries.id as country_id, + countries.de as country_name, + countries.code as country_code, + SUM(shopping_orders.subtotal_ws) as total_net, + SUM(shopping_orders.tax) as total_tax, + SUM(shopping_orders.total_shipping) as total_gross + ') + ->where('user_invoices.year', $year) + ->where('user_invoices.cancellation', false) + ->groupBy('user_invoices.month', 'countries.id', 'countries.de', 'countries.code') + ->orderBy('user_invoices.month') + ->orderByDesc('total_gross') + ->get(); + } + + private function getCreditByCountryYear(int $year) + { + return UserCredit::join('users', 'user_credits.user_id', '=', 'users.id') + ->join('user_accounts', 'users.account_id', '=', 'user_accounts.id') + ->join('countries', 'user_accounts.country_id', '=', 'countries.id') + ->selectRaw(' + countries.id as country_id, + countries.de as country_name, + countries.code as country_code, + SUM(user_credits.net) as total_net, + SUM(user_credits.tax) as total_tax, + SUM(user_credits.total) as total_gross + ') + ->where('user_credits.year', $year) + ->where('user_credits.cancellation', false) + ->groupBy('countries.id', 'countries.de', 'countries.code') + ->orderByDesc('total_gross') + ->get(); + } + + private function getCreditByCountryMonthly(int $year) + { + return UserCredit::join('users', 'user_credits.user_id', '=', 'users.id') + ->join('user_accounts', 'users.account_id', '=', 'user_accounts.id') + ->join('countries', 'user_accounts.country_id', '=', 'countries.id') + ->selectRaw(' + user_credits.month, + CONCAT(CASE user_credits.month + WHEN 1 THEN \'Januar\' + WHEN 2 THEN \'Februar\' + WHEN 3 THEN \'März\' + WHEN 4 THEN \'April\' + WHEN 5 THEN \'Mai\' + WHEN 6 THEN \'Juni\' + WHEN 7 THEN \'Juli\' + WHEN 8 THEN \'August\' + WHEN 9 THEN \'September\' + WHEN 10 THEN \'Oktober\' + WHEN 11 THEN \'November\' + WHEN 12 THEN \'Dezember\' + END, \' \', user_credits.year) as month_label, + countries.id as country_id, + countries.de as country_name, + countries.code as country_code, + SUM(user_credits.net) as total_net, + SUM(user_credits.tax) as total_tax, + SUM(user_credits.total) as total_gross + ') + ->where('user_credits.year', $year) + ->where('user_credits.cancellation', false) + ->groupBy('user_credits.month', 'countries.id', 'countries.de', 'countries.code') + ->orderBy('user_credits.month') + ->orderByDesc('total_gross') + ->get(); + } +} diff --git a/app/Http/Controllers/User/IncentiveController.php b/app/Http/Controllers/User/IncentiveController.php index 3a3e530..bd130c8 100644 --- a/app/Http/Controllers/User/IncentiveController.php +++ b/app/Http/Controllers/User/IncentiveController.php @@ -61,8 +61,7 @@ class IncentiveController extends Controller ->withRankingActivity() ->with('user', 'user.account') ->orderByIncentiveLeaderboard() - ->limit(self::USER_RANKING_DISPLAY_LIMIT) - ->get(); + ->paginate(100); $participateHasTrackableAbos = false; if (! $participant?->accepted_terms_at) { @@ -74,8 +73,8 @@ class IncentiveController extends Controller 'participant' => $participant, 'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null, 'ranking' => $ranking, - 'rankingDisplayLimit' => self::USER_RANKING_DISPLAY_LIMIT, 'participateHasTrackableAbos' => $participateHasTrackableAbos, + 'isVipView' => $user->isVIP(), ]); } @@ -160,12 +159,12 @@ class IncentiveController extends Controller return []; } - $files = glob($dir . '/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; + $files = glob($dir.'/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; $images = []; foreach ($files as $file) { $basename = basename($file); - $images[] = 'img/incentive/' . $basename; + $images[] = 'img/incentive/'.$basename; } sort($images); diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index 22fa424..adf9424 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -10,6 +10,7 @@ use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Repositories\CheckoutRepository; use App\Services\AboHelper; +use App\Services\CheckoutFunnelTracker; use App\Services\CustomerPriority; use App\Services\Payment; use App\Services\Shop; @@ -98,6 +99,11 @@ class CheckoutController extends Controller 'yard_instance' => $this->instance, ]; + CheckoutFunnelTracker::visitedCheckout( + consultantUserId: Util::getUserShop()?->id ?? null, + metadata: ['is_from' => $is_from, 'is_for' => $is_for, 'is_abo' => $is_abo], + ); + return view('web.templates.checkout', $data); } @@ -168,6 +174,14 @@ class CheckoutController extends Controller Util::setUserHistoryValue(['status' => 2, 'shopping_order_id' => $shopping_order->id]); + CheckoutFunnelTracker::submittedForm( + shoppingUserId: $shopping_user->id, + shoppingOrderId: $shopping_order->id, + consultantUserId: $shopping_user->auth_user_id ?? null, + paymentMethod: Request::get('payment_method'), + amountCents: (int) (Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100), + ); + // Zahlungsmethode verarbeiten if (Request::get('payment_method')) { return $this->processPaymentMethod($data, $shopping_user, $shopping_order); @@ -400,6 +414,11 @@ class CheckoutController extends Controller $ShoppingPayment->status = $status; $ShoppingPayment->save(); + CheckoutFunnelTracker::returnedFromPayment( + shoppingPaymentId: $ShoppingPayment->id, + returnStatus: $status, + ); + if ($status === 'success') { return $this->handleSuccessfulTransaction($ShoppingPayment, $reference); } @@ -410,7 +429,9 @@ class CheckoutController extends Controller return $this->showTransactionError( __('payment.payment_canceled'), - __('payment.payment_canceled_description') + __('payment.payment_canceled_description'), + 'cancel', + ['checkout_url' => route('checkout.checkout_card')], ); } @@ -418,9 +439,29 @@ class CheckoutController extends Controller Util::setUserHistoryValue(['status' => 23]); Util::setInstanceStatusByPayment($ShoppingPayment, 5); // link_failed + $latestTransaction = $ShoppingPayment->payment_transactions() + ->whereNotNull('errorcode') + ->orWhere(fn ($q) => $q->whereNotNull('transmitted_data')) + ->latest() + ->first(); + + $errorcode = null; + $errorDescription = null; + if ($latestTransaction) { + $errorcode = $latestTransaction->errorcode + ?? ($latestTransaction->transmitted_data['errorcode'] ?? null); + $errorDescription = $latestTransaction->error_description; + } + return $this->showTransactionError( __('payment.payment_error'), - __('payment.payment_error_description') + __('payment.payment_error_description'), + 'error', + [ + 'errorcode' => $errorcode, + 'error_description' => $errorDescription, + 'checkout_url' => route('checkout.checkout_card'), + ], ); } @@ -434,19 +475,21 @@ class CheckoutController extends Controller /** * Zeigt eine Transaktionsfehlerseite an * - * @param string $title - * @param string $message * @return \Illuminate\View\View */ - private function showTransactionError($title, $message) + /** + * @param array $extra + */ + private function showTransactionError(string $title, string $message, string $type = 'error', array $extra = []) { - $data = [ + $data = array_merge([ 'user_shop' => Util::getUserShop(), 'is_checkout' => true, 'yard_instance' => $this->instance, 'error_title' => $title, 'error_message' => $message, - ]; + 'error_type' => $type, + ], $extra); return view('web.templates.checkout-error', $data); } @@ -494,6 +537,12 @@ class CheckoutController extends Controller if ($payt->shopping_payment->reference != $reference) { abort(404); } + + CheckoutFunnelTracker::confirmedPayment( + shoppingPaymentId: $payt->shopping_payment_id, + txaction: $payt->txaction ?? $payt->status ?? 'approved', + ); + Yard::instance($this->instance)->destroy(); $this->checkoutRepo->sessionDestroy(true); Util::setInstanceStatus(3, true); // link_pending diff --git a/app/Http/Controllers/WizardController.php b/app/Http/Controllers/WizardController.php index 5cbff3a..fb4f531 100644 --- a/app/Http/Controllers/WizardController.php +++ b/app/Http/Controllers/WizardController.php @@ -608,6 +608,11 @@ class WizardController extends Controller if ($cartItem->qty > 1) { Yard::instance('shopping')->update($cartItem->rowId, 1); } + foreach (Yard::instance('shopping')->content() as $existingItem) { + if ($existingItem->rowId !== $cartItem->rowId) { + Yard::instance('shopping')->remove($existingItem->rowId); + } + } if (\App\Services\UserService::getTaxFree()) { Yard::setTax($cartItem->rowId, 0); } else { diff --git a/app/Http/Requests/PaymentIncident/AddIncidentActivityRequest.php b/app/Http/Requests/PaymentIncident/AddIncidentActivityRequest.php new file mode 100644 index 0000000..79c072a --- /dev/null +++ b/app/Http/Requests/PaymentIncident/AddIncidentActivityRequest.php @@ -0,0 +1,31 @@ + ['required', 'in:note,email,call,ticket,status_change,provider_response'], + 'title' => ['required', 'string', 'max:255'], + 'content' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'type.required' => 'Bitte einen Aktivitätstyp auswählen.', + 'type.in' => 'Ungültiger Aktivitätstyp.', + 'title.required' => 'Bitte einen Titel angeben.', + ]; + } +} diff --git a/app/Http/Requests/PaymentIncident/StorePaymentIncidentRequest.php b/app/Http/Requests/PaymentIncident/StorePaymentIncidentRequest.php new file mode 100644 index 0000000..09deb96 --- /dev/null +++ b/app/Http/Requests/PaymentIncident/StorePaymentIncidentRequest.php @@ -0,0 +1,44 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'provider' => ['required', 'in:payone,paypal,other'], // stripe,mollie aktuell nicht aktiv + 'type' => ['required', 'in:outage,ipn_error,payment_failure,slow_response,other'], + 'severity' => ['required', 'in:low,medium,high,critical'], + 'affected_orders' => ['nullable', 'integer', 'min:0'], + 'affected_revenue' => ['nullable', 'numeric', 'min:0'], + 'ticket_number' => ['nullable', 'string', 'max:100'], + 'detected_at' => ['required', 'date'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Bitte einen Titel angeben.', + 'provider.required' => 'Bitte einen Anbieter auswählen.', + 'provider.in' => 'Ungültiger Anbieter.', + 'type.required' => 'Bitte einen Incident-Typ auswählen.', + 'type.in' => 'Ungültiger Incident-Typ.', + 'severity.required' => 'Bitte eine Schwere angeben.', + 'severity.in' => 'Ungültige Schwere.', + 'detected_at.required' => 'Bitte ein Erkennungsdatum angeben.', + 'detected_at.date' => 'Ungültiges Datum.', + ]; + } +} diff --git a/app/Http/Requests/PaymentIncident/UpdateIncidentStatusRequest.php b/app/Http/Requests/PaymentIncident/UpdateIncidentStatusRequest.php new file mode 100644 index 0000000..6d10977 --- /dev/null +++ b/app/Http/Requests/PaymentIncident/UpdateIncidentStatusRequest.php @@ -0,0 +1,28 @@ + ['required', 'in:open,in_progress,waiting_provider,resolved,closed'], + ]; + } + + public function messages(): array + { + return [ + 'status.required' => 'Bitte einen Status auswählen.', + 'status.in' => 'Ungültiger Status.', + ]; + } +} diff --git a/app/Mail/PaymentIncidentAlert.php b/app/Mail/PaymentIncidentAlert.php new file mode 100644 index 0000000..a0c17e2 --- /dev/null +++ b/app/Mail/PaymentIncidentAlert.php @@ -0,0 +1,31 @@ +incident->severity_label); + $subject = "[{$severityLabel}] Payment Incident: {$this->incident->title}"; + + return $this + ->subject($subject) + ->view('emails.payment-incident-alert') + ->with([ + 'title' => "Payment Incident – {$severityLabel}", + 'incident' => $this->incident, + 'dashboardUrl' => route('admin.payment-dashboard.show', $this->incident), + ]); + } +} diff --git a/app/Models/CheckoutFunnelEvent.php b/app/Models/CheckoutFunnelEvent.php new file mode 100644 index 0000000..2fac42a --- /dev/null +++ b/app/Models/CheckoutFunnelEvent.php @@ -0,0 +1,123 @@ + 'array', + 'amount_cents' => 'integer', + ]; + } + + public function shopping_order(): BelongsTo + { + return $this->belongsTo(ShoppingOrder::class); + } + + public function shopping_payment(): BelongsTo + { + return $this->belongsTo(ShoppingPayment::class); + } + + public function consultant(): BelongsTo + { + return $this->belongsTo(User::class, 'consultant_user_id'); + } + + /** @return array */ + public static function eventLabels(): array + { + return [ + 'checkout_visited' => 'Checkout aufgerufen', + 'form_submitted' => 'Formular abgeschickt', + 'payment_initiated' => 'Zahlung gestartet', + 'payment_returned' => 'Zurück von PAYONE', + 'payment_confirmed' => 'PAYONE Callback', + ]; + } + + public function getEventLabelAttribute(): string + { + return self::eventLabels()[$this->event] ?? $this->event; + } + + /** + * Classifies the domain into a human-readable source channel. + * + * - my.mivita.* → Salescenter (Berater-Bereich) + * - in.mivita.* → Beraterzugang (in.mivita.care login) + * - *.mivita.care → Kundenshop (Live) + * - *.mivita.shop → Kundenshop (Live) + * - *.mivita.test → Testserver + */ + public function getSourceTypeAttribute(): string + { + $domain = strtolower($this->domain ?? ''); + + if (str_starts_with($domain, 'my.')) { + return 'salescenter'; + } + + if (str_starts_with($domain, 'in.')) { + return 'beraterzugang'; + } + + if (str_ends_with($domain, '.test') || str_contains($domain, '.mivita.test')) { + return 'testserver'; + } + + if (str_ends_with($domain, '.care') || str_ends_with($domain, '.shop')) { + return 'kundenshop'; + } + + return 'unbekannt'; + } + + /** @return array */ + public static function sourceLabels(): array + { + return [ + 'kundenshop' => 'Kundenshop (*.mivita.care / *.mivita.shop)', + 'salescenter' => 'Salescenter (my.mivita.care)', + 'beraterzugang' => 'Beraterzugang (in.mivita.care)', + 'testserver' => 'Testserver (*.mivita.test)', + 'unbekannt' => 'Unbekannt', + ]; + } +} diff --git a/app/Models/IncidentActivity.php b/app/Models/IncidentActivity.php new file mode 100644 index 0000000..40373a5 --- /dev/null +++ b/app/Models/IncidentActivity.php @@ -0,0 +1,46 @@ +belongsTo(PaymentIncident::class, 'incident_id'); + } + + public function getTypeIconAttribute(): string + { + return match ($this->type) { + 'email' => 'ion-md-mail', + 'call' => 'ion-md-call', + 'ticket' => 'ion-md-pricetag', + 'status_change' => 'ion-md-swap', + 'provider_response' => 'ion-md-return-left', + default => 'ion-md-create', + }; + } + + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'email' => 'E-Mail', + 'call' => 'Telefonat', + 'ticket' => 'Ticket', + 'status_change' => 'Statusänderung', + 'provider_response' => 'Anbieter-Antwort', + default => 'Notiz', + }; + } +} diff --git a/app/Models/PaymentIncident.php b/app/Models/PaymentIncident.php new file mode 100644 index 0000000..21a9e40 --- /dev/null +++ b/app/Models/PaymentIncident.php @@ -0,0 +1,156 @@ + */ + protected $attributes = [ + 'status' => 'open', + 'severity' => 'medium', + 'provider' => 'payone', + ]; + + protected function casts(): array + { + return [ + 'detected_at' => 'datetime', + 'resolved_at' => 'datetime', + 'affected_revenue' => 'decimal:2', + ]; + } + + public function activities(): HasMany + { + return $this->hasMany(IncidentActivity::class, 'incident_id')->orderBy('created_at'); + } + + public function scopeOpen(Builder $query): Builder + { + return $query->whereIn('status', ['open', 'in_progress', 'waiting_provider']); + } + + public function scopePayone(Builder $query): Builder + { + return $query->where('provider', 'payone'); + } + + public function scopeLastDays(Builder $query, int $days): Builder + { + return $query->where('detected_at', '>=', now()->subDays($days)); + } + + public function getDurationAttribute(): string + { + $end = $this->resolved_at ?? now(); + $diff = $this->detected_at->diff($end); + + if ($diff->days > 0) { + return $diff->days.'d '.$diff->h.'h'; + } + + if ($diff->h > 0) { + return $diff->h.'h '.$diff->i.'min'; + } + + return $diff->i.' min'; + } + + public function getSeverityColorAttribute(): string + { + return match ($this->severity) { + 'critical' => 'danger', + 'high' => 'warning', + 'medium' => 'info', + 'low' => 'success', + default => 'secondary', + }; + } + + public function getSeverityLabelAttribute(): string + { + return match ($this->severity) { + 'critical' => 'Kritisch', + 'high' => 'Hoch', + 'medium' => 'Mittel', + 'low' => 'Niedrig', + default => $this->severity, + }; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'open' => 'Offen', + 'in_progress' => 'In Bearbeitung', + 'waiting_provider' => 'Wartet auf Anbieter', + 'resolved' => 'Gelöst', + 'closed' => 'Geschlossen', + default => $this->status, + }; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'open' => 'danger', + 'in_progress' => 'warning', + 'waiting_provider' => 'info', + 'resolved' => 'success', + 'closed' => 'secondary', + default => 'secondary', + }; + } + + public function getTypeIconAttribute(): string + { + return match ($this->type) { + 'outage' => 'ion-md-power', + 'ipn_error' => 'ion-md-git-network', + 'payment_failure' => 'ion-md-card', + 'slow_response' => 'ion-md-timer', + default => 'ion-md-alert', + }; + } + + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'outage' => 'Ausfall', + 'ipn_error' => 'IPN-Fehler', + 'payment_failure' => 'Zahlungsfehler', + 'slow_response' => 'Langsame Antwort', + default => 'Sonstiges', + }; + } + + public function getProviderLabelAttribute(): string + { + return match ($this->provider) { + 'payone' => 'PAYONE', + // 'stripe' => 'Stripe', // aktuell nicht aktiv + 'paypal' => 'PayPal', + // 'mollie' => 'Mollie', // aktuell nicht aktiv + default => 'Sonstige', + }; + } +} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php index 67073ed..744b41b 100644 --- a/app/Models/PaymentTransaction.php +++ b/app/Models/PaymentTransaction.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Model; * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\ShoppingPayment $shopping_payment + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction query() @@ -39,18 +40,20 @@ use Illuminate\Database\Eloquent\Model; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereTxid($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value) + * * @property string|null $mode + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value) + * * @mixin \Eloquent */ class PaymentTransaction extends Model { protected $table = 'payment_transactions'; - protected $casts = [ - 'transmitted_data' => 'array' - ]; + 'transmitted_data' => 'array', + ]; protected $fillable = [ 'shopping_payment_id', @@ -67,9 +70,52 @@ class PaymentTransaction extends Model 'mode', ]; - public function shopping_payment() { - return $this->belongsTo('App\Models\ShoppingPayment','shopping_payment_id'); + return $this->belongsTo('App\Models\ShoppingPayment', 'shopping_payment_id'); + } + + /** + * Returns a human-readable description for known PAYONE error codes. + * + * @return array + */ + public static function payoneErrorDescriptions(): array + { + return [ + 2 => 'Allgemeiner Fehler', + 4 => 'Karte gesperrt', + 5 => 'Zahlung abgelehnt', + 12 => 'Ungültige Kartennummer', + 14 => 'Ungültige Kartendaten', + 33 => 'Karte abgelaufen', + 34 => 'Karte gesperrt/gestohlen', + 55 => 'Falsche PIN', + 56 => 'Kunde hat abgebrochen', + 58 => 'Transaktion nicht erlaubt', + 62 => 'Karte für internationale Transaktionen nicht zugelassen', + 104 => 'Karte für Online-Zahlungen nicht erlaubt', + 105 => 'Karte ungültig', + 120 => 'Falsche Prüfziffer (CVV)', + 130 => 'Limit überschritten', + 131 => 'Währung nicht unterstützt', + 135 => 'Bank antwortet nicht', + 136 => 'Bank nicht gefunden', + 137 => 'Betrag stimmt nicht überein', + 900 => '3D-Secure Authentifizierung fehlgeschlagen', + 902 => 'Bank hat abgelehnt', + 970 => 'Keine Antwort (Timeout)', + 4218 => 'Karte unter Betrugsverdacht', + 4219 => 'Transaktion von Kartenaussteller abgelehnt', + ]; + } + + public function getErrorDescriptionAttribute(): ?string + { + if (! $this->errorcode) { + return null; + } + + return self::payoneErrorDescriptions()[(int) $this->errorcode] ?? null; } } diff --git a/app/Models/ProviderUptimeLog.php b/app/Models/ProviderUptimeLog.php new file mode 100644 index 0000000..c7928ce --- /dev/null +++ b/app/Models/ProviderUptimeLog.php @@ -0,0 +1,24 @@ + 'boolean', + 'checked_at' => 'datetime', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f85bc05..ee04999 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,10 @@ namespace App\Providers; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Schema; -use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\URL; +use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { @@ -16,6 +17,8 @@ class AppServiceProvider extends ServiceProvider public function boot() { Schema::defaultStringLength(191); + Paginator::defaultView('pagination::bootstrap-4'); + Paginator::defaultSimpleView('pagination::simple-bootstrap-4'); if ($this->app->environment('production')) { URL::forceScheme('https'); @@ -33,7 +36,7 @@ class AppServiceProvider extends ServiceProvider // $userShop = $context->userShop ?? \App\Services\Util::getUserShop(); // $view->with('user_shop', $userShop); // } - + // Temporär: Verwende immer das normale Verhalten $view->with('user_shop', \App\Services\Util::getUserShop()); } catch (\Exception $e) { diff --git a/app/Services/CheckoutFunnelTracker.php b/app/Services/CheckoutFunnelTracker.php new file mode 100644 index 0000000..30747c9 --- /dev/null +++ b/app/Services/CheckoutFunnelTracker.php @@ -0,0 +1,128 @@ + $metadata + */ + public static function visitedCheckout(?int $consultantUserId = null, array $metadata = []): void + { + self::record('checkout_visited', [ + 'consultant_user_id' => $consultantUserId, + 'metadata' => $metadata ?: null, + ]); + } + + /** + * Step 2 – User submits the checkout form successfully (ShoppingOrder created). + * + * @param array $metadata + */ + public static function submittedForm( + int $shoppingUserId, + int $shoppingOrderId, + ?int $consultantUserId, + ?string $paymentMethod, + ?int $amountCents, + array $metadata = [], + ): void { + self::record('form_submitted', [ + 'shopping_user_id' => $shoppingUserId, + 'shopping_order_id' => $shoppingOrderId, + 'consultant_user_id' => $consultantUserId, + 'payment_method' => $paymentMethod, + 'amount_cents' => $amountCents, + 'metadata' => $metadata ?: null, + ]); + } + + /** + * Step 3 – User is redirected to PAYONE (ShoppingPayment created). + * + * @param array $metadata + */ + public static function initiatedPayment( + int $shoppingUserId, + int $shoppingOrderId, + int $shoppingPaymentId, + ?int $consultantUserId, + ?string $paymentMethod, + ?int $amountCents, + array $metadata = [], + ): void { + self::record('payment_initiated', [ + 'shopping_user_id' => $shoppingUserId, + 'shopping_order_id' => $shoppingOrderId, + 'shopping_payment_id' => $shoppingPaymentId, + 'consultant_user_id' => $consultantUserId, + 'payment_method' => $paymentMethod, + 'amount_cents' => $amountCents, + 'metadata' => $metadata ?: null, + ]); + } + + /** + * Step 4 – User returns from PAYONE redirect (success / cancel / error). + * + * @param array $metadata + */ + public static function returnedFromPayment( + int $shoppingPaymentId, + string $returnStatus, + array $metadata = [], + ): void { + self::record('payment_returned', [ + 'shopping_payment_id' => $shoppingPaymentId, + 'return_status' => $returnStatus, + 'metadata' => $metadata ?: null, + ]); + } + + /** + * Step 5 – PAYONE IPN callback received (PaymentTransaction created). + * + * @param array $metadata + */ + public static function confirmedPayment( + int $shoppingPaymentId, + string $txaction, + array $metadata = [], + ): void { + self::record('payment_confirmed', [ + 'shopping_payment_id' => $shoppingPaymentId, + 'metadata' => array_merge(['txaction' => $txaction], $metadata) ?: null, + ]); + } + + /** @param array $attributes */ + private static function record(string $event, array $attributes): void + { + try { + CheckoutFunnelEvent::create(array_merge([ + 'event' => $event, + 'session_id' => Session::getId(), + 'domain' => Request::getHost(), + ], $attributes)); + } catch (\Throwable $e) { + Log::warning('CheckoutFunnelTracker: could not record event', [ + 'event' => $event, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/database/migrations/2026_04_13_172135_create_payment_incidents_table.php b/database/migrations/2026_04_13_172135_create_payment_incidents_table.php new file mode 100644 index 0000000..3fa5214 --- /dev/null +++ b/database/migrations/2026_04_13_172135_create_payment_incidents_table.php @@ -0,0 +1,62 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->text('notes')->nullable(); + $table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie', 'other'])->default('payone'); + $table->enum('type', ['outage', 'ipn_error', 'payment_failure', 'slow_response', 'other'])->default('other'); + $table->enum('status', ['open', 'in_progress', 'waiting_provider', 'resolved', 'closed'])->default('open'); + $table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium'); + $table->integer('affected_orders')->default(0); + $table->decimal('affected_revenue', 10, 2)->default(0); + $table->timestamp('detected_at'); + $table->timestamp('resolved_at')->nullable(); + $table->string('ticket_number')->nullable(); + $table->timestamps(); + + $table->index(['provider', 'detected_at']); + $table->index('status'); + }); + + Schema::create('incident_activities', function (Blueprint $table) { + $table->id(); + $table->foreignId('incident_id')->constrained('payment_incidents')->onDelete('cascade'); + $table->enum('type', ['note', 'email', 'call', 'ticket', 'status_change', 'provider_response']); + $table->string('title'); + $table->text('content')->nullable(); + $table->string('author')->default('System'); + $table->timestamps(); + + $table->index('incident_id'); + }); + + Schema::create('provider_uptime_logs', function (Blueprint $table) { + $table->id(); + $table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie']); + $table->boolean('is_up')->default(true); + $table->integer('response_time_ms')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('checked_at'); + $table->timestamps(); + + $table->index(['provider', 'checked_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('provider_uptime_logs'); + Schema::dropIfExists('incident_activities'); + Schema::dropIfExists('payment_incidents'); + } +}; diff --git a/database/migrations/2026_04_13_184854_create_checkout_funnel_events_table.php b/database/migrations/2026_04_13_184854_create_checkout_funnel_events_table.php new file mode 100644 index 0000000..fb453a4 --- /dev/null +++ b/database/migrations/2026_04_13_184854_create_checkout_funnel_events_table.php @@ -0,0 +1,43 @@ +id(); + + $table->enum('event', [ + 'checkout_visited', + 'form_submitted', + 'payment_initiated', + 'payment_returned', + 'payment_confirmed', + ])->index(); + + $table->string('session_id', 100)->nullable()->index(); + $table->string('domain', 200)->nullable(); + $table->unsignedBigInteger('shopping_user_id')->nullable()->index(); + $table->unsignedBigInteger('shopping_order_id')->nullable()->index(); + $table->unsignedBigInteger('shopping_payment_id')->nullable()->index(); + $table->unsignedBigInteger('consultant_user_id')->nullable()->index(); + + $table->string('payment_method', 50)->nullable(); + $table->string('return_status', 20)->nullable(); + $table->unsignedInteger('amount_cents')->nullable(); + + $table->json('metadata')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('checkout_funnel_events'); + } +}; diff --git a/dev/payment-dashboard/ENTWICKLUNGSPLAN.md b/dev/payment-dashboard/ENTWICKLUNGSPLAN.md new file mode 100644 index 0000000..4c56afa --- /dev/null +++ b/dev/payment-dashboard/ENTWICKLUNGSPLAN.md @@ -0,0 +1,503 @@ +# Payment Dashboard – Entwicklungsplan + +**Ziel:** Integration eines Payment-Monitoring-Dashboards in mivita.care, das PAYONE-Probleme +frühzeitig sichtbar macht. Das Dashboard kombiniert manuell erfasste Incidents mit echten Daten +aus den bestehenden Tabellen (`payment_transactions`, `shopping_payments`) und den vorhandenen +PAYONE-Logs. + +--- + +## Implementierungsstand (Stand: 13.04.2026) + +| Phase | Thema | Status | Anmerkungen | +|-------|------------------------------|-------------------|-------------| +| 1 | Datenbank & Models | ✅ Umgesetzt | Migration, alle drei Models vorhanden | +| 2 | Controller & Routes | ✅ Umgesetzt | Routes in `routes/domains/crm.php`, Entwurfsdatei unter `dev/` ist veraltet | +| 3 | Views (Bootstrap/Appwork) | ✅ Umgesetzt | Index, Management, Show, Transactions, Logs + 4 Partials | +| 4 | Live-Transaktionsdaten | ✅ Umgesetzt | `transactions()` + View mit Filtern, Statistiken, Pagination | +| 5 | Log-Viewer | ✅ Umgesetzt | `?date=`-Parameter wird ausgewertet; sichere Validierung der Datumseingabe | +| 6 | Uptime-Check Artisan Command | ✅ Umgesetzt | Command `payment:check-uptime`, Schedule alle 5 Minuten, Uptime-Karten im Dashboard | +| 7 | E-Mail-Benachrichtigung | ✅ Umgesetzt | Dediziertes Mailable `PaymentIncidentAlert` (Queue), sendet bei `critical`-Incidents | +| 8 | Tests | ✅ Umgesetzt | `PaymentDashboardAccessTest`, `PaymentIncidentCrudTest`, `CheckPaymentUptimeCommandTest` | + +### Zusätzlich umgesetzt (nicht in Phasen geplant) + +- **Navigation:** Sidenav-Link mit gecachtem Badge für offene Incidents in `layout-sidenav.blade.php` +- **Übersetzungen:** `payment_monitor` in `de`, `en`, `es` navigation.php +- **Form Requests:** `StorePaymentIncidentRequest` + `AddIncidentActivityRequest` in `app/Http/Requests/PaymentIncident/` +- **Cache-Invalidierung:** `Cache::forget('open_incident_count')` in `store()`, `updateStatus()`, `addActivity()` + +### Bekannte Abweichungen vom Plan + +- **Views:** Nutzen `layouts.layout-2` statt `layout-1` (wie im Plan angegeben) +- **Entwurfsdatei** `dev/payment-dashboard/routes/payment-dashboard.php`: Veraltet — falscher Namespace (`App\Http\Controllers\PaymentDashboardController`), falsche Methodennamen (`developer` statt `index`), nur `auth`-Middleware statt `admin` + +### Offene Punkte + +1. **Phase 9 (Auto-Incident aus `PayoneController`):** Noch nicht begonnen — kritische Fehler (z.B. Error:2008) könnten automatisch Incidents anlegen +2. **Entwurfsdatei aufräumen:** `dev/payment-dashboard/routes/payment-dashboard.php` ggf. löschen oder als Archiv kennzeichnen + +--- + +## Analyse des Ist-Zustands + +### Bereits vorhandene Datenquellen + +| Quelle | Relevanz | +|-------------------------------|-------------------------------------------------------------------| +| `payment_transactions` | Jeder PAYONE-Callback: `txaction`, `errorcode`, `errormessage`, `mode`, `transmitted_data` | +| `shopping_payments` | Jede Zahlung: `reference`, `txaction`, `clearingtype`, `amount` | +| `shopping_orders` | Bestellstatus, `paid`-Flag, Verknüpfung zu Payments | +| `storage/logs/payone.log` | Fehler-Details aus `MyLog::writeLog('payone', ...)` (Error 2001–2008) | +| `storage/logs/payment.log` | Allgemeine Zahlungsfehler | + +### Bestehende PAYONE-Fehlercodes (aus `Api/PayoneController`) + +| Code | Bedeutung | +|--------|------------------------------------------------| +| 2001 | Callback: Parameter unvollständig | +| 2002 | Callback: Key-Validierung fehlgeschlagen | +| 2003 | Callback: ShoppingOrder nicht gefunden | +| 2004 | Callback: ShoppingPayment nicht gefunden | +| 2005 | Callback: Payment ↔ Order Zuordnung falsch | +| 2006 | Callback: Preisabweichung | +| 2008 | Callback: Datenbank-Transaktion fehlgeschlagen | + +### Wichtige Unterschiede zum Entwurf + +- **Layout:** Das Dashboard nutzt das bestehende Appwork/Bootstrap-Layout (`layout-1.blade.php`), + nicht das Custom-Dark-Mode-Layout aus dem Entwurf. +- **Auth:** Middleware `admin` (admin >= 2) statt nur `auth` — kein separates Rollen-System nötig. +- **Echte Daten:** Statt rein manuelle Incidents, werden bestehende Transaktionsdaten direkt + eingebunden (Live-Tab). Manuelle Incidents bleiben als Eskalationswerkzeug erhalten. +- **Logs:** Der `payone`-Log-Kanal wird direkt im Dashboard lesbar gemacht. + +--- + +## Phasen-Übersicht + +| Phase | Titel | Aufwand | Priorität | +|-------|------------------------------|----------|-----------| +| 1 | Datenbank & Models | ~1h | Muss | +| 2 | Controller & Routes | ~2h | Muss | +| 3 | Views (Bootstrap/Appwork) | ~3h | Muss | +| 4 | Live-Daten: Transaktionen | ~2h | Hoch | +| 5 | Log-Viewer | ~1h | Hoch | +| 6 | Uptime-Check Artisan Command | ~2h | Mittel | +| 7 | E-Mail-Benachrichtigung | ~1h | Mittel | +| 8 | Tests | ~2h | Muss | + +--- + +## Phase 1: Datenbank & Models ✅ + +### Migration (aus dem Entwurf übernehmen, leicht angepasst) + +**Datei:** `database/migrations/YYYY_MM_DD_000001_create_payment_incidents_table.php` + +Drei neue Tabellen: +- `payment_incidents` — manuell erfasste Störungen/Incidents +- `incident_activities` — Kommunikationsverlauf pro Incident (Notizen, Tickets, Anrufe) +- `provider_uptime_logs` — automatische Uptime-Checks (Phase 6) + +Anpassungen zum Entwurf: +- `provider`-Enum zunächst auf `payone` fokussiert (andere Provider ergänzen, wenn aktiv genutzt) +- `notes`-Feld ergänzen in `payment_incidents` für freie interne Kommentare +- Index auf `detected_at` und `provider` für Performance bei größeren Datenmengen + +### Models + +**`app/Models/PaymentIncident.php`** — aus dem Entwurf übernehmen, ergänzen um: +- `scopeOpen()` / `scopePayone()` / `scopeLastDays(int $days)` für häufige Abfragen +- `getTypeIconAttribute()` für die View-Darstellung + +**`app/Models/IncidentActivity.php`** — direkt aus dem Entwurf übernehmen + +**`app/Models/ProviderUptimeLog.php`** — neu erstellen für Phase 6 + +--- + +## Phase 2: Controller & Routes ✅ + +### Controller-Struktur + +``` +app/Http/Controllers/Admin/PaymentDashboardController.php +``` + +Platzierung unter `Admin/`, konsistent mit `PaymentSalesController.php`. + +#### Actions + +| Method | URL | Beschreibung | +|-----------|-----------------------------------------|--------------------------------------------| +| `index` | GET `/admin/payment-dashboard` | Entwickler-Ansicht: alle Incidents + Stats | +| `management` | GET `/admin/payment-dashboard/management` | GF-Ansicht: Ampel-Karten, kein Bearbeiten | +| `show` | GET `/admin/payment-dashboard/{incident}` | Incident-Detail mit Timeline | +| `store` | POST `/admin/payment-dashboard` | Neuen Incident anlegen | +| `addActivity` | POST `/admin/payment-dashboard/{incident}/activity` | Aktivität hinzufügen | +| `updateStatus` | PATCH `/admin/payment-dashboard/{incident}/status` | Status ändern | +| `transactions` | GET `/admin/payment-dashboard/transactions` | Live-PAYONE-Transaktionen (Phase 4) | +| `logs` | GET `/admin/payment-dashboard/logs` | PAYONE-Log-Viewer (Phase 5) | + +#### Stats-Methoden im Controller + +Zusätzlich zu den Stats aus dem Entwurf: +- `getPayoneTransactionStats()` — Fehlerquote der letzten 7/30 Tage aus `payment_transactions` +- `getFailedPayments()` — alle Payments mit `txaction = 'failed'` der letzten 30 Tage +- `getErrorDistribution()` — Häufigkeit der Fehlercodes 2001–2008 aus den Logs + +### Routes + +In `routes/web.php` ergänzen (nicht als separates File, analog zu bestehender Struktur): + +```php +Route::prefix('admin/payment-dashboard') + ->name('admin.payment-dashboard.') + ->middleware(['auth', 'admin']) + ->group(function () { + Route::get('/', [PaymentDashboardController::class, 'index'])->name('index'); + Route::get('/management', [PaymentDashboardController::class, 'management'])->name('management'); + Route::get('/transactions', [PaymentDashboardController::class, 'transactions'])->name('transactions'); + Route::get('/logs', [PaymentDashboardController::class, 'logs'])->name('logs'); + Route::get('/{incident}', [PaymentDashboardController::class, 'show'])->name('show'); + Route::post('/', [PaymentDashboardController::class, 'store'])->name('store'); + Route::post('/{incident}/activity', [PaymentDashboardController::class, 'addActivity'])->name('activity.store'); + Route::patch('/{incident}/status', [PaymentDashboardController::class, 'updateStatus'])->name('status.update'); + }); +``` + +--- + +## Phase 3: Views (Bootstrap/Appwork) ✅ + +### Layout-Anpassung + +**Kein** Custom-Dark-Mode-Layout. Stattdessen: `@extends('layouts.layout-1')`, analog zu +anderen Admin-Views (z.B. `resources/views/admin/payment/salesvolume.blade.php`). + +### View-Struktur + +``` +resources/views/admin/payment-dashboard/ +├── index.blade.php # Entwickler-Ansicht +├── management.blade.php # GF-Ansicht +├── show.blade.php # Incident-Detail +├── transactions.blade.php # Live-Transaktionen (Phase 4) +├── logs.blade.php # Log-Viewer (Phase 5) +└── _partials/ + ├── stats-cards.blade.php + ├── incident-table.blade.php + ├── activity-timeline.blade.php + └── create-incident-modal.blade.php +``` + +### UI-Komponenten + +**Entwickler-Ansicht (`index.blade.php`):** +- Stat-Karten oben: Offene Incidents | In Bearbeitung | PAYONE-Fehler (30 Tage) | Betroffener Umsatz +- Tabs: "Incidents" | "Live-Transaktionen" | "PAYONE Logs" +- Tabelle: Alle Incidents mit Status-Badge, Schwere-Farbe, Dauer, Quick-Status-Update +- Floating Button: "Neuen Incident anlegen" → Bootstrap-Modal + +**GF-Ansicht (`management.blade.php`):** +- Ampel-Karten: Grün/Gelb/Rot je nach offenen Incidents und Schwere +- Sehr einfach, keine Bearbeitungsfunktionen +- Tagesaktuelle Zusammenfassung + +**Incident-Detail (`show.blade.php`):** +- Kopfbereich: Titel, Provider-Badge, Status-Badge, Severity-Indikator +- Timeline: Chronologische Aktivitätsliste mit Icon je Typ +- Formulare: Aktivität hinzufügen, Status ändern +- Link zur betroffenen Bestellung (falls `ticket_number` eine Bestell-ID ist) + +--- + +## Phase 4: Live-Transaktionsdaten ✅ + +Das wertvollste Feature: Echtzeit-Einblick in PAYONE-Transaktionen aus bestehenden Tabellen. + +### Tab "Live-Transaktionen" in der Entwickler-Ansicht + +Datenquelle: `payment_transactions` JOIN `shopping_payments` JOIN `shopping_orders` + +**Anzeigen:** +- Alle Transaktionen der letzten 7 Tage, gefiltert nach `txaction` +- Fehlgeschlagene Transaktionen (`txaction = 'failed'`) hervorgehoben in Rot +- Fehlercodes und Fehlermeldungen aus `errorcode` / `errormessage` / `customermessage` +- `mode`-Feld: unterscheidet Test vs. Live-Modus (wichtig für Debugging) +- `transmitted_data` JSON: aufklappbar für Detailinspektion + +**Filter-Optionen:** +- Nach `txaction`: `appointed`, `pending`, `paid`, `failed` +- Nach Zeitraum: Heute / Letzte 7 Tage / Letzte 30 Tage +- Nach Modus: Test / Live + +**Stat-Block oben:** +- Erfolgsrate (paid / gesamt) der letzten 24h +- Anzahl `failed` Transaktionen heute +- Letzte `failed` Transaktion: vor X Minuten +- Verteilung der `clearingtype` (wlt, cc, elv…) + +### Neue Methode im Controller + +```php +private function getTransactionStats(int $days = 7): array +{ + $since = now()->subDays($days); + return [ + 'total' => PaymentTransaction::where('created_at', '>=', $since)->count(), + 'failed' => PaymentTransaction::where('txaction', 'failed')->where('created_at', '>=', $since)->count(), + 'paid' => PaymentTransaction::where('txaction', 'paid')->where('created_at', '>=', $since)->count(), + 'errors' => PaymentTransaction::whereNotNull('errorcode')->where('created_at', '>=', $since) + ->select('errorcode', 'errormessage', DB::raw('count(*) as count')) + ->groupBy('errorcode', 'errormessage') + ->orderByDesc('count') + ->get(), + 'last_failed' => PaymentTransaction::where('txaction', 'failed')->latest()->first(), + ]; +} +``` + +--- + +## Phase 5: Log-Viewer ✅ + +### Tab "PAYONE Logs" in der Entwickler-Ansicht + +Liest direkt aus `storage/logs/payone.log` (aktuellste Datei bei daily rotation). + +**Anzeigen:** +- Letzte 100 Log-Einträge (konfigurierbar) +- Farbliche Markierung nach Level: `error` (rot), `warning` (gelb), `info` (blau), `notice` (grau) +- Suche/Filter nach Fehlercode (z.B. "Error:2003") +- Zeitstempel, Log-Level, Nachricht, JSON-Payload (aufklappbar) + +**Implementierung:** + +```php +public function logs(): View +{ + $logPath = storage_path('logs/payone-' . now()->format('Y-m-d') . '.log'); + $entries = []; + + if (file_exists($logPath)) { + $lines = array_reverse(file($logPath)); + foreach (array_slice($lines, 0, 200) 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')); +} +``` + +--- + +## Phase 6: Artisan Command – Uptime-Check ✅ + +### `php artisan payment:check-uptime` + +**Datei:** `app/Console/Commands/CheckPaymentUptime.php` + +Prüft erreichbare PAYONE-Endpunkte (Status-API oder bekannte öffentliche URLs) und legt bei +Ausfall automatisch einen Incident an. + +```php +// Prüft: PAYONE Server-Status-Seite oder einen konfigurierbaren Health-Endpoint +// Speichert Ergebnis in provider_uptime_logs +// Bei Ausfall: erstellt PaymentIncident mit severity = 'critical', type = 'outage' +// Bei Wiederherstellung: setzt offene Outage-Incidents auf 'resolved' +``` + +**Scheduling in `app/Console/Kernel.php`:** + +```php +$schedule->command('payment:check-uptime')->everyFiveMinutes(); +``` + +**Konfiguration in `.env` / `config/services.php`:** + +``` +PAYONE_HEALTH_CHECK_URL=https://api.pay1.de/post-gateway/ +PAYONE_HEALTH_CHECK_ENABLED=true +``` + +--- + +## Phase 7: E-Mail-Benachrichtigung ✅ + +Bei neu eröffnetem Critical-Incident: automatische Mail via bestehenden `MyLog`-Mechanismus. + +```php +// In PaymentDashboardController::store() +if ($validated['severity'] === 'critical') { + MyLog::writeLog( + 'payment', + 'error', + 'Kritischer Zahlungs-Incident eröffnet: ' . $validated['title'], + $validated, + true // sendet Mail an config('app.exception_mail') + ); +} +``` + +Alternativ: Dedizierte Mailable `App\Mail\PaymentIncidentAlert` für bessere Darstellung. + +--- + +## Phase 8: Tests ✅ (vollständig) + +**Feature-Tests:** + +``` +tests/Feature/PaymentDashboard/ +├── PaymentDashboardAccessTest.php # Auth, Admin-Middleware +├── PaymentIncidentCrudTest.php # Create, Status-Update, Aktivität +├── PaymentDashboardStatsTest.php # Korrekte Stats aus Testdaten +└── CheckPaymentUptimeCommandTest.php # Artisan Command (Phase 6) +``` + +**Testszenarien:** +- Nicht eingeloggter User → Redirect +- Eingeloggter User ohne Admin (admin < 2) → 403 +- Admin (admin >= 2) → Zugriff auf Entwickler-Ansicht +- Incident anlegen: Pflichtfelder, korrekte Aktivität wird auto-erstellt +- Status auf "resolved" setzen → `resolved_at` wird gesetzt +- Stats-Methoden liefern korrekte Werte mit Testdaten + +--- + +## Implementierungsreihenfolge (empfohlen) + +``` +Phase 1 (Migration + Models) + → Phase 2 (Routes + Controller-Grundstruktur) + → Phase 3 (Views mit Bootstrap, ohne Echtdaten) + → Phase 4 (Live-Transaktionsdaten einbauen) + → Phase 5 (Log-Viewer) + → Phase 8 (Tests für Phasen 1–5) + → Phase 6 (Uptime-Check, optional) + → Phase 7 (Benachrichtigung, optional) +``` + +--- + +## Datenbankzugriff auf bestehende Daten – kein Code-Eingriff nötig + +Phase 4 liest nur lesend aus bestehenden Tabellen. Es sind **keine Änderungen** an: +- `Api/PayoneController` (PAYONE-Callback-Handler) +- `ShoppingPayment`, `PaymentTransaction` (Models) +- bestehenden Migrations + +--- + +## Navigation (`layout-sidenav.blade.php`) + +**Datei:** `resources/views/layouts/includes/layout-sidenav.blade.php` + +Der Eintrag kommt als letztes Item im bestehenden `admin/payments`-Untermenü (nach "Steuerberater", +aktuell Zeile ~372). **Kein** eigener Top-Level-Eintrag — das Dashboard gehört thematisch zu Payments. + +```blade +
  • + + +
    {{ __('navigation.payment_monitor') }}
    + @php $openIncidentCount = Cache::remember('open_incident_count', 60, fn() => + \App\Models\PaymentIncident::whereIn('status', ['open','in_progress','waiting_provider'])->count() + ); @endphp + @if ($openIncidentCount > 0) +
    +
    {{ $openIncidentCount }}
    +
    + @endif +
    +
  • +``` + +Der Badge ist gecacht (60 Sekunden) um N+1-Queries bei jedem Seitenaufruf zu vermeiden. +Der Cache wird im Controller bei Incident-Änderungen mit `Cache::forget('open_incident_count')` invalidiert. + +--- + +## Übersetzungsschlüssel + +In alle drei Sprachdateien ergänzen: + +**`resources/lang/de/navigation.php`** +```php +'payment_monitor' => 'Payment Monitor', +``` + +**`resources/lang/en/navigation.php`** +```php +'payment_monitor' => 'Payment Monitor', +``` + +**`resources/lang/es/navigation.php`** +```php +'payment_monitor' => 'Monitor de Pagos', +``` + +--- + +## Form Request Klassen + +Laut Projektkonventionen: keine Inline-Validierung im Controller. + +``` +app/Http/Requests/PaymentIncident/ +├── StorePaymentIncidentRequest.php # Validierung für store() +└── AddIncidentActivityRequest.php # Validierung für addActivity() +``` + +--- + +## Auto-Incident aus `PayoneController` (Phase 9, optional) + +Der wertvollste Ausbauschritt: Kritische Fehler im PAYONE-Callback-Handler legen automatisch +einen Incident an, ohne manuelle Erfassung. + +**Eingriff in `app/Http/Controllers/Api/PayoneController.php`:** + +```php +// Bei Error:2008 (DB-Rollback) → sofort Critical-Incident +} catch (\Exception $e) { + \DB::rollBack(); + MyLog::writeLog('payone', 'error', 'Error:2008 ...', [...]); + + // NEU: Automatischer Critical-Incident + PaymentIncident::firstOrCreate( + ['type' => 'payment_failure', 'status' => 'open', 'provider' => 'payone', + 'detected_at' => now()->startOfHour()], // deduplication per Stunde + ['title' => 'Automatisch: DB-Fehler bei PAYONE-Callback (Error:2008)', + 'severity' => 'critical', 'ticket_number' => $data['txid'] ?? null] + ); +} +``` + +`firstOrCreate` mit Stunden-Deduplication verhindert Duplikate bei mehrfachen Fehlern. + +--- + +## Offene Fragen vor Start + +1. **Welcher Admin-User ist "Alois"?** Hat er `admin >= 2`? Falls nicht → eigene schlanke + Route ohne `admin`-Middleware, geschützt durch separaten Login oder feste User-ID-Prüfung. + unter Superadmin! + +2. **PAYONE Health-Endpoint:** Welche URL soll der Uptime-Check prüfen? + (Phase 6 — kann vorerst übersprungen werden) + +3. **Mollie/Stripe/PayPal aktiv?** Falls nein → Provider-Enum im ersten Schritt auf + `payone` + `other` reduzieren, spätere Erweiterung bleibt möglich. + +4. **Cache-Invalidierung:** `Cache::forget('open_incident_count')` muss in + `store()`, `updateStatus()` und dem Artisan Command aufgerufen werden. diff --git a/dev/payment-dashboard/README.md b/dev/payment-dashboard/README.md new file mode 100644 index 0000000..9c79968 --- /dev/null +++ b/dev/payment-dashboard/README.md @@ -0,0 +1,100 @@ +# mivita Payment Dashboard – Laravel Modul + +Aktivitäts- und Störungs-Dashboard für die Zahlungsanbieter (PAYONE, Stripe, PayPal, Mollie). + +--- + +## Installation + +### 1. Dateien kopieren + +Kopiere die Ordner in dein bestehendes Laravel-Projekt: + +``` +database/migrations/ → in dein database/migrations/ +app/Models/ → PaymentIncident.php + IncidentActivity.php nach app/Models/ +app/Http/Controllers/ → PaymentDashboardController.php nach app/Http/Controllers/ +resources/views/ → Ordner dashboard/ und layouts/dashboard.blade.php +routes/ → payment-dashboard.php +``` + +### 2. Migration ausführen + +```bash +php artisan migrate +``` + +Erstellt drei Tabellen: +- `payment_incidents` – Incidents / Störungen +- `incident_activities` – Kommunikationsverlauf pro Incident +- `provider_uptime_logs` – Uptime-Logs (für spätere Automatisierung) + +### 3. Routes einbinden + +In `routes/web.php` ergänzen: + +```php +require base_path('routes/payment-dashboard.php'); +``` + +### 4. Layout prüfen + +Das Dashboard hat ein eigenes Layout (`layouts/dashboard.blade.php`). +Falls du ein bestehendes App-Layout verwenden willst, passe die +`@extends`-Direktive in den Views entsprechend an. + +--- + +## URLs + +| URL | Beschreibung | +|----------------------------------|-------------------------------------| +| `/payment-dashboard` | Entwickler-Ansicht (Kevin) | +| `/payment-dashboard/management` | GF-Ansicht (Alois) – read-only | +| `/payment-dashboard/{id}` | Incident-Detail mit Timeline | + +--- + +## Features + +### Entwickler-Ansicht +- Alle offenen Incidents mit Schnell-Status-Update +- Neuen Incident anlegen (Modal) +- Kommunikations-Timeline (E-Mails, Tickets, Calls, Notizen) +- Vollständige Incident-Tabelle mit Pagination +- Anbieter-Übersicht (PAYONE, Stripe, PayPal, Mollie) + +### GF-Ansicht (Alois) +- Klare Ampel-Karten: Offene Störungen, betroffener Umsatz, PAYONE-Probleme +- Anbieter-Status auf einen Blick +- Aktive Störungen mit Schwere und Dauer +- Letzte Vorfälle als Tabelle + +### Incident-Detail +- Vollständiger Kommunikationsverlauf als Timeline +- Aktivitäten hinzufügen (E-Mail, Telefonat, Ticket, Notiz, Anbieter-Antwort) +- Status direkt ändern (löst automatisch Aktivitätseintrag aus) + +--- + +## Nächste Ausbaustufen (optional) + +- **Automatischer Uptime-Check**: Artisan Command, der PAYONE/Stripe-Endpunkte + periodisch prüft und bei Ausfall automatisch einen Incident anlegt: + ```bash + php artisan payment:check-uptime + ``` + +- **E-Mail-Benachrichtigung**: Bei neuem Critical-Incident automatisch Mail + an kevin@adametz-media.de + +- **Rollen**: Middleware, die Alois nur auf `/management` weiterleitet + +--- + +## Technik + +- Laravel (Blade Templates, Eloquent, Form Requests) +- Kein zusätzliches JS-Framework – reines Blade + CSS +- Dark Mode Design, responsive +- Alle Labels auf Deutsch diff --git a/dev/payment-dashboard/app/Http/Controllers/PaymentDashboardController.php b/dev/payment-dashboard/app/Http/Controllers/PaymentDashboardController.php new file mode 100644 index 0000000..1720d35 --- /dev/null +++ b/dev/payment-dashboard/app/Http/Controllers/PaymentDashboardController.php @@ -0,0 +1,152 @@ +getStats(); + $recentIncidents = PaymentIncident::orderBy('detected_at', 'desc')->take(5)->get(); + $openIncidents = PaymentIncident::whereIn('status', ['open', 'in_progress', 'waiting_provider'])->get(); + $providerStats = $this->getProviderStats(); + + return view('dashboard.management', compact('stats', 'recentIncidents', 'openIncidents', 'providerStats')); + } + + // ─── Entwickler-Ansicht (Kevin) ─────────────────────────────────────────── + public function developer() + { + $stats = $this->getStats(); + $allIncidents = PaymentIncident::with('activities')->orderBy('detected_at', 'desc')->paginate(20); + $openIncidents = PaymentIncident::whereIn('status', ['open', 'in_progress', 'waiting_provider'])->with('activities')->get(); + $providerStats = $this->getProviderStats(); + $recentActivity = IncidentActivity::with('incident')->orderBy('created_at', 'desc')->take(10)->get(); + + return view('dashboard.developer', compact('stats', 'allIncidents', 'openIncidents', 'providerStats', 'recentActivity')); + } + + // ─── Incident erstellen ─────────────────────────────────────────────────── + public function store(Request $request) + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'provider' => 'required|in:payone,stripe,paypal,mollie,other', + 'type' => 'required|in:outage,ipn_error,payment_failure,slow_response,other', + 'severity' => 'required|in:low,medium,high,critical', + 'affected_orders' => 'nullable|integer|min:0', + 'affected_revenue' => 'nullable|numeric|min:0', + 'ticket_number' => 'nullable|string|max:100', + 'detected_at' => 'required|date', + ]); + + $incident = PaymentIncident::create($validated); + + // Erste Aktivität automatisch anlegen + IncidentActivity::create([ + 'incident_id' => $incident->id, + 'type' => 'note', + 'title' => 'Incident eröffnet', + 'content' => $validated['description'] ?? null, + 'author' => auth()->user()->name ?? 'Kevin', + ]); + + return redirect()->route('payment-dashboard.developer') + ->with('success', 'Incident erfolgreich angelegt.'); + } + + // ─── Aktivität hinzufügen ───────────────────────────────────────────────── + public function addActivity(Request $request, PaymentIncident $incident) + { + $validated = $request->validate([ + 'type' => 'required|in:note,email,call,ticket,status_change,provider_response', + 'title' => 'required|string|max:255', + 'content' => 'nullable|string', + ]); + + IncidentActivity::create([ + 'incident_id' => $incident->id, + 'type' => $validated['type'], + 'title' => $validated['title'], + 'content' => $validated['content'] ?? null, + 'author' => auth()->user()->name ?? 'Kevin', + ]); + + // Status automatisch auf "in_progress" setzen wenn noch offen + if ($incident->status === 'open') { + $incident->update(['status' => 'in_progress']); + } + + return back()->with('success', 'Aktivität hinzugefügt.'); + } + + // ─── Status ändern ──────────────────────────────────────────────────────── + public function updateStatus(Request $request, PaymentIncident $incident) + { + $request->validate(['status' => 'required|in:open,in_progress,waiting_provider,resolved,closed']); + + $oldStatus = $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: '.$oldStatus.' → '.$incident->fresh()->status_label, + 'author' => auth()->user()->name ?? 'Kevin', + ]); + + return back()->with('success', 'Status aktualisiert.'); + } + + // ─── Incident Detail ────────────────────────────────────────────────────── + public function show(PaymentIncident $incident) + { + $incident->load('activities'); + + return view('dashboard.show', compact('incident')); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + private function getStats(): 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::whereIn('status', ['open', 'in_progress', 'waiting_provider']) + ->sum('affected_revenue'), + 'payone_incidents_30d' => PaymentIncident::where('provider', 'payone') + ->where('detected_at', '>=', now()->subDays(30))->count(), + ]; + } + + private function getProviderStats(): array + { + $providers = ['payone', 'stripe', 'paypal', 'mollie']; + $stats = []; + + foreach ($providers as $provider) { + $stats[$provider] = [ + 'label' => strtoupper($provider), + 'open_incidents' => PaymentIncident::where('provider', $provider) + ->whereIn('status', ['open', 'in_progress', 'waiting_provider'])->count(), + 'total_30d' => PaymentIncident::where('provider', $provider) + ->where('detected_at', '>=', now()->subDays(30))->count(), + 'last_incident' => PaymentIncident::where('provider', $provider) + ->orderBy('detected_at', 'desc')->first(), + ]; + } + + return $stats; + } +} diff --git a/dev/payment-dashboard/app/Models/IncidentActivity.php b/dev/payment-dashboard/app/Models/IncidentActivity.php new file mode 100644 index 0000000..aa864dd --- /dev/null +++ b/dev/payment-dashboard/app/Models/IncidentActivity.php @@ -0,0 +1,40 @@ +belongsTo(PaymentIncident::class, 'incident_id'); + } + + public function getTypeIconAttribute(): string + { + return match ($this->type) { + 'email' => '✉️', + 'call' => '📞', + 'ticket' => '🎫', + 'status_change' => '🔄', + 'provider_response' => '💬', + default => '📝', + }; + } + + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'email' => 'E-Mail', + 'call' => 'Telefonat', + 'ticket' => 'Support-Ticket', + 'status_change' => 'Statusänderung', + 'provider_response' => 'Anbieter-Antwort', + default => 'Notiz', + }; + } +} diff --git a/dev/payment-dashboard/app/Models/PaymentIncident.php b/dev/payment-dashboard/app/Models/PaymentIncident.php new file mode 100644 index 0000000..134d77d --- /dev/null +++ b/dev/payment-dashboard/app/Models/PaymentIncident.php @@ -0,0 +1,74 @@ + 'datetime', + 'resolved_at' => 'datetime', + 'affected_revenue' => 'decimal:2', + ]; + + public function activities(): HasMany + { + return $this->hasMany(IncidentActivity::class, 'incident_id'); + } + + public function getDurationAttribute(): string + { + $end = $this->resolved_at ?? now(); + $diff = $this->detected_at->diff($end); + if ($diff->days > 0) { + return $diff->days.'d '.$diff->h.'h'; + } + if ($diff->h > 0) { + return $diff->h.'h '.$diff->i.'min'; + } + + return $diff->i.' min'; + } + + public function getSeverityColorAttribute(): string + { + return match ($this->severity) { + 'critical' => '#ef4444', + 'high' => '#f97316', + 'medium' => '#eab308', + 'low' => '#22c55e', + default => '#6b7280', + }; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'open' => 'Offen', + 'in_progress' => 'In Bearbeitung', + 'waiting_provider' => 'Wartet auf Anbieter', + 'resolved' => 'Gelöst', + 'closed' => 'Geschlossen', + default => $this->status, + }; + } + + public function getProviderLabelAttribute(): string + { + return match ($this->provider) { + 'payone' => 'PAYONE', + 'stripe' => 'Stripe', + 'paypal' => 'PayPal', + 'mollie' => 'Mollie', + default => 'Sonstige', + }; + } +} diff --git a/dev/payment-dashboard/database/migrations/2024_01_01_000001_create_payment_incidents_table.php b/dev/payment-dashboard/database/migrations/2024_01_01_000001_create_payment_incidents_table.php new file mode 100644 index 0000000..fe46eb5 --- /dev/null +++ b/dev/payment-dashboard/database/migrations/2024_01_01_000001_create_payment_incidents_table.php @@ -0,0 +1,54 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie', 'other'])->default('payone'); + $table->enum('type', ['outage', 'ipn_error', 'payment_failure', 'slow_response', 'other'])->default('other'); + $table->enum('status', ['open', 'in_progress', 'waiting_provider', 'resolved', 'closed'])->default('open'); + $table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium'); + $table->integer('affected_orders')->default(0); + $table->decimal('affected_revenue', 10, 2)->default(0); + $table->timestamp('detected_at'); + $table->timestamp('resolved_at')->nullable(); + $table->string('ticket_number')->nullable(); + $table->timestamps(); + }); + + Schema::create('incident_activities', function (Blueprint $table) { + $table->id(); + $table->foreignId('incident_id')->constrained('payment_incidents')->onDelete('cascade'); + $table->enum('type', ['note', 'email', 'call', 'ticket', 'status_change', 'provider_response']); + $table->string('title'); + $table->text('content')->nullable(); + $table->string('author')->default('Kevin'); + $table->timestamps(); + }); + + Schema::create('provider_uptime_logs', function (Blueprint $table) { + $table->id(); + $table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie']); + $table->boolean('is_up')->default(true); + $table->integer('response_time_ms')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('checked_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('provider_uptime_logs'); + Schema::dropIfExists('incident_activities'); + Schema::dropIfExists('payment_incidents'); + } +}; diff --git a/dev/payment-dashboard/resources/views/dashboard/developer.blade.php b/dev/payment-dashboard/resources/views/dashboard/developer.blade.php new file mode 100644 index 0000000..9a1fbb2 --- /dev/null +++ b/dev/payment-dashboard/resources/views/dashboard/developer.blade.php @@ -0,0 +1,264 @@ +@extends('layouts.dashboard') +@section('page-title', 'Entwickler-Dashboard') + +@section('content') + +{{-- ── Stats ── --}} +
    +
    +
    Offen / Wartend
    +
    {{ $stats['open_incidents'] }}
    +
    Incidents ohne Lösung
    +
    +
    +
    In Bearbeitung
    +
    {{ $stats['in_progress'] }}
    +
    Aktiv bearbeitet
    +
    +
    +
    Gelöst diesen Monat
    +
    {{ $stats['resolved_this_month'] }}
    +
    {{ now()->format('F Y') }}
    +
    +
    +
    PAYONE (30 Tage)
    +
    {{ $stats['payone_incidents_30d'] }}
    +
    Incidents bei PAYONE
    +
    +
    + +{{-- ── Anbieter-Status ── --}} +
    +
    +
    Anbieter-Status
    +
    +
    + @foreach($providerStats as $key => $provider) +
    +
    {{ $provider['label'] }}
    +
    {{ $provider['open_incidents'] }}
    +
    offene Störungen
    +
    {{ $provider['total_30d'] }}× in 30 Tagen
    + @if($provider['last_incident']) +
    + Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y H:i') }} +
    + @endif +
    + @endforeach +
    +
    + +
    + {{-- ── Offene Incidents ── --}} +
    +
    +
    Offene Incidents
    + +
    + + @forelse($openIncidents as $incident) +
    +
    +
    +
    {{ $incident->title }}
    +
    + {{ ucfirst($incident->severity) }} + {{ $incident->status_label }} + {{ $incident->provider_label }} +
    +
    + Detail → +
    + +
    + Erkannt: {{ $incident->detected_at->format('d.m.Y H:i') }} · Dauer: {{ $incident->duration }} + @if($incident->ticket_number) + · Ticket: {{ $incident->ticket_number }} + @endif + @if($incident->affected_orders > 0) + · {{ $incident->affected_orders }} Bestellungen betroffen + @endif +
    + + {{-- Letzte Aktivität --}} + @if($incident->activities->count() > 0) + @php $last = $incident->activities->sortByDesc('created_at')->first(); @endphp +
    + {{ $last->type_icon }} Letzte Aktivität: + {{ $last->title }} + – {{ $last->created_at->diffForHumans() }} +
    + @endif + + {{-- Schnell-Status-Update --}} +
    + @csrf @method('PATCH') + + +
    +
    + @empty +
    +
    +
    Keine offenen Incidents
    +
    + @endforelse +
    + + {{-- ── Letzte Aktivitäten ── --}} +
    +
    Kommunikations-Verlauf
    +
    +
    + @forelse($recentActivity as $activity) +
    +
    +
    + {{ $activity->type_icon }} {{ $activity->title }} +
    +
    + {{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }} + @if($activity->incident) + · {{ $activity->incident->title }} + @endif +
    + @if($activity->content) +
    {{ $activity->content }}
    + @endif +
    + @empty +
    Noch keine Aktivitäten erfasst.
    + @endforelse +
    +
    +
    +
    + +{{-- ── Alle Incidents Tabelle ── --}} +
    +
    +
    Alle Incidents
    + {{ $allIncidents->total() }} gesamt +
    +
    + + + + + + + + + + + + + + + + + @forelse($allIncidents as $incident) + + + + + + + + + + + + + @empty + + @endforelse + +
    #TitelAnbieterTypSchwereStatusErkanntDauerTicket
    #{{ $incident->id }}{{ $incident->title }}{{ $incident->provider_label }}{{ $incident->type }}{{ ucfirst($incident->severity) }}{{ $incident->status_label }}{{ $incident->detected_at->format('d.m.Y H:i') }}{{ $incident->duration }}{{ $incident->ticket_number ?? '–' }}Detail
    Noch keine Incidents.
    +
    +
    {{ $allIncidents->links() }}
    +
    + +{{-- ── Modal: Neuer Incident ── --}} + + +@endsection diff --git a/dev/payment-dashboard/resources/views/dashboard/management.blade.php b/dev/payment-dashboard/resources/views/dashboard/management.blade.php new file mode 100644 index 0000000..bbf9a71 --- /dev/null +++ b/dev/payment-dashboard/resources/views/dashboard/management.blade.php @@ -0,0 +1,120 @@ +@extends('layouts.dashboard') +@section('page-title', 'Zahlungssystem – Übersicht') + +@section('content') + +{{-- ── Stat-Karten ── --}} +
    +
    +
    Offene Störungen
    +
    {{ $stats['open_incidents'] }}
    +
    Warten auf Lösung
    +
    +
    +
    In Bearbeitung
    +
    {{ $stats['in_progress'] }}
    +
    Aktiv bearbeitet
    +
    +
    +
    Betroffener Umsatz
    +
    {{ number_format($stats['total_affected_revenue'], 0, ',', '.') }} €
    +
    Offene Incidents
    +
    +
    +
    PAYONE Probleme
    +
    {{ $stats['payone_incidents_30d'] }}
    +
    Letzte 30 Tage
    +
    +
    + +{{-- ── Anbieter-Status ── --}} +
    +
    Anbieter-Übersicht
    +
    + @foreach($providerStats as $key => $provider) +
    +
    {{ $provider['label'] }}
    +
    {{ $provider['open_incidents'] }}
    +
    offene Störungen
    +
    {{ $provider['total_30d'] }}× in 30 Tagen
    + @if($provider['last_incident']) +
    + Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y') }} +
    + @endif +
    + @endforeach +
    +
    + +{{-- ── Offene Störungen ── --}} +@if($openIncidents->count() > 0) +
    +
    ⚠ Aktive Störungen
    +
    + + + + + + + + + + + + + @foreach($openIncidents as $incident) + + + + + + + + + @endforeach + +
    StörungAnbieterSchwereStatusSeitBetroffene Bestellungen
    {{ $incident->title }}{{ $incident->provider_label }}{{ ucfirst($incident->severity) }}{{ $incident->status_label }}{{ $incident->detected_at->format('d.m.Y H:i') }}
    {{ $incident->duration }}
    {{ $incident->affected_orders > 0 ? $incident->affected_orders . ' Bestellungen' : '–' }}
    +
    +
    +@else +
    +
    +
    Keine aktiven Störungen
    +
    Alle Zahlungssysteme laufen normal.
    +
    +@endif + +{{-- ── Letzte Incidents ── --}} +
    +
    Letzte Vorfälle
    +
    + + + + + + + + + + + + @forelse($recentIncidents as $incident) + + + + + + + + @empty + + @endforelse + +
    TitelAnbieterDatumStatusDauer
    {{ $incident->title }}{{ $incident->provider_label }}{{ $incident->detected_at->format('d.m.Y') }}{{ $incident->status_label }}{{ $incident->duration }}
    Noch keine Vorfälle erfasst.
    +
    +
    + +@endsection diff --git a/dev/payment-dashboard/resources/views/dashboard/show.blade.php b/dev/payment-dashboard/resources/views/dashboard/show.blade.php new file mode 100644 index 0000000..3ebafee --- /dev/null +++ b/dev/payment-dashboard/resources/views/dashboard/show.blade.php @@ -0,0 +1,150 @@ +@extends('layouts.dashboard') +@section('page-title', 'Incident #' . $incident->id) + +@section('content') + +
    + ← Zurück + {{ ucfirst($incident->severity) }} + {{ $incident->status_label }} + {{ $incident->provider_label }} · {{ $incident->detected_at->format('d.m.Y H:i') }} · {{ $incident->duration }} +
    + +
    + {{-- ── Incident-Details ── --}} +
    +
    +
    Incident-Details
    +
    {{ $incident->title }}
    + + @if($incident->description) +
    + {{ $incident->description }} +
    + @endif + + + + + + + + + + + + + + + @if($incident->resolved_at) + + + + + @endif + + + + + @if($incident->ticket_number) + + + + + @endif + @if($incident->affected_orders > 0) + + + + + @endif + @if($incident->affected_revenue > 0) + + + + + @endif +
    Anbieter{{ $incident->provider_label }}
    Typ{{ $incident->type }}
    Erkannt{{ $incident->detected_at->format('d.m.Y H:i') }} Uhr
    Gelöst{{ $incident->resolved_at->format('d.m.Y H:i') }} Uhr
    Dauer{{ $incident->duration }}
    Ticket-Nr.{{ $incident->ticket_number }}
    Bestellungen{{ $incident->affected_orders }} betroffen
    Umsatz{{ number_format($incident->affected_revenue, 2, ',', '.') }} €
    +
    + + {{-- Status ändern --}} +
    +
    Status aktualisieren
    +
    + @csrf @method('PATCH') + + +
    +
    + + {{-- Aktivität hinzufügen --}} +
    +
    Aktivität hinzufügen
    +
    + @csrf +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + + {{-- ── Aktivitäts-Timeline ── --}} +
    +
    Kommunikations-Verlauf ({{ $incident->activities->count() }} Einträge)
    +
    + @if($incident->activities->count() > 0) +
    + @foreach($incident->activities->sortByDesc('created_at') as $activity) +
    +
    +
    + {{ $activity->type_icon }} {{ $activity->title }} +
    +
    + {{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }} Uhr + · {{ $activity->created_at->diffForHumans() }} +
    + @if($activity->content) +
    {{ $activity->content }}
    + @endif +
    + @endforeach +
    + @else +
    Noch keine Aktivitäten erfasst.
    + @endif +
    +
    +
    + +@endsection diff --git a/dev/payment-dashboard/resources/views/layouts/dashboard.blade.php b/dev/payment-dashboard/resources/views/layouts/dashboard.blade.php new file mode 100644 index 0000000..78516a1 --- /dev/null +++ b/dev/payment-dashboard/resources/views/layouts/dashboard.blade.php @@ -0,0 +1,402 @@ + + + + + + + @yield('title', 'Payment Dashboard') – mivita + + @stack('styles') + + +
    + + +
    +
    +
    +
    @yield('page-title', 'Dashboard')
    +
    +
    + Stand: {{ now()->format('d.m.Y H:i') }} Uhr +
    +
    + +
    + @if(session('success')) +
    ✓ {{ session('success') }}
    + @endif + @if(session('error')) +
    ✗ {{ session('error') }}
    + @endif + + @yield('content') +
    +
    +
    +@stack('scripts') + + diff --git a/dev/payment-dashboard/routes/payment-dashboard.php b/dev/payment-dashboard/routes/payment-dashboard.php new file mode 100644 index 0000000..2bd4ac8 --- /dev/null +++ b/dev/payment-dashboard/routes/payment-dashboard.php @@ -0,0 +1,35 @@ +name('payment-dashboard.')->middleware(['auth'])->group(function () { + + // GF-Ansicht (Alois) – vereinfacht, nur lesen + Route::get('/management', [PaymentDashboardController::class, 'management']) + ->name('management'); + + // Entwickler-Ansicht (Kevin) – voller Zugriff + Route::get('/', [PaymentDashboardController::class, 'developer']) + ->name('developer'); + + // Incident Detail + Route::get('/{incident}', [PaymentDashboardController::class, 'show']) + ->name('show'); + + // Neuen Incident anlegen + Route::post('/', [PaymentDashboardController::class, 'store']) + ->name('store'); + + // Aktivität zu Incident hinzufügen + Route::post('/{incident}/activity', [PaymentDashboardController::class, 'addActivity']) + ->name('activity.store'); + + // Status eines Incidents ändern + Route::patch('/{incident}/status', [PaymentDashboardController::class, 'updateStatus']) + ->name('status.update'); +}); diff --git a/resources/lang/de/incentive.php b/resources/lang/de/incentive.php index ffa7676..e75e61f 100644 --- a/resources/lang/de/incentive.php +++ b/resources/lang/de/incentive.php @@ -63,8 +63,12 @@ return [ 'no_participants' => 'Noch keine Teilnehmer.', 'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.', 'anonymous_consultant' => 'Anonymer Berater', + 'ranking_all_active' => 'Alle Aktiven', + 'vip_view_notice' => 'VIP-Ansicht: Klarnamen aller Teilnehmer werden angezeigt.', + 'vip_terms_accepted' => 'Teilnahmebedingungen akzeptiert', + 'vip_terms_pending' => 'Teilnahmebedingungen noch nicht akzeptiert', 'ranking_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.', - 'ranking_extended_hint' => 'Die Liste zeigt die Plätze 1–30. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.', + 'ranking_extended_hint' => 'Die Liste zeigt alle Berater mit mehr als 0 Punkten. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.', 'calculation_details' => 'Berechnungsdetails', 'close' => 'Schliessen', @@ -136,6 +140,14 @@ return [ 'you_participate' => 'Du nimmst teil!', 'your_rank' => 'Dein aktueller Rang', 'participate_intro' => 'Bist du bereit für die Challenge? Melde dich einmalig an, um im offiziellen Ranking gelistet zu werden.', + 'dash_notice_unregistered_title' => 'Noch nicht angemeldet', + 'dash_notice_unregistered_body' => 'Du nimmst am Incentive noch nicht offiziell teil. Ohne Bestätigung werden deine Punkte nicht gewertet und du erscheinst nicht in der Rangliste.', + 'dash_notice_unconfirmed_title' => 'Teilnahme noch nicht bestätigt', + 'dash_notice_unconfirmed_body' => 'Deine Punkte laufen bereits mit – aber ohne Bestätigung der Teilnahmebedingungen wirst du in der Rangliste anonym angezeigt und kannst nicht gewinnen.', + 'dash_notice_btn' => 'Jetzt Teilnahme bestätigen', + 'dash_modal_title' => 'Teilnahme bestätigen', + 'dash_modal_intro' => 'Bitte lies die Informationen und Teilnahmebedingungen sorgfältig durch und bestätige anschließend deine Teilnahme.', + 'dash_modal_cancel' => 'Schließen', 'pending_confirmation_banner' => 'Deine Punkte werden bereits im Qualifikationszeitraum mitgerechnet. Bitte bestätige die Teilnahme, damit dein Name in der Rangliste sichtbar wird und du alle Funktionen nutzen kannst.', 'details_requires_confirmation' => 'Die Detailansicht ist erst nach Bestätigung der Teilnahme verfügbar.', 'participate_abo_hint' => 'Es liegt mindestens ein für die Wertung relevantes Abo vor (aktives Berater-Abo oder Kundenabo im Qualifikationszeitraum). Mit dem Teilnehmen werden die Punkte dafür direkt nach den aktuellen Regeln übernommen.', diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index 967915a..d82edf3 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -86,4 +86,6 @@ return [ 'my_abo' => 'Mein Abo', 'my_subscriptions' => 'Meine Abos', 'team_customers' => 'Team Kunden', + 'payment_monitor' => 'Payment Monitor', + 'payment_monitor_management' => 'Payment Monitor GF', ]; diff --git a/resources/lang/de/pagination.php b/resources/lang/de/pagination.php new file mode 100644 index 0000000..341dd79 --- /dev/null +++ b/resources/lang/de/pagination.php @@ -0,0 +1,6 @@ + '« Zurück', + 'next' => 'Weiter »', +]; diff --git a/resources/lang/de/payment.php b/resources/lang/de/payment.php index 8754af2..83b246d 100644 --- a/resources/lang/de/payment.php +++ b/resources/lang/de/payment.php @@ -161,12 +161,32 @@ return [ 'payment_not_found' => 'Zahlung nicht gefunden', 'payment_not_found_description' => 'Die Zahlung mit der Referenz :reference konnte nicht gefunden werden. Bitte kontaktieren Sie uns, falls Sie bereits bezahlt haben.', 'payment_canceled' => 'Zahlung abgebrochen', - 'payment_canceled_description' => 'Der Zahlungsvorgang wurde abgebrochen. Ihre Bestellung wurde nicht ausgeführt.', - 'payment_error' => 'Zahlungsfehler', - 'payment_error_description' => 'Bei der Zahlungsabwicklung ist ein Fehler aufgetreten. Ihre Bestellung konnte nicht abgeschlossen werden.', + 'payment_canceled_description' => 'Sie haben den Zahlungsvorgang abgebrochen. Ihre Bestellung wurde nicht ausgeführt und es wurde nichts belastet.', + 'payment_canceled_hint' => 'Sie können jederzeit einen neuen Zahlungsversuch starten.', + 'payment_error' => 'Zahlung fehlgeschlagen', + 'payment_error_description' => 'Die Zahlung konnte leider nicht abgeschlossen werden.', + 'payment_error_hint' => 'Bitte prüfen Sie Ihre Zahlungsdaten und versuchen Sie es erneut — oder wählen Sie eine andere Zahlungsart.', + 'payment_error_retry' => 'Erneut versuchen', + 'payment_error_code' => 'Fehlercode', + 'payment_error_what_to_do' => 'Was kann ich tun?', 'payment_unknown_status' => 'Unbekannter Zahlungsstatus', 'payment_unknown_status_description' => 'Der Zahlungsstatus konnte nicht ermittelt werden. Bitte kontaktieren Sie uns für weitere Informationen.', - 'contact_support_if_needed' => 'Bei Fragen wenden Sie sich bitte an unseren Kundenservice.', + 'contact_support_if_needed' => 'Bei weiteren Fragen wenden Sie sich bitte an unseren Kundenservice.', + 'try_again' => 'Erneut versuchen', + 'choose_different_payment' => 'Andere Zahlungsart wählen', + 'nothing_was_charged' => 'Es wurde nichts von Ihrem Konto abgebucht.', + 'payment_error_reasons' => [ + 'card_expired' => 'Ihre Karte ist abgelaufen. Bitte verwenden Sie eine gültige Karte oder wählen Sie eine andere Zahlungsart.', + 'card_blocked' => 'Ihre Karte ist gesperrt. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.', + 'card_invalid' => 'Die Kartendaten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.', + 'card_declined' => 'Ihre Bank hat die Zahlung abgelehnt. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.', + 'insufficient_funds' => 'Das Kartenlimit wurde überschritten. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.', + 'cvv_invalid' => 'Die Prüfziffer (CVV) ist nicht korrekt. Bitte überprüfen Sie die 3-stellige Zahl auf der Rückseite Ihrer Karte.', + '3ds_failed' => 'Die 3D-Secure-Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsart.', + 'timeout' => 'Die Verbindung zur Bank ist unterbrochen (Timeout). Bitte versuchen Sie es in wenigen Minuten erneut.', + 'fraud' => 'Die Zahlung wurde aus Sicherheitsgründen abgelehnt. Bitte wenden Sie sich an Ihre Bank.', + 'general' => 'Bitte überprüfen Sie Ihre Zahlungsdaten und versuchen Sie es erneut. Falls das Problem weiterhin besteht, wählen Sie eine andere Zahlungsart.', + ], // DHL Packstation/Paketbox 'packstation_delivery' => 'Lieferung an Packstation/Paketbox', diff --git a/resources/lang/en/incentive.php b/resources/lang/en/incentive.php index 92bf945..2121348 100644 --- a/resources/lang/en/incentive.php +++ b/resources/lang/en/incentive.php @@ -63,8 +63,12 @@ return [ 'no_participants' => 'No participants yet.', 'no_participants_with_points' => 'No participants with points yet.', 'anonymous_consultant' => 'Anonymous consultant', + 'ranking_all_active' => 'All Active', + 'vip_view_notice' => 'VIP view: Real names of all participants are shown.', + 'vip_terms_accepted' => 'Terms accepted', + 'vip_terms_pending' => 'Terms not yet accepted', 'ranking_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.', - 'ranking_extended_hint' => 'The list shows ranks 1–30. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.', + 'ranking_extended_hint' => 'The list shows all consultants with more than 0 points. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.', 'calculation_details' => 'Calculation Details', 'close' => 'Close', @@ -136,6 +140,14 @@ return [ 'you_participate' => 'You are participating!', 'your_rank' => 'Your current rank', 'participate_intro' => 'Ready for the challenge? Register once to be listed in the official ranking.', + 'dash_notice_unregistered_title' => 'Not yet registered', + 'dash_notice_unregistered_body' => 'You are not yet officially participating. Without confirmation, your points won\'t count and you won\'t appear in the ranking.', + 'dash_notice_unconfirmed_title' => 'Participation not yet confirmed', + 'dash_notice_unconfirmed_body' => 'Your points are already tracked – but without accepting the terms you will appear anonymously in the ranking and cannot win.', + 'dash_notice_btn' => 'Confirm participation now', + 'dash_modal_title' => 'Confirm participation', + 'dash_modal_intro' => 'Please read the information and terms carefully, then confirm your participation.', + 'dash_modal_cancel' => 'Close', 'pending_confirmation_banner' => 'Your points are already counted for the qualification period. Please confirm participation so your name appears in the ranking and you can use all features.', 'details_requires_confirmation' => 'The detail view is available only after you confirm participation.', 'participate_abo_hint' => 'You already have at least one subscription that counts (active consultant subscription or a customer subscription started in the qualification period). When you join, points for it are applied immediately according to the current rules.', diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index a022f4d..ad14da3 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -86,4 +86,6 @@ return [ 'my_abo' => 'My Abo', 'my_subscriptions' => 'My Subscriptions', 'team_customers' => 'Team Customers', + 'payment_monitor' => 'Payment Monitor', + 'payment_monitor_management' => 'Payment Monitor (Mgmt)', ]; diff --git a/resources/lang/en/pagination.php b/resources/lang/en/pagination.php new file mode 100644 index 0000000..4172d54 --- /dev/null +++ b/resources/lang/en/pagination.php @@ -0,0 +1,6 @@ + '« Previous', + 'next' => 'Next »', +]; diff --git a/resources/lang/en/payment.php b/resources/lang/en/payment.php index 118fa91..b445fa4 100644 --- a/resources/lang/en/payment.php +++ b/resources/lang/en/payment.php @@ -154,12 +154,32 @@ return [ 'payment_not_found' => 'Payment not found', 'payment_not_found_description' => 'The payment with reference :reference could not be found. Please contact us if you have already paid.', 'payment_canceled' => 'Payment canceled', - 'payment_canceled_description' => 'The payment process was canceled. Your order was not processed.', - 'payment_error' => 'Payment error', - 'payment_error_description' => 'An error occurred during payment processing. Your order could not be completed.', + 'payment_canceled_description' => 'You have canceled the payment process. Your order was not processed and nothing has been charged.', + 'payment_canceled_hint' => 'You can start a new payment attempt at any time.', + 'payment_error' => 'Payment failed', + 'payment_error_description' => 'Unfortunately, the payment could not be completed.', + 'payment_error_hint' => 'Please check your payment details and try again — or choose a different payment method.', + 'payment_error_retry' => 'Try again', + 'payment_error_code' => 'Error code', + 'payment_error_what_to_do' => 'What can I do?', 'payment_unknown_status' => 'Unknown payment status', 'payment_unknown_status_description' => 'The payment status could not be determined. Please contact us for more information.', - 'contact_support_if_needed' => 'If you have any questions, please contact our customer service.', + 'contact_support_if_needed' => 'If you have further questions, please contact our customer service.', + 'try_again' => 'Try again', + 'choose_different_payment' => 'Choose a different payment method', + 'nothing_was_charged' => 'Nothing has been charged to your account.', + 'payment_error_reasons' => [ + 'card_expired' => 'Your card has expired. Please use a valid card or choose a different payment method.', + 'card_blocked' => 'Your card is blocked. Please contact your bank or choose a different payment method.', + 'card_invalid' => 'The card details are invalid. Please check your input.', + 'card_declined' => 'Your bank declined the payment. Please contact your bank or choose a different payment method.', + 'insufficient_funds' => 'The card limit has been exceeded. Please contact your bank or choose a different payment method.', + 'cvv_invalid' => 'The security code (CVV) is incorrect. Please check the 3-digit code on the back of your card.', + '3ds_failed' => '3D Secure authentication failed. Please try again or choose a different payment method.', + 'timeout' => 'The connection to the bank was interrupted (timeout). Please try again in a few minutes.', + 'fraud' => 'The payment was declined for security reasons. Please contact your bank.', + 'general' => 'Please check your payment details and try again. If the problem persists, please choose a different payment method.', + ], // DHL Packstation/Parcel Box 'packstation_delivery' => 'Delivery to Packstation/Parcel Box', diff --git a/resources/lang/es/incentive.php b/resources/lang/es/incentive.php index 5f16f51..d9d71f2 100644 --- a/resources/lang/es/incentive.php +++ b/resources/lang/es/incentive.php @@ -63,8 +63,12 @@ return [ 'no_participants' => 'Aun no hay participantes.', 'no_participants_with_points' => 'Aun no hay participantes con puntos.', 'anonymous_consultant' => 'Consultor anonimo', + 'ranking_all_active' => 'Todos los activos', + 'vip_view_notice' => 'Vista VIP: Se muestran los nombres reales de todos los participantes.', + 'vip_terms_accepted' => 'Condiciones aceptadas', + 'vip_terms_pending' => 'Condiciones aun no aceptadas', 'ranking_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.', - 'ranking_extended_hint' => 'La lista muestra los puestos 1–30. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.', + 'ranking_extended_hint' => 'La lista muestra todos los consultores con mas de 0 puntos. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.', 'calculation_details' => 'Detalles del calculo', 'close' => 'Cerrar', @@ -136,6 +140,14 @@ return [ 'you_participate' => 'Estas participando!', 'your_rank' => 'Tu puesto actual', 'participate_intro' => 'Listo para el desafio? Registrate una vez para aparecer en el ranking oficial.', + 'dash_notice_unregistered_title' => 'Aun no registrado', + 'dash_notice_unregistered_body' => 'Aun no participas oficialmente. Sin confirmacion, tus puntos no contaran y no aparecereas en el ranking.', + 'dash_notice_unconfirmed_title' => 'Participacion aun no confirmada', + 'dash_notice_unconfirmed_body' => 'Tus puntos ya se estan registrando, pero sin aceptar las condiciones aparecereas de forma anonima en el ranking y no podras ganar.', + 'dash_notice_btn' => 'Confirmar participacion ahora', + 'dash_modal_title' => 'Confirmar participacion', + 'dash_modal_intro' => 'Por favor lee la informacion y las condiciones con atencion y confirma tu participacion.', + 'dash_modal_cancel' => 'Cerrar', 'pending_confirmation_banner' => 'Tus puntos ya cuentan en el periodo de calificacion. Confirma la participacion para que tu nombre sea visible en el ranking y puedas usar todas las funciones.', 'details_requires_confirmation' => 'La vista detallada solo esta disponible despues de confirmar la participacion.', 'participate_abo_hint' => 'Ya tienes al menos una suscripcion relevante (suscripcion de consultor activa o suscripcion de cliente en el periodo de calificacion). Al participar, los puntos se aplican de inmediato segun las reglas vigentes.', diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index 65b1e7e..1e9b361 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -86,4 +86,6 @@ return [ 'my_abo' => 'Mi Suscripción', 'my_subscriptions' => 'Mis Suscripciones', 'team_customers' => 'Clientes del Equipo', + 'payment_monitor' => 'Monitor de Pagos', + 'payment_monitor_management' => 'Monitor de Pagos (Dir.)', ]; diff --git a/resources/lang/es/pagination.php b/resources/lang/es/pagination.php new file mode 100644 index 0000000..1bed3bd --- /dev/null +++ b/resources/lang/es/pagination.php @@ -0,0 +1,6 @@ + '« Anterior', + 'next' => 'Siguiente »', +]; diff --git a/resources/lang/es/payment.php b/resources/lang/es/payment.php index fbf6d1d..8e3ed22 100644 --- a/resources/lang/es/payment.php +++ b/resources/lang/es/payment.php @@ -155,12 +155,32 @@ return [ 'payment_not_found' => 'Pago no encontrado', 'payment_not_found_description' => 'No se pudo encontrar el pago con la referencia :reference. Por favor contáctenos si ya ha realizado el pago.', 'payment_canceled' => 'Pago cancelado', - 'payment_canceled_description' => 'El proceso de pago fue cancelado. Su pedido no fue procesado.', - 'payment_error' => 'Error de pago', - 'payment_error_description' => 'Se produjo un error durante el procesamiento del pago. Su pedido no pudo completarse.', + 'payment_canceled_description' => 'Ha cancelado el proceso de pago. Su pedido no fue procesado y no se realizó ningún cargo.', + 'payment_canceled_hint' => 'Puede iniciar un nuevo intento de pago en cualquier momento.', + 'payment_error' => 'Pago fallido', + 'payment_error_description' => 'Lamentablemente, el pago no pudo completarse.', + 'payment_error_hint' => 'Por favor, revise sus datos de pago e inténtelo de nuevo — o elija otro método de pago.', + 'payment_error_retry' => 'Intentar de nuevo', + 'payment_error_code' => 'Código de error', + 'payment_error_what_to_do' => '¿Qué puedo hacer?', 'payment_unknown_status' => 'Estado de pago desconocido', 'payment_unknown_status_description' => 'No se pudo determinar el estado del pago. Por favor contáctenos para más información.', - 'contact_support_if_needed' => 'Si tiene alguna pregunta, por favor contacte a nuestro servicio de atención al cliente.', + 'contact_support_if_needed' => 'Si tiene más preguntas, por favor contacte a nuestro servicio de atención al cliente.', + 'try_again' => 'Intentar de nuevo', + 'choose_different_payment' => 'Elegir otro método de pago', + 'nothing_was_charged' => 'No se ha realizado ningún cargo en su cuenta.', + 'payment_error_reasons' => [ + 'card_expired' => 'Su tarjeta ha caducado. Por favor use una tarjeta válida o elija otro método de pago.', + 'card_blocked' => 'Su tarjeta está bloqueada. Por favor contacte a su banco o elija otro método de pago.', + 'card_invalid' => 'Los datos de la tarjeta son inválidos. Por favor verifique su entrada.', + 'card_declined' => 'Su banco rechazó el pago. Por favor contacte a su banco o elija otro método de pago.', + 'insufficient_funds' => 'El límite de la tarjeta fue superado. Por favor contacte a su banco o elija otro método de pago.', + 'cvv_invalid' => 'El código de seguridad (CVV) es incorrecto. Por favor verifique los 3 dígitos en el reverso de su tarjeta.', + '3ds_failed' => 'La autenticación 3D Secure falló. Por favor inténtelo de nuevo o elija otro método de pago.', + 'timeout' => 'La conexión con el banco se interrumpió (tiempo de espera). Por favor inténtelo de nuevo en unos minutos.', + 'fraud' => 'El pago fue rechazado por razones de seguridad. Por favor contacte a su banco.', + 'general' => 'Por favor verifique sus datos de pago e inténtelo de nuevo. Si el problema persiste, elija otro método de pago.', + ], // DHL Packstation/Paketbox 'packstation_delivery' => 'Entrega a Packstation/Paketbox', diff --git a/resources/views/admin/payment-dashboard/_partials/activity-timeline.blade.php b/resources/views/admin/payment-dashboard/_partials/activity-timeline.blade.php new file mode 100644 index 0000000..1a7c3c0 --- /dev/null +++ b/resources/views/admin/payment-dashboard/_partials/activity-timeline.blade.php @@ -0,0 +1,45 @@ +
    + @forelse($incident->activities as $activity) +
    +
    + +
    +
    +
    +
    + {{ $activity->type_label }} + {{ $activity->title }} +
    + + {{ $activity->created_at->format('d.m.Y H:i') }} + — {{ $activity->author }} + +
    + @if($activity->content) +
    {{ $activity->content }}
    + @endif +
    +
    + @empty +

    Noch keine Aktivitäten.

    + @endforelse +
    + + diff --git a/resources/views/admin/payment-dashboard/_partials/create-incident-modal.blade.php b/resources/views/admin/payment-dashboard/_partials/create-incident-modal.blade.php new file mode 100644 index 0000000..38317fc --- /dev/null +++ b/resources/views/admin/payment-dashboard/_partials/create-incident-modal.blade.php @@ -0,0 +1,100 @@ + diff --git a/resources/views/admin/payment-dashboard/_partials/incident-table.blade.php b/resources/views/admin/payment-dashboard/_partials/incident-table.blade.php new file mode 100644 index 0000000..41da37a --- /dev/null +++ b/resources/views/admin/payment-dashboard/_partials/incident-table.blade.php @@ -0,0 +1,71 @@ +
    + + + + + + + + + + + @if(isset($showActions) && $showActions) + + @endif + + + + @forelse($incidents as $incident) + + + + + + + + + @if(isset($showActions) && $showActions) + + @endif + + @empty + + + + @endforelse + +
    SchwereTitelAnbieterTypStatusErkanntDauer
    + + {{ $incident->severity_label }} + + + + {{ $incident->title }} + + @if($incident->ticket_number) + #{{ $incident->ticket_number }} + @endif + {{ $incident->provider_label }} + + {{ $incident->type_label }} + + + {{ $incident->status_label }} + + {{ $incident->detected_at->format('d.m.Y H:i') }}{{ $incident->duration }} +
    +
    + @csrf + @method('PATCH') + +
    + + + +
    +
    Keine Incidents vorhanden.
    +
    diff --git a/resources/views/admin/payment-dashboard/_partials/stats-cards.blade.php b/resources/views/admin/payment-dashboard/_partials/stats-cards.blade.php new file mode 100644 index 0000000..e86c6fe --- /dev/null +++ b/resources/views/admin/payment-dashboard/_partials/stats-cards.blade.php @@ -0,0 +1,64 @@ +
    +
    +
    +
    +
    Offene Incidents
    +
    + {{ $stats['open_incidents'] }} +
    +
    +
    +
    +
    +
    +
    +
    In Bearbeitung
    +
    + {{ $stats['in_progress'] }} +
    +
    +
    +
    +
    +
    +
    +
    PAYONE (30 Tage)
    +
    + {{ $stats['payone_incidents_30d'] }} +
    +
    +
    +
    +
    +
    +
    +
    Gelöst (Monat)
    +
    + {{ $stats['resolved_this_month'] }} +
    +
    +
    +
    +
    +
    +
    +
    Erfolgsrate Zahlung
    +
    + {{ $transactionStats['success_rate'] }}% +
    +
    letzte {{ $transactionStats['days'] }} Tage
    +
    +
    +
    +
    +
    +
    +
    Fehlgeschlagen
    +
    + {{ $transactionStats['failed'] }} +
    +
    letzte {{ $transactionStats['days'] }} Tage
    +
    +
    +
    +
    diff --git a/resources/views/admin/payment-dashboard/abandoned.blade.php b/resources/views/admin/payment-dashboard/abandoned.blade.php new file mode 100644 index 0000000..80558e8 --- /dev/null +++ b/resources/views/admin/payment-dashboard/abandoned.blade.php @@ -0,0 +1,457 @@ +@extends('layouts.layout-2') + +@section('content') + +
    +
    + + Zurück + + Abbruch-Analyse + Nicht gestartete, abgebrochene und technisch fehlerhafte Zahlungen +
    + {{-- Zeitraum-Filter --}} +
    + + +
    +
    + + {{-- Stat-Karten --}} +
    +
    +
    +
    +
    Zahlung nie gestartet
    +
    {{ $abandonedStats['no_payment'] }}
    + Orders mit txaction=prev ohne Payment +
    +
    +
    +
    +
    +
    +
    Abgebrochen / Fehler
    +
    {{ $abandonedStats['cancelled'] }}
    + cancel + error Payments +
    +
    +
    +
    +
    +
    +
    Kein PAYONE-Callback
    +
    {{ $abandonedStats['no_callback'] }}
    + Payments ohne Transaktion (>2h) +
    +
    +
    +
    + + {{-- Tabs --}} + + +
    + + {{-- Tab 1: Orders ohne Payment --}} +
    +

    + Bestellungen, bei denen der Benutzer den Checkout-Prozess zwar abgeschlossen hat (txaction=prev), + aber die Zahlung nie initiiert wurde. Mindestens 30 Minuten alt. +

    + @if ($ordersWithoutPayment->isEmpty()) +
    Keine offenen Bestellungen ohne Zahlung im gewählten Zeitraum.
    + @else +
    + + + + + + + + + + + + + @foreach ($ordersWithoutPayment as $order) + @php + $isConsultant = $order->auth_user_id && $order->auth_user; + if ($isConsultant) { + $name = trim( + ($order->auth_user->firstname ?? '') . + ' ' . + ($order->auth_user->lastname ?? ''), + ); + $email = $order->auth_user->email ?? '–'; + } else { + $name = trim( + ($order->shopping_user->billing_firstname ?? '') . + ' ' . + ($order->shopping_user->billing_lastname ?? ''), + ); + $email = $order->shopping_user->billing_email ?? '–'; + } + @endphp + + + + + + + + + @endforeach + +
    Order-IDKunde / BeraterTypBetragErstelltVor
    + + #{{ $order->id }} + + + @if ($isConsultant) + Berater + @else + Kunde + @endif + {{ $name ?: '–' }} +
    {{ $email }} +
    {{ $order->payment_for ?? '–' }} + {{ $order->price_total ? number_format($order->price_total, 2, ',', '.') . ' €' : '–' }} + + {{ $order->created_at ? $order->created_at->format('d.m.Y H:i') : '–' }} + {{ $order->created_at ? $order->created_at->diffForHumans() : '–' }}
    +
    + {{ $ordersWithoutPayment->links() }} + @endif +
    + + {{-- Tab 2: Abgebrochene / Fehler --}} +
    +

    + Zahlungen, bei denen der Nutzer aktiv abgebrochen hat (cancel) oder bei denen PAYONE + einen Fehler zurückgemeldet hat (error). Zeile anklicken für PAYONE-Fehlerdetails. +

    + @if ($cancelledPayments->isEmpty()) +
    Keine abgebrochenen Zahlungen im gewählten Zeitraum.
    + @else +
    + + + + + + + + + + + + + + + + @foreach ($cancelledPayments as $payment) + @php + $order = $payment->shopping_order; + $isConsultant = $order && $order->auth_user_id && $order->auth_user; + if ($order && $isConsultant) { + $name = trim( + ($order->auth_user->firstname ?? '') . + ' ' . + ($order->auth_user->lastname ?? ''), + ); + $email = $order->auth_user->email ?? '–'; + } elseif ($order && $order->shopping_user) { + $name = trim( + ($order->shopping_user->billing_firstname ?? '') . + ' ' . + ($order->shopping_user->billing_lastname ?? ''), + ); + $email = $order->shopping_user->billing_email ?? '–'; + } else { + $name = '–'; + $email = '–'; + } + $hasTransactions = $payment->payment_transactions->isNotEmpty(); + $collapseId = 'cancelled-tx-' . $payment->id; + @endphp + {{-- Hauptzeile --}} + + + + + + + + + + + + + {{-- Aufklappbare Fehlerdetails --}} + @if($hasTransactions) + + + + @endif + @endforeach + +
    ReferenzOrder-IDKunde / BeraterBetragStatusZahlungsartZeitpunktVor
    + @if($hasTransactions) + + @endif + {{ $payment->reference }} + @if ($order) + #{{ $order->id }} + @else + + @endif + + @if ($order) + @if ($isConsultant) + Berater + @else + Kunde + @endif + {{ $name ?: '–' }} +
    {{ $email }} + @else + + @endif +
    + {{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '–' }} + + @if ($payment->status === 'cancel') + Abgebrochen + @elseif($payment->status === 'error') + Fehler + @else + {{ $payment->status }} + @endif + {{ $payment->payment_type ?? '–' }} + {{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '–' }} + {{ $payment->created_at ? $payment->created_at->diffForHumans() : '–' }}
    +
    + + PAYONE-Transaktionen ({{ $payment->payment_transactions->count() }}) + + @foreach($payment->payment_transactions as $tx) +
    +
    +
    + TX-ID: + {{ $tx->txid ?? '–' }}
    + Action: + {{ $tx->txaction ?? '–' }}
    + Request: + {{ $tx->request ?? '–' }}
    + Status: + @if($tx->status === 'approved') + approved + @elseif($tx->status === 'error') + error + @else + {{ $tx->status ?? '–' }} + @endif +
    +
    + @php + $errorcode = $tx->errorcode + ?? ($tx->transmitted_data['errorcode'] ?? null); + $failedcause = $tx->transmitted_data['failedcause'] ?? null; + $errormessage = $tx->errormessage + ?? ($tx->transmitted_data['errormessage'] ?? null); + $customermessage = $tx->customermessage + ?? ($tx->transmitted_data['customermessage'] ?? null); + $description = $tx->error_description; + @endphp + @if($errorcode) + + + Fehlercode {{ $errorcode }} +
    + @if($description) + {{ $description }}
    + @endif + @if($errormessage) + PAYONE-Meldung: + {{ $errormessage }}
    + @endif + @if($failedcause && $failedcause != '-'.$errorcode) + Ursache: + {{ $failedcause }}
    + @endif + @if($customermessage) + Kundennachricht: + {{ $customermessage }} + @endif + @else + + @if($tx->txaction === 'failed') + Fehlercode nicht übermittelt +
    (txaction=failed ohne Fehlercode) + @elseif($tx->status === 'REDIRECT') + Nutzer zu PAYONE weitergeleitet +
    (kein Fehler, Redirect) + @else + Kein Fehlercode in diesem Callback + @endif +
    + @endif +
    +
    + Modus: + @if($tx->mode === 'test') + TEST + @elseif($tx->mode === 'live') + LIVE + @else + + @endif +
    + Zeitpunkt: + {{ $tx->created_at ? $tx->created_at->format('d.m.Y H:i:s') : '–' }} +
    +
    +
    + @endforeach +
    +
    +
    + {{ $cancelledPayments->links() }} + @endif +
    + + {{-- Tab 3: Kein Callback --}} +
    +

    + Zahlungen, die gestartet wurden (PAYONE-Redirect), aber nach mehr als 2 Stunden + weder einen Callback noch eine Nutzer-Rückkehr registriert haben. + Dies kann auf technische Probleme (Timeout, fehlgeschlagene Weiterleitung) hinweisen. +

    + @if ($pendingPayments->isEmpty()) +
    Keine offenen Zahlungen ohne Callback im gewählten Zeitraum.
    + @else +
    + + + + + + + + + + + + + + + @foreach ($pendingPayments as $payment) + @php + $order = $payment->shopping_order; + $isConsultant = $order && $order->auth_user_id && $order->auth_user; + if ($order && $isConsultant) { + $name = trim( + ($order->auth_user->firstname ?? '') . + ' ' . + ($order->auth_user->lastname ?? ''), + ); + $email = $order->auth_user->email ?? '–'; + } elseif ($order && $order->shopping_user) { + $name = trim( + ($order->shopping_user->billing_firstname ?? '') . + ' ' . + ($order->shopping_user->billing_lastname ?? ''), + ); + $email = $order->shopping_user->billing_email ?? '–'; + } else { + $name = '–'; + $email = '–'; + } + @endphp + + + + + + + + + + + @endforeach + +
    ReferenzOrder-IDKunde / BeraterBetragZahlungsartModusGestartetVor
    {{ $payment->reference }} + @if ($order) + #{{ $order->id }} + @else + + @endif + + @if ($order) + @if ($isConsultant) + Berater + @else + Kunde + @endif + {{ $name ?: '–' }} +
    {{ $email }} + @else + + @endif +
    + {{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '–' }} + {{ $payment->payment_type ?? '–' }} + @if (($payment->mode ?? '') === 'test') + TEST + @elseif(($payment->mode ?? '') === 'live') + LIVE + @else + + @endif + + {{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '–' }} + {{ $payment->created_at ? $payment->created_at->diffForHumans() : '–' }}
    +
    + {{ $pendingPayments->links() }} + @endif +
    + +
    + +@endsection diff --git a/resources/views/admin/payment-dashboard/funnel.blade.php b/resources/views/admin/payment-dashboard/funnel.blade.php new file mode 100644 index 0000000..852ae06 --- /dev/null +++ b/resources/views/admin/payment-dashboard/funnel.blade.php @@ -0,0 +1,302 @@ +@extends('layouts.layout-2') + +@section('content') + +
    +
    + + Zurück + + Checkout-Funnel Tracking + Internes Tracking aller Checkout-Schritte +
    +
    + + +
    +
    + + @php $topCount = $funnelSteps[0]['count'] > 0 ? $funnelSteps[0]['count'] : 1; @endphp + +
    + {{-- ── Funnel ──────────────────────────────────────────────────────── --}} +
    +
    +
    Checkout-Funnel
    +
    + @foreach ($funnelSteps as $i => $step) + @php $barWidth = $topCount > 0 ? round($step['count'] / $topCount * 100) : 0; @endphp +
    +
    +
    + {{ $i + 1 }} + {{ $step['label'] }} +
    +
    + {{ number_format($step['count'], 0, ',', '.') }} + @if ($step['conversion'] !== null) + + ↓ {{ $step['conversion'] }}% + + @endif +
    +
    +
    +
    + @if ($barWidth > 8) + {{ $barWidth }}% + @endif +
    +
    +
    + @endforeach + + @php + $totalConversion = + $funnelSteps[0]['count'] > 0 + ? round(($funnelSteps[4]['count'] / $funnelSteps[0]['count']) * 100, 1) + : 0; + @endphp +
    + Gesamt-Konversionsrate: {{ $totalConversion }}% + (Checkout aufgerufen → Zahlung bestätigt) +
    +
    +
    +
    + + {{-- ── Rechte Spalte ────────────────────────────────────────────────── --}} +
    + + {{-- Rückkehr-Status --}} +
    +
    Rückkehr von PAYONE
    +
    + + + @forelse($returnStats as $status => $count) + + + + + @empty + + + + @endforelse + +
    + @if ($status === 'success') + success + @elseif($status === 'cancel') + cancel + @elseif($status === 'error') + error + @else + {{ $status ?? '?' }} + @endif + {{ number_format($count, 0, ',', '.') }}
    Noch keine Daten
    +
    +
    + + {{-- Quell-Kanal --}} +
    +
    Quelle (Checkout-Aufrufe)
    +
    + + + @forelse($sourceBreakdown as $key => $source) + + + + + @empty + + + + @endforelse + +
    + @if ($key === 'kundenshop') + + @elseif($key === 'salescenter') + + @elseif($key === 'beraterzugang') + + @elseif($key === 'testserver') + + @else + + @endif + {{ $source['label'] }} + + {{ number_format($source['count'], 0, ',', '.') }}
    Noch keine Daten
    +
    +
    + +
    +
    + + {{-- ── Ereignisse (gefiltert + paginiert) ─────────────────────────────────── --}} +
    +
    + Ereignisse + {{-- Filter-Leiste --}} +
    + + + + + @if ($filterEvent || $filterStatus || $filterSource) + ✕ Reset + @endif +
    +
    +
    + + + + + + + + + + + + + + + + @forelse($recentEvents as $event) + + + + + + + + + + + + @empty + + + + @endforelse + +
    ZeitpunktEreignisQuelleDomainBeraterOrder-IDZahlungsartBetragStatus
    + {{ $event->created_at->format('d.m. H:i:s') }}
    + {{ $event->created_at->diffForHumans() }} +
    + @php + $badgeClass = match ($event->event) { + 'checkout_visited' => 'badge-secondary', + 'form_submitted' => 'badge-info', + 'payment_initiated' => 'badge-primary', + 'payment_returned' => match ($event->return_status) { + 'success' => 'badge-success', + 'cancel' => 'badge-warning', + default => 'badge-danger', + }, + 'payment_confirmed' => 'badge-success', + default => 'badge-light', + }; + @endphp + {{ $event->event_label }} + @if ($event->metadata && isset($event->metadata['txaction'])) + {{ $event->metadata['txaction'] }} + @endif + + @php $src = $event->source_type; @endphp + @if ($src === 'kundenshop') + Shop + @elseif($src === 'salescenter') + SC + @elseif($src === 'beraterzugang') + BZ + @elseif($src === 'testserver') + TEST + @else + ? + @endif + {{ $event->domain ?? '–' }} + @if ($event->consultant) + {{ $event->consultant->firstname }} {{ $event->consultant->lastname }} + @else + + @endif + + @if ($event->shopping_order_id) + + #{{ $event->shopping_order_id }} + + @else + + @endif + {{ $event->payment_method ?? '–' }} + {{ $event->amount_cents ? number_format($event->amount_cents / 100, 2, ',', '.') . ' €' : '–' }} + + @if ($event->return_status) + @if ($event->return_status === 'success') + {{ $event->return_status }} + @elseif($event->return_status === 'cancel') + {{ $event->return_status }} + @else + {{ $event->return_status }} + @endif + @else + + @endif +
    + Keine Ereignisse gefunden. + @if ($filterEvent || $filterStatus || $filterSource) + Filter + zurücksetzen + @endif +
    +
    + @if ($recentEvents->hasPages()) + + @endif +
    + + {{-- Tracking-Hinweis --}} +
    + + Hinweis: Das Tracking ist ab dem Aktivierungszeitpunkt aktiv. Ältere Checkouts sind nicht + enthalten. + Schritt 5 „PAYONE Callback" wird sowohl bei synchroner Bestätigung (transactionApproved) + als auch bei asynchronem IPN-Callback (txaction=paid und appointed) erfasst. +
    + +@endsection diff --git a/resources/views/admin/payment-dashboard/index.blade.php b/resources/views/admin/payment-dashboard/index.blade.php new file mode 100644 index 0000000..8adf8e3 --- /dev/null +++ b/resources/views/admin/payment-dashboard/index.blade.php @@ -0,0 +1,143 @@ +@extends('layouts.layout-2') + +@section('content') + + @if(session('success')) +
    + {{ session('success') }} + +
    + @endif + +
    +
    +

    + Payment Monitor +

    + Entwickler-Ansicht — {{ now()->format('d.m.Y H:i') }} +
    +
    + +
    +
    + + {{-- Stat-Karten --}} + @include('admin.payment-dashboard._partials.stats-cards') + + {{-- Anbieter-Status --}} +
    + @foreach($providerStats as $key => $provider) + @php $uptime = $uptimeStats[$key] ?? null; @endphp +
    +
    +
    +
    + {{ $provider['label'] }} +
    + @if($uptime && $uptime['last_check']) + @if($uptime['last_check']->is_up) + + Online + + @else + + Offline + + @endif + @endif + @if($provider['open_incidents'] > 0) + {{ $provider['open_incidents'] }} Incident + @endif +
    +
    +
    {{ $provider['total_30d'] }} Incidents (30 Tage)
    + @if($uptime && $uptime['checks_24h'] > 0) +
    + Uptime 24h: {{ $uptime['uptime_24h'] }}% + @if($uptime['failures_24h'] > 0) + — {{ $uptime['failures_24h'] }} Ausfälle + @endif +
    + @elseif($uptime) +
    Noch keine Uptime-Daten
    + @endif +
    +
    +
    + @endforeach +
    + + {{-- Tabs --}} + + +
    + {{-- Tab: Offene Incidents --}} +
    +
    +
    + @include('admin.payment-dashboard._partials.incident-table', [ + 'incidents' => $openIncidents, + 'showActions' => true, + ]) +
    +
    +
    + + {{-- Tab: Alle Incidents --}} +
    +
    +
    + @include('admin.payment-dashboard._partials.incident-table', [ + 'incidents' => $allIncidents, + 'showActions' => true, + ]) +
    +
    +
    + {{ $allIncidents->links() }} +
    +
    +
    + + @include('admin.payment-dashboard._partials.create-incident-modal') + +@endsection diff --git a/resources/views/admin/payment-dashboard/logs.blade.php b/resources/views/admin/payment-dashboard/logs.blade.php new file mode 100644 index 0000000..65a9e7a --- /dev/null +++ b/resources/views/admin/payment-dashboard/logs.blade.php @@ -0,0 +1,86 @@ +@extends('layouts.layout-2') + +@section('content') + +
    +
    + + Zurück + + PAYONE Log-Viewer +
    + @if(count($availableDates) > 1) +
    + +
    + @endif +
    + + @if(count($entries) === 0) +
    + + Keine Log-Einträge für {{ \Carbon\Carbon::parse($selectedDate)->format('d.m.Y') }} gefunden. + @if(count($availableDates) > 0) + Verfügbare Daten: {{ implode(', ', array_map(fn($d) => \Carbon\Carbon::parse($d)->format('d.m.Y'), $availableDates)) }} + @else + Der Log-Kanal payone hat noch keine Einträge geschrieben. + @endif +
    + @else +
    + {{ count($entries) }} Einträge (neueste zuerst) +
    + + {{-- Filter --}} +
    + +
    + +
    +
    +
    + @foreach($entries as $entry) + @php + $levelColor = match($entry['level']) { + 'error' => 'danger', + 'warning' => 'warning', + 'info' => 'info', + 'notice' => 'secondary', + default => 'secondary', + }; + @endphp +
    +
    + {{ strtoupper($entry['level']) }} +
    +
    {{ $entry['timestamp'] }}
    +
    {{ $entry['message'] }}
    +
    +
    +
    + @endforeach +
    +
    +
    + @endif + + + +@endsection diff --git a/resources/views/admin/payment-dashboard/management.blade.php b/resources/views/admin/payment-dashboard/management.blade.php new file mode 100644 index 0000000..fb5cde8 --- /dev/null +++ b/resources/views/admin/payment-dashboard/management.blade.php @@ -0,0 +1,111 @@ +@extends('layouts.layout-2') + +@section('content') + +
    +
    +

    + Payment Monitor +

    + Stand: {{ now()->format('d.m.Y H:i') }} +
    + + Entwickler-Ansicht + +
    + + {{-- Ampel-Karten --}} +
    +
    + @php + $level = $stats['critical_open'] > 0 ? 'danger' : ($stats['open_incidents'] > 0 ? 'warning' : 'success'); + $levelText = $stats['critical_open'] > 0 ? 'Kritische Störung!' : ($stats['open_incidents'] > 0 ? 'Offene Störungen' : 'Alles in Ordnung'); + @endphp +
    +
    + +
    {{ $levelText }}
    +
    {{ $stats['open_incidents'] }}
    +
    Offene Störungen
    +
    +
    +
    +
    +
    +
    + +
    Zahlungsquote
    +
    + {{ $transactionStats['success_rate'] }}% +
    +
    + {{ $transactionStats['failed'] }} fehlgeschlagen (30 Tage) +
    +
    +
    +
    +
    +
    +
    + +
    Betroffener Umsatz
    +
    + {{ number_format($stats['total_affected_revenue'], 0, ',', '.') }} € +
    +
    bei offenen Incidents
    +
    +
    +
    +
    + + {{-- Anbieter-Status --}} +
    +
    Anbieter-Status
    +
    +
    + @foreach($providerStats as $key => $provider) +
    +
    {{ $provider['label'] }}
    + @if($provider['open_incidents'] > 0) + + {{ $provider['open_incidents'] }} Störung(en) + + @else + + OK + + @endif +
    {{ $provider['total_30d'] }} Incidents (30d)
    +
    + @endforeach +
    +
    +
    + + {{-- Aktive Störungen --}} + @if($openIncidents->count() > 0) +
    +
    + Aktive Störungen ({{ $openIncidents->count() }}) +
    +
    + @include('admin.payment-dashboard._partials.incident-table', [ + 'incidents' => $openIncidents, + 'showActions' => false, + ]) +
    +
    + @endif + + {{-- Letzte Vorfälle --}} +
    +
    Letzte Vorfälle
    +
    + @include('admin.payment-dashboard._partials.incident-table', [ + 'incidents' => $recentIncidents, + 'showActions' => false, + ]) +
    +
    + +@endsection diff --git a/resources/views/admin/payment-dashboard/payments.blade.php b/resources/views/admin/payment-dashboard/payments.blade.php new file mode 100644 index 0000000..b8e8c96 --- /dev/null +++ b/resources/views/admin/payment-dashboard/payments.blade.php @@ -0,0 +1,328 @@ +@extends('layouts.layout-2') + +@section('content') + +
    +
    + + Zurück + + Zahlungs-Übersicht + ShoppingPayments mit Transaktionen und Bestellung +
    +
    + + {{-- Stat-Karten --}} +
    +
    +
    +
    +
    Zahlungen gesamt
    +
    {{ $paymentStats['total'] }}
    +
    {{ $paymentStats['days'] }} Tage
    +
    +
    +
    +
    +
    +
    +
    Bezahlt
    +
    {{ $paymentStats['paid'] }}
    +
    +
    +
    +
    +
    +
    +
    Mit Fehler
    +
    + {{ $paymentStats['failed'] }} +
    +
    +
    +
    +
    +
    +
    +
    Ausstehend
    +
    {{ $paymentStats['pending'] }}
    +
    +
    +
    +
    +
    +
    +
    Volumen gesamt
    +
    {{ number_format($paymentStats['total_amount'], 2, ',', '.') }} €
    +
    +
    +
    +
    +
    +
    +
    Fehlvolumen
    +
    + {{ number_format($paymentStats['failed_amount'], 2, ',', '.') }} € +
    +
    +
    +
    +
    + + {{-- Filter --}} +
    +
    +
    + + + + + + +
    +
    +
    + + {{-- Zahlungs-Tabelle --}} +
    +
    +
    + + + + + + + + + + + + + + + + + @forelse($payments as $payment) + @php + $hasFailed = $payment->payment_transactions->where('txaction', 'failed')->count() > 0; + $isPaid = $payment->txaction === 'paid'; + $rowClass = $hasFailed ? 'table-danger' : ($isPaid ? 'table-success' : ''); + @endphp + + + + + + + + + + + + + + {{-- Aufklappbare Transaktions-Sub-Tabelle --}} + @if($payment->payment_transactions->count() > 0) + + + + @endif + @empty + + + + @endforelse + +
    Payment IDBestellungKundeZahlartBetragStatusModusTransaktionenDatum
    + @if($payment->payment_transactions->count() > 0) + + @endif + + {{ $payment->reference }} + + @if($payment->shopping_order) + @php + $isCustomerOrder = in_array($payment->shopping_order->payment_for, [6, 7]); + $orderRoute = $isCustomerOrder + ? route('admin_sales_customers_detail', $payment->shopping_order->id) + : route('admin_sales_users_detail', $payment->shopping_order->id); + @endphp + + #{{ $payment->shopping_order->id }} + + @if($payment->shopping_order->paid) + + @endif +
    + {{ number_format($payment->shopping_order->total ?? 0, 2, ',', '.') }} € +
    + @else + + @endif +
    + @if($payment->shopping_order?->auth_user_id && $payment->shopping_order->auth_user) + {{-- Berater-Bestellung --}} + Berater +
    {{ $payment->shopping_order->auth_user->name }}
    +
    + {{ $payment->shopping_order->auth_user->email }} +
    + @elseif($payment->shopping_order?->shopping_user) + {{-- Kunden-Bestellung --}} + @php $su = $payment->shopping_order->shopping_user; @endphp + Kunde +
    + {{ trim($su->billing_firstname . ' ' . $su->billing_lastname) ?: '—' }} +
    +
    + {{ $su->billing_email }} +
    + @else + + @endif +
    + {{ $payment->getPaymentType() }} + @if($payment->clearingtype) + {{ $payment->clearingtype }} + @endif + + {{ number_format($payment->amount / 100, 2, ',', '.') }} + {{ $payment->currency }} + + @php + $txColor = match($payment->txaction) { + 'paid' => 'success', + 'failed' => 'danger', + 'appointed' => 'info', + 'pending' => 'warning', + default => 'secondary', + }; + @endphp + {{ $payment->txaction ?? '—' }} + @if($hasFailed && $isPaid) + + + + @endif + + @if($payment->mode) + + {{ $payment->mode }} + + @endif + + @if($payment->payment_transactions->count() > 0) + + {{ $payment->payment_transactions->count() }} + + @else + 0 + @endif + + {{ $payment->created_at->format('d.m.Y H:i') }} +
    +
    + + Transaktionen zu Payment #{{ $payment->id }} / Referenz {{ $payment->reference }} + +
    + + + + + + + + + + + + + + + + @foreach($payment->payment_transactions as $tx) + + + + + + + + + + + + @if($tx->transmitted_data) + + + + @endif + @endforeach + +
    TX-IDAktionStatusFehlercodeFehlermeldungKundennachrichtModusDatum
    {{ $tx->txid ?? '—' }} + @php + $txaColor = match($tx->txaction) { + 'paid' => 'success', 'failed' => 'danger', + 'appointed' => 'info', 'pending' => 'warning', + default => 'secondary', + }; + @endphp + {{ $tx->txaction ?? '—' }} + {{ $tx->status ?? '—' }} + @if($tx->errorcode) + {{ $tx->errorcode }} + @else + + @endif + + {{ \Illuminate\Support\Str::limit($tx->errormessage, 60) }} + + {{ \Illuminate\Support\Str::limit($tx->customermessage, 50) }} + + @if($tx->mode) + {{ $tx->mode }} + @endif + {{ $tx->created_at->format('d.m.Y H:i') }} + @if($tx->transmitted_data) + + @endif +
    +
    {{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
    +
    +
    + Keine Zahlungen im gewählten Zeitraum gefunden. +
    +
    +
    +
    + +
    + {{ $payments->links() }} +
    + +@endsection diff --git a/resources/views/admin/payment-dashboard/show.blade.php b/resources/views/admin/payment-dashboard/show.blade.php new file mode 100644 index 0000000..0ef28a6 --- /dev/null +++ b/resources/views/admin/payment-dashboard/show.blade.php @@ -0,0 +1,165 @@ +@extends('layouts.layout-2') + +@section('content') + + @if(session('success')) +
    + {{ session('success') }} + +
    + @endif + +
    + +
    + + {{ $incident->severity_label }} + + + {{ $incident->status_label }} + +
    +
    + +
    + {{-- Linke Spalte: Details + Timeline --}} +
    +
    +
    + +
    {{ $incident->title }}
    +
    +
    +
    +
    + Anbieter + {{ $incident->provider_label }} +
    +
    + Typ + {{ $incident->type_label }} +
    +
    + Erkannt am + {{ $incident->detected_at->format('d.m.Y H:i') }} +
    +
    +
    +
    + Dauer + {{ $incident->duration }} +
    +
    + Betroffene Bestellungen + {{ $incident->affected_orders }} +
    +
    + Betroffener Umsatz + {{ number_format($incident->affected_revenue, 2, ',', '.') }} € +
    +
    + @if($incident->ticket_number) +
    + Ticket-Nummer + {{ $incident->ticket_number }} +
    + @endif + @if($incident->description) +
    + Beschreibung +

    {{ $incident->description }}

    +
    + @endif + @if($incident->notes) +
    + Interne Notizen +

    {{ $incident->notes }}

    +
    + @endif +
    +
    + + {{-- Aktivitäten-Timeline --}} +
    +
    Kommunikationsverlauf ({{ $incident->activities->count() }})
    +
    + @include('admin.payment-dashboard._partials.activity-timeline') +
    +
    +
    + + {{-- Rechte Spalte: Aktionen --}} +
    + {{-- Status ändern --}} +
    +
    Status ändern
    +
    +
    + @csrf + @method('PATCH') +
    + +
    + +
    +
    +
    + + {{-- Aktivität hinzufügen --}} +
    +
    Aktivität hinzufügen
    +
    + @if($errors->has('type') || $errors->has('title')) +
    + @foreach($errors->all() as $error) +
    {{ $error }}
    + @endforeach +
    + @endif +
    + @csrf +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + + @if($incident->resolved_at) +
    + + Gelöst am {{ $incident->resolved_at->format('d.m.Y H:i') }} +
    + @endif +
    +
    + +@endsection diff --git a/resources/views/admin/payment-dashboard/transactions.blade.php b/resources/views/admin/payment-dashboard/transactions.blade.php new file mode 100644 index 0000000..ed0ec43 --- /dev/null +++ b/resources/views/admin/payment-dashboard/transactions.blade.php @@ -0,0 +1,183 @@ +@extends('layouts.layout-2') + +@section('content') + +
    + +
    + + {{-- Stat-Karten --}} +
    +
    +
    +
    +
    Gesamt
    +
    {{ $transactionStats['total'] }}
    +
    letzte {{ $transactionStats['days'] }} Tage
    +
    +
    +
    +
    +
    +
    +
    Erfolgsrate
    +
    + {{ $transactionStats['success_rate'] }}% +
    +
    +
    +
    +
    +
    +
    +
    Bezahlt
    +
    {{ $transactionStats['paid'] }}
    +
    +
    +
    +
    +
    +
    +
    Fehlgeschlagen
    +
    + {{ $transactionStats['failed'] }} +
    +
    +
    +
    +
    + + @if($transactionStats['error_distribution']->count() > 0) +
    +
    Fehlercodes (letzte {{ $transactionStats['days'] }} Tage)
    +
    + @foreach($transactionStats['error_distribution'] as $error) + + Code {{ $error->errorcode }}: {{ $error->count }}× + @if($error->errormessage) — {{ \Illuminate\Support\Str::limit($error->errormessage, 60) }} @endif + + @endforeach +
    +
    + @endif + + {{-- Filter --}} +
    +
    +
    + + + + +
    +
    +
    + + {{-- Transaktions-Tabelle --}} +
    +
    +
    + + + + + + + + + + + + + + + + @forelse($transactions as $tx) + + + + + + + + + + + + @if($tx->transmitted_data) + + + + @endif + @empty + + + + @endforelse + +
    IDDatumAktionTX-IDReferenzModusFehlercodeFehlermeldung
    {{ $tx->id }}{{ $tx->created_at->format('d.m.Y H:i') }} + @php + $actionColor = match($tx->txaction) { + 'paid' => 'success', + 'failed' => 'danger', + 'appointed' => 'info', + 'pending' => 'warning', + default => 'secondary', + }; + @endphp + {{ $tx->txaction ?? '—' }} + {{ $tx->txid ?? '—' }} + @if($tx->shopping_payment) + {{ $tx->shopping_payment->reference }} + @else + — + @endif + + @if($tx->mode) + {{ $tx->mode }} + @endif + + @if($tx->errorcode) + {{ $tx->errorcode }} + @endif + + {{ $tx->errormessage }} + @if($tx->customermessage) + ({{ \Illuminate\Support\Str::limit($tx->customermessage, 40) }}) + @endif + + @if($tx->transmitted_data) + + @endif +
    +
    {{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
    +
    Keine Transaktionen gefunden.
    +
    +
    +
    + +
    + {{ $transactions->links() }} +
    + +@endsection diff --git a/resources/views/admin/revenue/index.blade.php b/resources/views/admin/revenue/index.blade.php index f994bd5..6e7a046 100644 --- a/resources/views/admin/revenue/index.blade.php +++ b/resources/views/admin/revenue/index.blade.php @@ -138,6 +138,148 @@ + +
    + + +
    +
    +
    +
    +
    Umsätze nach Ländern {{ session('revenue_filter_year') }}
    +
    +
    + @if(isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0) + + + + + + + + + + + @foreach($revenue_summary['country_yearly'] as $item) + + + + + + + @endforeach + +
    LandNettoSteuerBrutto
    {{ $item->country_name }}{{ number_format($item->total_net, 2, ',', '.') }} €{{ number_format($item->total_tax, 2, ',', '.') }} €{{ number_format($item->total_gross, 2, ',', '.') }} €
    + @else +

    Keine Umsätze nach Ländern für {{ session('revenue_filter_year') }} gefunden

    + @endif +
    +
    +
    + + +
    +
    +
    +
    Gutschriften nach Ländern {{ session('revenue_filter_year') }}
    +
    +
    + @if(isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0) + + + + + + + + + + + @foreach($credit_summary['country_yearly'] as $item) + + + + + + + @endforeach + +
    LandNettoSteuerBrutto
    {{ $item->country_name }}{{ number_format($item->total_net, 2, ',', '.') }} €{{ number_format($item->total_tax, 2, ',', '.') }} €{{ number_format($item->total_gross, 2, ',', '.') }} €
    + @else +

    Keine Gutschriften nach Ländern für {{ session('revenue_filter_year') }} gefunden

    + @endif +
    +
    +
    +
    + + +
    +
    +
    +
    +
    Umsätze nach Ländern – monatliche Aufschlüsselung
    +
    +
    + @if(isset($revenue_summary['country_monthly']) && $revenue_summary['country_monthly']->count() > 0) + @php $revenueByMonth = $revenue_summary['country_monthly']->groupBy('month'); @endphp + @foreach($revenueByMonth as $month => $countries) +
    + {{ $countries->first()->month_label }} +
    + + + @foreach($countries as $item) + + + + + + + @endforeach + +
    {{ $item->country_name }}{{ number_format($item->total_net, 2, ',', '.') }} €{{ number_format($item->total_tax, 2, ',', '.') }} €{{ number_format($item->total_gross, 2, ',', '.') }} €
    + @endforeach + @else +

    Keine monatlichen Umsätze nach Ländern gefunden

    + @endif +
    +
    +
    + + +
    +
    +
    +
    Gutschriften nach Ländern – monatliche Aufschlüsselung
    +
    +
    + @if(isset($credit_summary['country_monthly']) && $credit_summary['country_monthly']->count() > 0) + @php $creditByMonth = $credit_summary['country_monthly']->groupBy('month'); @endphp + @foreach($creditByMonth as $month => $countries) +
    + {{ $countries->first()->month_label }} +
    + + + @foreach($countries as $item) + + + + + + + @endforeach + +
    {{ $item->country_name }}{{ number_format($item->total_net, 2, ',', '.') }} €{{ number_format($item->total_tax, 2, ',', '.') }} €{{ number_format($item->total_gross, 2, ',', '.') }} €
    + @endforeach + @else +

    Keine monatlichen Gutschriften nach Ländern gefunden

    + @endif +
    +
    +
    +