568 lines
21 KiB
PHP
568 lines
21 KiB
PHP
<?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');
|
||
}
|
||
}
|