mivita/app/Http/Controllers/Admin/PaymentDashboardController.php
2026-04-14 18:07:45 +02:00

568 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaymentIncident\AddIncidentActivityRequest;
use App\Http\Requests\PaymentIncident\StorePaymentIncidentRequest;
use App\Http\Requests\PaymentIncident\UpdateIncidentStatusRequest;
use App\Mail\PaymentIncidentAlert;
use App\Models\CheckoutFunnelEvent;
use App\Models\IncidentActivity;
use App\Models\PaymentIncident;
use App\Models\PaymentTransaction;
use App\Models\ProviderUptimeLog;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Services\MyLog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
class PaymentDashboardController extends Controller
{
public function __construct()
{
$this->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<string, array{last_check: ProviderUptimeLog|null, uptime_24h: float, checks_24h: int, failures_24h: int}>
*/
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');
}
}