14-04-2026

This commit is contained in:
Kevin Adametz 2026-04-14 18:07:45 +02:00
parent f58c709945
commit 0f82fea88a
72 changed files with 7414 additions and 148 deletions

View file

@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use App\Models\IncidentActivity;
use App\Models\PaymentIncident;
use App\Models\ProviderUptimeLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CheckPaymentUptime extends Command
{
protected $signature = 'payment:check-uptime';
protected $description = 'Prüft die Erreichbarkeit von PAYONE und legt bei Ausfall automatisch einen Incident an.';
/**
* @var array<string, string>
*/
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.");
}
}
}

View file

@ -5,6 +5,7 @@ namespace App\Console;
use App\Console\Commands\BusinessStore; use App\Console\Commands\BusinessStore;
use App\Console\Commands\BusinessStoreOptimized; use App\Console\Commands\BusinessStoreOptimized;
use App\Console\Commands\CheckPaymentsAccount; use App\Console\Commands\CheckPaymentsAccount;
use App\Console\Commands\CheckPaymentUptime;
use App\Console\Commands\DhlUpdateTracking; use App\Console\Commands\DhlUpdateTracking;
use App\Console\Commands\UserCleanup; use App\Console\Commands\UserCleanup;
use App\Console\Commands\UserMakeAboOrder; use App\Console\Commands\UserMakeAboOrder;
@ -21,6 +22,7 @@ class Kernel extends ConsoleKernel
protected $commands = [ protected $commands = [
BusinessStore::class, BusinessStore::class,
BusinessStoreOptimized::class, BusinessStoreOptimized::class,
CheckPaymentUptime::class,
CheckPaymentsAccount::class, CheckPaymentsAccount::class,
UserMakeAboOrder::class, UserMakeAboOrder::class,
UserCleanup::class, UserCleanup::class,
@ -34,6 +36,12 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule) 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. // Job 1: Überprüft täglich um 02:00 Uhr die Zahlungskonten.
$schedule->command('payments:check-accounts')->dailyAt('02:00'); $schedule->command('payments:check-accounts')->dailyAt('02:00');
// Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript. // Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript.

View file

@ -0,0 +1,568 @@
<?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');
}
}

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
use App\Services\CheckoutFunnelTracker;
use App\Services\MyLog; use App\Services\MyLog;
use App\Services\Payment; use App\Services\Payment;
use App\Services\ShoppingUserService; use App\Services\ShoppingUserService;
@ -152,6 +153,9 @@ class PayoneController extends Controller
'txaction' => $data['txaction'], 'txaction' => $data['txaction'],
'transmitted_data' => Util::utf8ize($data), 'transmitted_data' => Util::utf8ize($data),
'mode' => $data['mode'], 'mode' => $data['mode'],
'errorcode' => $data['errorcode'] ?? null,
'errormessage' => $data['errormessage'] ?? null,
'customermessage' => $data['customermessage'] ?? null,
]); ]);
// Define txaction priority (higher number = higher priority) // Define txaction priority (higher number = higher priority)
@ -219,6 +223,20 @@ class PayoneController extends Controller
); );
throw $e; 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; $data['send_link'] = $send_link;

View file

@ -26,6 +26,7 @@ namespace App\Http\Controllers\Pay;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
use App\Services\CheckoutFunnelTracker;
use App\Services\MyLog; use App\Services\MyLog;
use App\Services\Payone; use App\Services\Payone;
use Util; use Util;
@ -189,6 +190,15 @@ class PayoneController extends Controller
'mode' => $this->shopping_order->mode, '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; $this->default['mode'] = $this->shopping_order->mode;
return $this->reference; return $this->reference;

View file

@ -2,15 +2,13 @@
namespace App\Http\Controllers; 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\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 Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Request;
class RevenueReportController extends Controller class RevenueReportController extends Controller
{ {
@ -22,42 +20,42 @@ class RevenueReportController extends Controller
public function index() public function index()
{ {
$this->setFilterVars(); $this->setFilterVars();
$data = [ $data = [
'filter_months' => HTMLHelper::getTransMonths(), 'filter_months' => HTMLHelper::getTransMonths(),
'filter_years' => HTMLHelper::getYearRange(2022), 'filter_years' => HTMLHelper::getYearRange(2022),
'revenue_summary' => $this->getRevenueSummary(), 'revenue_summary' => $this->getRevenueSummary(),
'credit_summary' => $this->getCreditSummary() 'credit_summary' => $this->getCreditSummary(),
]; ];
return view('admin.revenue.index', $data); return view('admin.revenue.index', $data);
} }
public function export() public function export()
{ {
$this->setFilterVars(); $this->setFilterVars();
$filter_year = session('revenue_filter_year'); $filter_year = session('revenue_filter_year');
// Get data like in the HTML view // Get data like in the HTML view
$revenue_summary = $this->getRevenueSummary(); $revenue_summary = $this->getRevenueSummary();
$credit_summary = $this->getCreditSummary(); $credit_summary = $this->getCreditSummary();
$filename = "umsatz-gutschrift-bericht-{$filter_year}"; $filename = "umsatz-gutschrift-bericht-{$filter_year}";
$columns = []; $columns = [];
// Umsätze Section Header // Umsätze Section Header
$columns[] = ['Typ' => 'UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => 'UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
// Yearly Revenue Summary // Yearly Revenue Summary
if(isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) { if (isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) {
foreach($revenue_summary['yearly'] as $item) { foreach ($revenue_summary['yearly'] as $item) {
$columns[] = [ $columns[] = [
'Typ' => $item->period_label, 'Typ' => $item->period_label,
'Netto' => number_format($item->total_net, 2, ',', '.'), 'Netto' => number_format($item->total_net, 2, ',', '.'),
'Steuer' => number_format($item->total_tax, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'),
'Brutto' => number_format($item->total_gross, 2, ',', '.') 'Brutto' => number_format($item->total_gross, 2, ',', '.'),
]; ];
} }
} else { } else {
@ -65,22 +63,22 @@ class RevenueReportController extends Controller
'Typ' => "Jahr {$filter_year}", 'Typ' => "Jahr {$filter_year}",
'Netto' => '0,00', 'Netto' => '0,00',
'Steuer' => '0,00', 'Steuer' => '0,00',
'Brutto' => '0,00' 'Brutto' => '0,00',
]; ];
} }
// Empty row // Empty row
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
// Monthly Revenue Breakdown // Monthly Revenue Breakdown
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
if(isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) { if (isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) {
foreach($revenue_summary['monthly'] as $item) { foreach ($revenue_summary['monthly'] as $item) {
$columns[] = [ $columns[] = [
'Typ' => $item->period_label, 'Typ' => $item->period_label,
'Netto' => number_format($item->total_net, 2, ',', '.'), 'Netto' => number_format($item->total_net, 2, ',', '.'),
'Steuer' => number_format($item->total_tax, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'),
'Brutto' => number_format($item->total_gross, 2, ',', '.') 'Brutto' => number_format($item->total_gross, 2, ',', '.'),
]; ];
} }
} else { } else {
@ -88,25 +86,25 @@ class RevenueReportController extends Controller
'Typ' => 'Keine monatlichen Umsätze gefunden', 'Typ' => 'Keine monatlichen Umsätze gefunden',
'Netto' => '', 'Netto' => '',
'Steuer' => '', 'Steuer' => '',
'Brutto' => '' 'Brutto' => '',
]; ];
} }
// Two empty rows for separation // Two empty rows for separation
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
// Gutschriften Section Header // Gutschriften Section Header
$columns[] = ['Typ' => 'GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => 'GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
// Yearly Credit Summary // Yearly Credit Summary
if(isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) { if (isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) {
foreach($credit_summary['yearly'] as $item) { foreach ($credit_summary['yearly'] as $item) {
$columns[] = [ $columns[] = [
'Typ' => $item->period_label, 'Typ' => $item->period_label,
'Netto' => number_format($item->total_net, 2, ',', '.'), 'Netto' => number_format($item->total_net, 2, ',', '.'),
'Steuer' => number_format($item->total_tax, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'),
'Brutto' => number_format($item->total_gross, 2, ',', '.') 'Brutto' => number_format($item->total_gross, 2, ',', '.'),
]; ];
} }
} else { } else {
@ -114,22 +112,22 @@ class RevenueReportController extends Controller
'Typ' => "Jahr {$filter_year}", 'Typ' => "Jahr {$filter_year}",
'Netto' => '0,00', 'Netto' => '0,00',
'Steuer' => '0,00', 'Steuer' => '0,00',
'Brutto' => '0,00' 'Brutto' => '0,00',
]; ];
} }
// Empty row // Empty row
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
// Monthly Credit Breakdown // Monthly Credit Breakdown
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => '']; $columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
if(isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) { if (isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) {
foreach($credit_summary['monthly'] as $item) { foreach ($credit_summary['monthly'] as $item) {
$columns[] = [ $columns[] = [
'Typ' => $item->period_label, 'Typ' => $item->period_label,
'Netto' => number_format($item->total_net, 2, ',', '.'), 'Netto' => number_format($item->total_net, 2, ',', '.'),
'Steuer' => number_format($item->total_tax, 2, ',', '.'), 'Steuer' => number_format($item->total_tax, 2, ',', '.'),
'Brutto' => number_format($item->total_gross, 2, ',', '.') 'Brutto' => number_format($item->total_gross, 2, ',', '.'),
]; ];
} }
} else { } else {
@ -137,24 +135,106 @@ class RevenueReportController extends Controller
'Typ' => 'Keine monatlichen Gutschriften gefunden', 'Typ' => 'Keine monatlichen Gutschriften gefunden',
'Netto' => '', 'Netto' => '',
'Steuer' => '', '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 (€)']; $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() private function setFilterVars()
{ {
if (!session('revenue_filter_month')) { if (! session('revenue_filter_month')) {
session(['revenue_filter_month' => intval(date('m'))]); session(['revenue_filter_month' => intval(date('m'))]);
} }
if (!session('revenue_filter_year')) { if (! session('revenue_filter_year')) {
session(['revenue_filter_year' => intval(date('Y'))]); session(['revenue_filter_year' => intval(date('Y'))]);
} }
if(!session('revenue_filter_type')){ if (! session('revenue_filter_type')) {
session(['revenue_filter_type' => 'year']); session(['revenue_filter_type' => 'year']);
} }
if (Request::get('revenue_filter_month')) { if (Request::get('revenue_filter_month')) {
@ -171,20 +251,24 @@ class RevenueReportController extends Controller
private function getRevenueSummary() private function getRevenueSummary()
{ {
$year = session('revenue_filter_year'); $year = session('revenue_filter_year');
return [ return [
'yearly' => $this->getRevenueByYear($year), '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() private function getCreditSummary()
{ {
$year = session('revenue_filter_year'); $year = session('revenue_filter_year');
return [ return [
'yearly' => $this->getCreditByYear($year), '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') ->orderBy('month')
->get(); ->get();
} }
}
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();
}
}

View file

@ -61,8 +61,7 @@ class IncentiveController extends Controller
->withRankingActivity() ->withRankingActivity()
->with('user', 'user.account') ->with('user', 'user.account')
->orderByIncentiveLeaderboard() ->orderByIncentiveLeaderboard()
->limit(self::USER_RANKING_DISPLAY_LIMIT) ->paginate(100);
->get();
$participateHasTrackableAbos = false; $participateHasTrackableAbos = false;
if (! $participant?->accepted_terms_at) { if (! $participant?->accepted_terms_at) {
@ -74,8 +73,8 @@ class IncentiveController extends Controller
'participant' => $participant, 'participant' => $participant,
'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null, 'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null,
'ranking' => $ranking, 'ranking' => $ranking,
'rankingDisplayLimit' => self::USER_RANKING_DISPLAY_LIMIT,
'participateHasTrackableAbos' => $participateHasTrackableAbos, 'participateHasTrackableAbos' => $participateHasTrackableAbos,
'isVipView' => $user->isVIP(),
]); ]);
} }
@ -160,12 +159,12 @@ class IncentiveController extends Controller
return []; return [];
} }
$files = glob($dir . '/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; $files = glob($dir.'/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
$images = []; $images = [];
foreach ($files as $file) { foreach ($files as $file) {
$basename = basename($file); $basename = basename($file);
$images[] = 'img/incentive/' . $basename; $images[] = 'img/incentive/'.$basename;
} }
sort($images); sort($images);

View file

@ -10,6 +10,7 @@ use App\Models\ShoppingPayment;
use App\Models\ShoppingUser; use App\Models\ShoppingUser;
use App\Repositories\CheckoutRepository; use App\Repositories\CheckoutRepository;
use App\Services\AboHelper; use App\Services\AboHelper;
use App\Services\CheckoutFunnelTracker;
use App\Services\CustomerPriority; use App\Services\CustomerPriority;
use App\Services\Payment; use App\Services\Payment;
use App\Services\Shop; use App\Services\Shop;
@ -98,6 +99,11 @@ class CheckoutController extends Controller
'yard_instance' => $this->instance, '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); return view('web.templates.checkout', $data);
} }
@ -168,6 +174,14 @@ class CheckoutController extends Controller
Util::setUserHistoryValue(['status' => 2, 'shopping_order_id' => $shopping_order->id]); 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 // Zahlungsmethode verarbeiten
if (Request::get('payment_method')) { if (Request::get('payment_method')) {
return $this->processPaymentMethod($data, $shopping_user, $shopping_order); return $this->processPaymentMethod($data, $shopping_user, $shopping_order);
@ -400,6 +414,11 @@ class CheckoutController extends Controller
$ShoppingPayment->status = $status; $ShoppingPayment->status = $status;
$ShoppingPayment->save(); $ShoppingPayment->save();
CheckoutFunnelTracker::returnedFromPayment(
shoppingPaymentId: $ShoppingPayment->id,
returnStatus: $status,
);
if ($status === 'success') { if ($status === 'success') {
return $this->handleSuccessfulTransaction($ShoppingPayment, $reference); return $this->handleSuccessfulTransaction($ShoppingPayment, $reference);
} }
@ -410,7 +429,9 @@ class CheckoutController extends Controller
return $this->showTransactionError( return $this->showTransactionError(
__('payment.payment_canceled'), __('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::setUserHistoryValue(['status' => 23]);
Util::setInstanceStatusByPayment($ShoppingPayment, 5); // link_failed 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( return $this->showTransactionError(
__('payment.payment_error'), __('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 * Zeigt eine Transaktionsfehlerseite an
* *
* @param string $title
* @param string $message
* @return \Illuminate\View\View * @return \Illuminate\View\View
*/ */
private function showTransactionError($title, $message) /**
* @param array<string, mixed> $extra
*/
private function showTransactionError(string $title, string $message, string $type = 'error', array $extra = [])
{ {
$data = [ $data = array_merge([
'user_shop' => Util::getUserShop(), 'user_shop' => Util::getUserShop(),
'is_checkout' => true, 'is_checkout' => true,
'yard_instance' => $this->instance, 'yard_instance' => $this->instance,
'error_title' => $title, 'error_title' => $title,
'error_message' => $message, 'error_message' => $message,
]; 'error_type' => $type,
], $extra);
return view('web.templates.checkout-error', $data); return view('web.templates.checkout-error', $data);
} }
@ -494,6 +537,12 @@ class CheckoutController extends Controller
if ($payt->shopping_payment->reference != $reference) { if ($payt->shopping_payment->reference != $reference) {
abort(404); abort(404);
} }
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payt->shopping_payment_id,
txaction: $payt->txaction ?? $payt->status ?? 'approved',
);
Yard::instance($this->instance)->destroy(); Yard::instance($this->instance)->destroy();
$this->checkoutRepo->sessionDestroy(true); $this->checkoutRepo->sessionDestroy(true);
Util::setInstanceStatus(3, true); // link_pending Util::setInstanceStatus(3, true); // link_pending

View file

@ -608,6 +608,11 @@ class WizardController extends Controller
if ($cartItem->qty > 1) { if ($cartItem->qty > 1) {
Yard::instance('shopping')->update($cartItem->rowId, 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()) { if (\App\Services\UserService::getTaxFree()) {
Yard::setTax($cartItem->rowId, 0); Yard::setTax($cartItem->rowId, 0);
} else { } else {

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\PaymentIncident;
use Illuminate\Foundation\Http\FormRequest;
class AddIncidentActivityRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'type' => ['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.',
];
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\PaymentIncident;
use Illuminate\Foundation\Http\FormRequest;
class StorePaymentIncidentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['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.',
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\PaymentIncident;
use Illuminate\Foundation\Http\FormRequest;
class UpdateIncidentStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['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.',
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\PaymentIncident;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class PaymentIncidentAlert extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public PaymentIncident $incident) {}
public function build(): self
{
$severityLabel = strtoupper($this->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),
]);
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $event checkout_visited|form_submitted|payment_initiated|payment_returned|payment_confirmed
* @property string|null $session_id
* @property string|null $domain
* @property int|null $shopping_user_id
* @property int|null $shopping_order_id
* @property int|null $shopping_payment_id
* @property int|null $consultant_user_id
* @property string|null $payment_method
* @property string|null $return_status
* @property int|null $amount_cents
* @property array|null $metadata
* @property \Illuminate\Support\Carbon $created_at
*/
class CheckoutFunnelEvent extends Model
{
protected $fillable = [
'event',
'session_id',
'domain',
'shopping_user_id',
'shopping_order_id',
'shopping_payment_id',
'consultant_user_id',
'payment_method',
'return_status',
'amount_cents',
'metadata',
];
protected function casts(): array
{
return [
'metadata' => '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<string, string> */
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<string, string> */
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',
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IncidentActivity extends Model
{
protected $fillable = [
'incident_id',
'type',
'title',
'content',
'author',
];
public function incident(): BelongsTo
{
return $this->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',
};
}
}

View file

@ -0,0 +1,156 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PaymentIncident extends Model
{
protected $fillable = [
'title',
'description',
'notes',
'provider',
'type',
'status',
'severity',
'affected_orders',
'affected_revenue',
'detected_at',
'resolved_at',
'ticket_number',
];
/** @var array<string, mixed> */
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',
};
}
}

View file

@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Model;
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ShoppingPayment $shopping_payment * @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 newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction query() * @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 whereTxid($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value)
*
* @property string|null $mode * @property string|null $mode
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value)
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class PaymentTransaction extends Model class PaymentTransaction extends Model
{ {
protected $table = 'payment_transactions'; protected $table = 'payment_transactions';
protected $casts = [ protected $casts = [
'transmitted_data' => 'array' 'transmitted_data' => 'array',
]; ];
protected $fillable = [ protected $fillable = [
'shopping_payment_id', 'shopping_payment_id',
@ -67,9 +70,52 @@ class PaymentTransaction extends Model
'mode', 'mode',
]; ];
public function shopping_payment() 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<int, string>
*/
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;
} }
} }

View file

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProviderUptimeLog extends Model
{
protected $fillable = [
'provider',
'is_up',
'response_time_ms',
'error_message',
'checked_at',
];
protected function casts(): array
{
return [
'is_up' => 'boolean',
'checked_at' => 'datetime',
];
}
}

View file

@ -2,9 +2,10 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -16,6 +17,8 @@ class AppServiceProvider extends ServiceProvider
public function boot() public function boot()
{ {
Schema::defaultStringLength(191); Schema::defaultStringLength(191);
Paginator::defaultView('pagination::bootstrap-4');
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');
if ($this->app->environment('production')) { if ($this->app->environment('production')) {
URL::forceScheme('https'); URL::forceScheme('https');
@ -33,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
// $userShop = $context->userShop ?? \App\Services\Util::getUserShop(); // $userShop = $context->userShop ?? \App\Services\Util::getUserShop();
// $view->with('user_shop', $userShop); // $view->with('user_shop', $userShop);
// } // }
// Temporär: Verwende immer das normale Verhalten // Temporär: Verwende immer das normale Verhalten
$view->with('user_shop', \App\Services\Util::getUserShop()); $view->with('user_shop', \App\Services\Util::getUserShop());
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -0,0 +1,128 @@
<?php
namespace App\Services;
use App\Models\CheckoutFunnelEvent;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Session;
/**
* Tracks checkout funnel events for internal analytics.
*
* All public methods are fire-and-forget: exceptions are caught and logged
* so that a tracking failure never breaks the checkout flow.
*/
class CheckoutFunnelTracker
{
/**
* Step 1 User opens the checkout page.
*
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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(),
]);
}
}
}

View file

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_incidents', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('checkout_funnel_events', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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 20012008) |
| `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 20012008 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 15)
→ 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
<li class="sidenav-item{{ Request::is('admin/payment-dashboard*') ? ' active' : '' }}">
<a href="{{ route('admin.payment-dashboard.index') }}" class="sidenav-link">
<i class="sidenav-icon ion ion-md-alert"></i>
<div>{{ __('navigation.payment_monitor') }}</div>
@php $openIncidentCount = Cache::remember('open_incident_count', 60, fn() =>
\App\Models\PaymentIncident::whereIn('status', ['open','in_progress','waiting_provider'])->count()
); @endphp
@if ($openIncidentCount > 0)
<div class="pl-1 ml-auto">
<div class="badge badge-danger">{{ $openIncidentCount }}</div>
</div>
@endif
</a>
</li>
```
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.

View file

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

View file

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers;
use App\Models\IncidentActivity;
use App\Models\PaymentIncident;
use Illuminate\Http\Request;
class PaymentDashboardController extends Controller
{
// ─── GF-Ansicht (Alois) ───────────────────────────────────────────────────
public function management()
{
$stats = $this->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;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IncidentActivity extends Model
{
protected $fillable = ['incident_id', 'type', 'title', 'content', 'author'];
public function incident(): BelongsTo
{
return $this->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',
};
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PaymentIncident extends Model
{
protected $fillable = [
'title', 'description', 'provider', 'type', 'status',
'severity', 'affected_orders', 'affected_revenue',
'detected_at', 'resolved_at', 'ticket_number',
];
protected $casts = [
'detected_at' => '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',
};
}
}

View file

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_incidents', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,264 @@
@extends('layouts.dashboard')
@section('page-title', 'Entwickler-Dashboard')
@section('content')
{{-- ── Stats ── --}}
<div class="grid-4 mb-6">
<div class="stat-card {{ $stats['open_incidents'] > 0 ? 'danger' : 'ok' }}">
<div class="stat-label">Offen / Wartend</div>
<div class="stat-value">{{ $stats['open_incidents'] }}</div>
<div class="stat-sub">Incidents ohne Lösung</div>
</div>
<div class="stat-card {{ $stats['in_progress'] > 0 ? 'warning' : 'ok' }}">
<div class="stat-label">In Bearbeitung</div>
<div class="stat-value">{{ $stats['in_progress'] }}</div>
<div class="stat-sub">Aktiv bearbeitet</div>
</div>
<div class="stat-card ok">
<div class="stat-label">Gelöst diesen Monat</div>
<div class="stat-value">{{ $stats['resolved_this_month'] }}</div>
<div class="stat-sub">{{ now()->format('F Y') }}</div>
</div>
<div class="stat-card {{ $stats['payone_incidents_30d'] >= 3 ? 'danger' : ($stats['payone_incidents_30d'] >= 1 ? 'warning' : 'ok') }}">
<div class="stat-label">PAYONE (30 Tage)</div>
<div class="stat-value">{{ $stats['payone_incidents_30d'] }}</div>
<div class="stat-sub">Incidents bei PAYONE</div>
</div>
</div>
{{-- ── Anbieter-Status ── --}}
<div class="card mb-6">
<div class="flex items-center justify-between mb-4">
<div class="card-title" style="margin:0">Anbieter-Status</div>
</div>
<div class="provider-grid">
@foreach($providerStats as $key => $provider)
<div class="provider-card {{ $provider['open_incidents'] > 0 ? 'has-issues' : 'ok' }}">
<div class="provider-name">{{ $provider['label'] }}</div>
<div class="provider-incidents">{{ $provider['open_incidents'] }}</div>
<div class="provider-sub">offene Störungen</div>
<div style="margin-top:8px; font-size:11px; color: var(--text-muted);">{{ $provider['total_30d'] }}× in 30 Tagen</div>
@if($provider['last_incident'])
<div style="margin-top:4px; font-size:10px; color: var(--text-muted);">
Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y H:i') }}
</div>
@endif
</div>
@endforeach
</div>
</div>
<div class="grid-2">
{{-- ── Offene Incidents ── --}}
<div>
<div class="flex items-center justify-between mb-4">
<div class="section-title" style="margin:0; border:none; padding:0;">Offene Incidents</div>
<button class="btn btn-primary" onclick="document.getElementById('modal-new').classList.add('open')">
+ Neuer Incident
</button>
</div>
@forelse($openIncidents as $incident)
<div class="card mb-4" style="border-left: 3px solid {{ $incident->severity_color }};">
<div class="flex items-center justify-between mb-4">
<div>
<div style="font-weight:700; margin-bottom:4px;">{{ $incident->title }}</div>
<div class="flex gap-2">
<span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span>
<span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span>
<span style="font-size:11px; color:var(--text-muted); padding: 3px 0;">{{ $incident->provider_label }}</span>
</div>
</div>
<a href="{{ route('payment-dashboard.show', $incident) }}" class="btn btn-ghost" style="font-size:12px;">Detail </a>
</div>
<div style="font-size:12px; color:var(--text-muted); margin-bottom:12px;">
Erkannt: {{ $incident->detected_at->format('d.m.Y H:i') }} · Dauer: {{ $incident->duration }}
@if($incident->ticket_number)
· Ticket: <span class="text-accent">{{ $incident->ticket_number }}</span>
@endif
@if($incident->affected_orders > 0)
· {{ $incident->affected_orders }} Bestellungen betroffen
@endif
</div>
{{-- Letzte Aktivität --}}
@if($incident->activities->count() > 0)
@php $last = $incident->activities->sortByDesc('created_at')->first(); @endphp
<div style="background: var(--surface-2); border: 1px solid var(--border); border-radius:7px; padding:10px 12px; font-size:12px;">
<span style="color:var(--text-muted);">{{ $last->type_icon }} Letzte Aktivität:</span>
<strong>{{ $last->title }}</strong>
<span class="text-muted"> {{ $last->created_at->diffForHumans() }}</span>
</div>
@endif
{{-- Schnell-Status-Update --}}
<form action="{{ route('payment-dashboard.status.update', $incident) }}" method="POST" style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
@csrf @method('PATCH')
<select name="status" style="width:auto; flex:1;">
<option value="open" {{ $incident->status === 'open' ? 'selected' : '' }}>Offen</option>
<option value="in_progress" {{ $incident->status === 'in_progress' ? 'selected' : '' }}>In Bearbeitung</option>
<option value="waiting_provider" {{ $incident->status === 'waiting_provider' ? 'selected' : '' }}>Wartet auf Anbieter</option>
<option value="resolved" {{ $incident->status === 'resolved' ? 'selected' : '' }}>Gelöst</option>
<option value="closed" {{ $incident->status === 'closed' ? 'selected' : '' }}>Geschlossen</option>
</select>
<button type="submit" class="btn btn-ghost" style="font-size:12px;">Aktualisieren</button>
</form>
</div>
@empty
<div class="card" style="text-align:center; padding:32px; border-color: var(--green);">
<div style="font-size:28px; margin-bottom:8px;"></div>
<div style="font-weight:700; color:var(--green);">Keine offenen Incidents</div>
</div>
@endforelse
</div>
{{-- ── Letzte Aktivitäten ── --}}
<div>
<div class="section-title">Kommunikations-Verlauf</div>
<div class="card">
<div class="timeline">
@forelse($recentActivity as $activity)
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-title">
{{ $activity->type_icon }} {{ $activity->title }}
</div>
<div class="timeline-meta">
{{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }}
@if($activity->incident)
· <a href="{{ route('payment-dashboard.show', $activity->incident) }}">{{ $activity->incident->title }}</a>
@endif
</div>
@if($activity->content)
<div class="timeline-content">{{ $activity->content }}</div>
@endif
</div>
@empty
<div class="text-muted">Noch keine Aktivitäten erfasst.</div>
@endforelse
</div>
</div>
</div>
</div>
{{-- ── Alle Incidents Tabelle ── --}}
<div class="card" style="margin-top:8px;">
<div class="flex items-center justify-between mb-4">
<div class="card-title" style="margin:0;">Alle Incidents</div>
<span class="text-muted" style="font-size:12px;">{{ $allIncidents->total() }} gesamt</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>Titel</th>
<th>Anbieter</th>
<th>Typ</th>
<th>Schwere</th>
<th>Status</th>
<th>Erkannt</th>
<th>Dauer</th>
<th>Ticket</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($allIncidents as $incident)
<tr>
<td class="text-muted" style="font-size:11px;">#{{ $incident->id }}</td>
<td><strong>{{ $incident->title }}</strong></td>
<td>{{ $incident->provider_label }}</td>
<td style="font-size:12px; color:var(--text-muted);">{{ $incident->type }}</td>
<td><span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span></td>
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
<td style="font-size:12px;">{{ $incident->detected_at->format('d.m.Y H:i') }}</td>
<td style="font-size:12px;">{{ $incident->duration }}</td>
<td style="font-size:12px; color:var(--accent);">{{ $incident->ticket_number ?? '' }}</td>
<td><a href="{{ route('payment-dashboard.show', $incident) }}" class="btn btn-ghost" style="font-size:11px; padding:4px 10px;">Detail</a></td>
</tr>
@empty
<tr><td colspan="10" class="text-muted">Noch keine Incidents.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:16px;">{{ $allIncidents->links() }}</div>
</div>
{{-- ── Modal: Neuer Incident ── --}}
<div class="modal-overlay" id="modal-new" onclick="if(event.target===this)this.classList.remove('open')">
<div class="modal">
<div class="modal-title">Neuen Incident erfassen</div>
<form action="{{ route('payment-dashboard.store') }}" method="POST">
@csrf
<div class="form-group">
<label>Titel *</label>
<input type="text" name="title" placeholder="z.B. IPN-Fehler PayPal via PAYONE" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Anbieter *</label>
<select name="provider">
<option value="payone">PAYONE</option>
<option value="stripe">Stripe</option>
<option value="paypal">PayPal</option>
<option value="mollie">Mollie</option>
<option value="other">Sonstige</option>
</select>
</div>
<div class="form-group">
<label>Typ *</label>
<select name="type">
<option value="ipn_error">IPN-Fehler</option>
<option value="outage">Komplettausfall</option>
<option value="payment_failure">Zahlungsfehler</option>
<option value="slow_response">Langsame Reaktion</option>
<option value="other">Sonstiges</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Schwere *</label>
<select name="severity">
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
</select>
</div>
<div class="form-group">
<label>Ticket-Nummer</label>
<input type="text" name="ticket_number" placeholder="z.B. PAY-20240112">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Betroffene Bestellungen</label>
<input type="number" name="affected_orders" value="0" min="0">
</div>
<div class="form-group">
<label>Betroffener Umsatz ()</label>
<input type="number" name="affected_revenue" value="0" min="0" step="0.01">
</div>
</div>
<div class="form-group">
<label>Erkannt am *</label>
<input type="datetime-local" name="detected_at" value="{{ now()->format('Y-m-d\TH:i') }}" required>
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea name="description" rows="3" placeholder="Was ist passiert? Fehlermeldung, Kontext..."></textarea>
</div>
<div class="flex gap-2" style="justify-content:flex-end;">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('modal-new').classList.remove('open')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Incident anlegen</button>
</div>
</form>
</div>
</div>
@endsection

View file

@ -0,0 +1,120 @@
@extends('layouts.dashboard')
@section('page-title', 'Zahlungssystem Übersicht')
@section('content')
{{-- ── Stat-Karten ── --}}
<div class="grid-4 mb-6">
<div class="stat-card {{ $stats['open_incidents'] > 0 ? 'danger' : 'ok' }}">
<div class="stat-label">Offene Störungen</div>
<div class="stat-value">{{ $stats['open_incidents'] }}</div>
<div class="stat-sub">Warten auf Lösung</div>
</div>
<div class="stat-card {{ $stats['in_progress'] > 0 ? 'warning' : 'ok' }}">
<div class="stat-label">In Bearbeitung</div>
<div class="stat-value">{{ $stats['in_progress'] }}</div>
<div class="stat-sub">Aktiv bearbeitet</div>
</div>
<div class="stat-card {{ $stats['total_affected_revenue'] > 0 ? 'danger' : 'ok' }}">
<div class="stat-label">Betroffener Umsatz</div>
<div class="stat-value">{{ number_format($stats['total_affected_revenue'], 0, ',', '.') }} </div>
<div class="stat-sub">Offene Incidents</div>
</div>
<div class="stat-card {{ $stats['payone_incidents_30d'] >= 3 ? 'danger' : ($stats['payone_incidents_30d'] >= 1 ? 'warning' : 'ok') }}">
<div class="stat-label">PAYONE Probleme</div>
<div class="stat-value">{{ $stats['payone_incidents_30d'] }}</div>
<div class="stat-sub">Letzte 30 Tage</div>
</div>
</div>
{{-- ── Anbieter-Status ── --}}
<div class="card mb-6">
<div class="card-title">Anbieter-Übersicht</div>
<div class="provider-grid">
@foreach($providerStats as $key => $provider)
<div class="provider-card {{ $provider['open_incidents'] > 0 ? 'has-issues' : 'ok' }}">
<div class="provider-name">{{ $provider['label'] }}</div>
<div class="provider-incidents">{{ $provider['open_incidents'] }}</div>
<div class="provider-sub">offene Störungen</div>
<div class="provider-sub" style="margin-top:6px;">{{ $provider['total_30d'] }}× in 30 Tagen</div>
@if($provider['last_incident'])
<div class="provider-sub" style="margin-top:4px; font-size:10px;">
Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y') }}
</div>
@endif
</div>
@endforeach
</div>
</div>
{{-- ── Offene Störungen ── --}}
@if($openIncidents->count() > 0)
<div class="card mb-6" style="border-color: var(--red);">
<div class="card-title" style="color: var(--red);"> Aktive Störungen</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Störung</th>
<th>Anbieter</th>
<th>Schwere</th>
<th>Status</th>
<th>Seit</th>
<th>Betroffene Bestellungen</th>
</tr>
</thead>
<tbody>
@foreach($openIncidents as $incident)
<tr>
<td><strong>{{ $incident->title }}</strong></td>
<td>{{ $incident->provider_label }}</td>
<td><span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span></td>
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
<td>{{ $incident->detected_at->format('d.m.Y H:i') }}<br><span class="text-muted">{{ $incident->duration }}</span></td>
<td>{{ $incident->affected_orders > 0 ? $incident->affected_orders . ' Bestellungen' : '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@else
<div class="card mb-6" style="border-color: var(--green); text-align:center; padding: 32px;">
<div style="font-size: 32px; margin-bottom: 8px;"></div>
<div style="font-weight: 700; color: var(--green); margin-bottom: 4px;">Keine aktiven Störungen</div>
<div class="text-muted">Alle Zahlungssysteme laufen normal.</div>
</div>
@endif
{{-- ── Letzte Incidents ── --}}
<div class="card">
<div class="card-title">Letzte Vorfälle</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Titel</th>
<th>Anbieter</th>
<th>Datum</th>
<th>Status</th>
<th>Dauer</th>
</tr>
</thead>
<tbody>
@forelse($recentIncidents as $incident)
<tr>
<td>{{ $incident->title }}</td>
<td>{{ $incident->provider_label }}</td>
<td>{{ $incident->detected_at->format('d.m.Y') }}</td>
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
<td>{{ $incident->duration }}</td>
</tr>
@empty
<tr><td colspan="5" class="text-muted">Noch keine Vorfälle erfasst.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View file

@ -0,0 +1,150 @@
@extends('layouts.dashboard')
@section('page-title', 'Incident #' . $incident->id)
@section('content')
<div class="flex items-center gap-3 mb-6" style="flex-wrap:wrap;">
<a href="{{ route('payment-dashboard.developer') }}" class="btn btn-ghost" style="font-size:12px;"> Zurück</a>
<span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span>
<span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span>
<span style="font-size:13px; color:var(--text-muted);">{{ $incident->provider_label }} · {{ $incident->detected_at->format('d.m.Y H:i') }} · {{ $incident->duration }}</span>
</div>
<div class="grid-2">
{{-- ── Incident-Details ── --}}
<div>
<div class="card mb-4">
<div class="card-title">Incident-Details</div>
<div style="font-size:20px; font-weight:800; margin-bottom:12px;">{{ $incident->title }}</div>
@if($incident->description)
<div style="background:var(--surface-2); border:1px solid var(--border); border-radius:7px; padding:14px; font-size:13px; color:var(--text-muted); margin-bottom:16px;">
{{ $incident->description }}
</div>
@endif
<table style="width:100%; font-size:13px;">
<tr>
<td style="padding:6px 0; color:var(--text-muted); width:140px;">Anbieter</td>
<td style="padding:6px 0; font-weight:600;">{{ $incident->provider_label }}</td>
</tr>
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Typ</td>
<td style="padding:6px 0;">{{ $incident->type }}</td>
</tr>
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Erkannt</td>
<td style="padding:6px 0;">{{ $incident->detected_at->format('d.m.Y H:i') }} Uhr</td>
</tr>
@if($incident->resolved_at)
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Gelöst</td>
<td style="padding:6px 0; color:var(--green);">{{ $incident->resolved_at->format('d.m.Y H:i') }} Uhr</td>
</tr>
@endif
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Dauer</td>
<td style="padding:6px 0;">{{ $incident->duration }}</td>
</tr>
@if($incident->ticket_number)
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Ticket-Nr.</td>
<td style="padding:6px 0; color:var(--accent); font-weight:600;">{{ $incident->ticket_number }}</td>
</tr>
@endif
@if($incident->affected_orders > 0)
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Bestellungen</td>
<td style="padding:6px 0; color:var(--red);">{{ $incident->affected_orders }} betroffen</td>
</tr>
@endif
@if($incident->affected_revenue > 0)
<tr>
<td style="padding:6px 0; color:var(--text-muted);">Umsatz</td>
<td style="padding:6px 0; color:var(--red); font-weight:700;">{{ number_format($incident->affected_revenue, 2, ',', '.') }} </td>
</tr>
@endif
</table>
</div>
{{-- Status ändern --}}
<div class="card mb-4">
<div class="card-title">Status aktualisieren</div>
<form action="{{ route('payment-dashboard.status.update', $incident) }}" method="POST" class="flex gap-2">
@csrf @method('PATCH')
<select name="status" style="flex:1;">
<option value="open" {{ $incident->status === 'open' ? 'selected' : '' }}>Offen</option>
<option value="in_progress" {{ $incident->status === 'in_progress' ? 'selected' : '' }}>In Bearbeitung</option>
<option value="waiting_provider" {{ $incident->status === 'waiting_provider' ? 'selected' : '' }}>Wartet auf Anbieter</option>
<option value="resolved" {{ $incident->status === 'resolved' ? 'selected' : '' }}>Gelöst</option>
<option value="closed" {{ $incident->status === 'closed' ? 'selected' : '' }}>Geschlossen</option>
</select>
<button type="submit" class="btn btn-primary">Speichern</button>
</form>
</div>
{{-- Aktivität hinzufügen --}}
<div class="card">
<div class="card-title">Aktivität hinzufügen</div>
<form action="{{ route('payment-dashboard.activity.store', $incident) }}" method="POST">
@csrf
<div class="form-group">
<label>Typ</label>
<select name="type">
<option value="note">📝 Notiz</option>
<option value="email">✉️ E-Mail</option>
<option value="call">📞 Telefonat</option>
<option value="ticket">🎫 Support-Ticket</option>
<option value="provider_response">💬 Anbieter-Antwort</option>
</select>
</div>
<div class="form-group">
<label>Titel *</label>
<input type="text" name="title" placeholder="z.B. Ticket bei PAYONE eingereicht" required>
</div>
<div class="form-group">
<label>Details</label>
<textarea name="content" rows="3" placeholder="Inhalt der Mail, Antwort des Anbieters, Notizen..."></textarea>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Aktivität speichern</button>
</form>
</div>
</div>
{{-- ── Aktivitäts-Timeline ── --}}
<div>
<div class="section-title">Kommunikations-Verlauf ({{ $incident->activities->count() }} Einträge)</div>
<div class="card">
@if($incident->activities->count() > 0)
<div class="timeline">
@foreach($incident->activities->sortByDesc('created_at') as $activity)
<div class="timeline-item">
<div class="timeline-dot" style="background: {{ match($activity->type) {
'provider_response' => 'var(--green)',
'email' => 'var(--accent)',
'call' => 'var(--yellow)',
'ticket' => 'var(--orange)',
'status_change' => 'var(--text-muted)',
default => 'var(--accent)'
} }};"></div>
<div class="timeline-title">
{{ $activity->type_icon }} {{ $activity->title }}
</div>
<div class="timeline-meta">
{{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }} Uhr
· <span style="color: var(--text-muted)">{{ $activity->created_at->diffForHumans() }}</span>
</div>
@if($activity->content)
<div class="timeline-content">{{ $activity->content }}</div>
@endif
</div>
@endforeach
</div>
@else
<div class="text-muted" style="text-align:center; padding:24px;">Noch keine Aktivitäten erfasst.</div>
@endif
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Payment Dashboard') mivita</title>
<style>
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f14;
--surface: #161920;
--surface-2: #1e2230;
--border: #2a2f3f;
--text: #e8eaf0;
--text-muted: #7c839a;
--accent: #4f8ef7;
--accent-soft: #1a2a4a;
--red: #ef4444;
--red-soft: #3a1515;
--orange: #f97316;
--orange-soft: #3a2010;
--yellow: #eab308;
--yellow-soft: #332d00;
--green: #22c55e;
--green-soft: #0f2d1a;
--radius: 10px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
}
body {
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.6;
}
/* ── Layout ── */
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 24px 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-logo {
padding: 0 20px 24px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.sidebar-logo span {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
}
.sidebar-logo strong {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-top: 2px;
}
.sidebar-nav { padding: 0 12px; flex: 1; }
.nav-section {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
padding: 16px 8px 6px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 7px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
margin-bottom: 2px;
}
.nav-link:hover, .nav-link.active {
background: var(--surface-2);
color: var(--text);
}
.nav-link.active { color: var(--accent); }
.nav-icon { font-size: 15px; width: 20px; text-align: center; }
/* ── Main ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar-title { font-size: 18px; font-weight: 700; }
.topbar-meta { font-size: 12px; color: var(--text-muted); }
.content { padding: 32px; overflow-y: auto; flex: 1; }
/* ── Cards & Grid ── */
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 24px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 16px;
}
/* ── Stat Cards ── */
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.stat-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.stat-sub { font-size: 12px; color: var(--text-muted); }
.stat-card.danger { border-color: var(--red); background: var(--red-soft); }
.stat-card.warning { border-color: var(--orange); background: var(--orange-soft); }
.stat-card.ok { border-color: var(--green); background: var(--green-soft); }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
}
.badge-critical { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
.badge-high { background: var(--orange-soft); color: var(--orange); border: 1px solid var(--orange); }
.badge-medium { background: var(--yellow-soft); color: var(--yellow); border: 1px solid var(--yellow); }
.badge-low { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
.badge-open { background: #1a1a2e; color: #818cf8; border: 1px solid #818cf8; }
.badge-in_progress { background: #1a2a3a; color: var(--accent); border: 1px solid var(--accent); }
.badge-waiting_provider { background: var(--orange-soft); color: var(--orange); border: 1px solid var(--orange); }
.badge-resolved { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
.badge-closed { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
/* ── Table ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface-2); }
/* ── Timeline ── */
.timeline { position: relative; padding-left: 28px; }
.timeline::before {
content: '';
position: absolute;
left: 8px; top: 8px; bottom: 8px;
width: 2px;
background: var(--border);
}
.timeline-item {
position: relative;
margin-bottom: 20px;
}
.timeline-dot {
position: absolute;
left: -24px;
top: 4px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--bg);
}
.timeline-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
}
.timeline-meta { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
.timeline-content {
font-size: 13px;
color: var(--text-muted);
background: var(--surface-2);
padding: 10px 14px;
border-radius: 7px;
margin-top: 6px;
border: 1px solid var(--border);
}
/* ── Provider Status ── */
.provider-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.provider-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.provider-name { font-size: 13px; font-weight: 700; margin-bottom: 6px; }
.provider-incidents { font-size: 28px; font-weight: 800; margin-bottom: 2px; }
.provider-sub { font-size: 11px; color: var(--text-muted); }
.provider-card.has-issues { border-color: var(--red); }
.provider-card.has-issues .provider-incidents { color: var(--red); }
.provider-card.ok .provider-incidents { color: var(--green); }
/* ── Forms ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
text-decoration: none;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #3a7ef0; }
.btn-ghost { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
select, input, textarea {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 12px;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
width: 100%;
outline: none;
transition: border-color 0.15s;
}
select:focus, input:focus, textarea:focus { border-color: var(--accent); }
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
.form-group { margin-bottom: 16px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* ── Alert ── */
.alert {
padding: 12px 16px;
border-radius: 7px;
margin-bottom: 20px;
font-size: 13px;
font-weight: 500;
}
.alert-success { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
.alert-danger { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
/* ── Modal ── */
.modal-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
width: 560px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 20px; }
/* ── Misc ── */
.text-muted { color: var(--text-muted); }
.text-red { color: var(--red); }
.text-green { color: var(--green); }
.text-accent { color: var(--accent); }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.section-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
</style>
@stack('styles')
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-logo">
<span>mivita</span>
<strong>Payment Monitor</strong>
</div>
<nav class="sidebar-nav">
<div class="nav-section">Ansichten</div>
<a href="{{ route('payment-dashboard.developer') }}" class="nav-link {{ request()->routeIs('payment-dashboard.developer') ? 'active' : '' }}">
<span class="nav-icon">⚙️</span> Entwickler
</a>
<a href="{{ route('payment-dashboard.management') }}" class="nav-link {{ request()->routeIs('payment-dashboard.management') ? 'active' : '' }}">
<span class="nav-icon">📊</span> Management
</a>
</nav>
</aside>
<div class="main">
<div class="topbar">
<div>
<div class="topbar-title">@yield('page-title', 'Dashboard')</div>
</div>
<div class="topbar-meta">
Stand: {{ now()->format('d.m.Y H:i') }} Uhr
</div>
</div>
<div class="content">
@if(session('success'))
<div class="alert alert-success"> {{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger"> {{ session('error') }}</div>
@endif
@yield('content')
</div>
</div>
</div>
@stack('scripts')
</body>
</html>

View file

@ -0,0 +1,35 @@
<?php
use App\Http\Controllers\PaymentDashboardController;
use Illuminate\Support\Facades\Route;
// ─── Payment Dashboard Routes ─────────────────────────────────────────────────
// In web.php einbinden mit: require base_path('routes/payment-dashboard.php');
// Oder direkt in web.php einfügen.
Route::prefix('payment-dashboard')->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');
});

View file

@ -63,8 +63,12 @@ return [
'no_participants' => 'Noch keine Teilnehmer.', 'no_participants' => 'Noch keine Teilnehmer.',
'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.', 'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.',
'anonymous_consultant' => 'Anonymer Berater', '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_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.',
'ranking_extended_hint' => 'Die Liste zeigt die Plätze 130. 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', 'calculation_details' => 'Berechnungsdetails',
'close' => 'Schliessen', 'close' => 'Schliessen',
@ -136,6 +140,14 @@ return [
'you_participate' => 'Du nimmst teil!', 'you_participate' => 'Du nimmst teil!',
'your_rank' => 'Dein aktueller Rang', '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.', '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.', '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.', '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.', '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.',

View file

@ -86,4 +86,6 @@ return [
'my_abo' => 'Mein Abo', 'my_abo' => 'Mein Abo',
'my_subscriptions' => 'Meine Abos', 'my_subscriptions' => 'Meine Abos',
'team_customers' => 'Team Kunden', 'team_customers' => 'Team Kunden',
'payment_monitor' => 'Payment Monitor',
'payment_monitor_management' => 'Payment Monitor GF',
]; ];

View file

@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; Zurück',
'next' => 'Weiter &raquo;',
];

View file

@ -161,12 +161,32 @@ return [
'payment_not_found' => 'Zahlung nicht gefunden', '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_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' => 'Zahlung abgebrochen',
'payment_canceled_description' => 'Der Zahlungsvorgang wurde abgebrochen. Ihre Bestellung wurde nicht ausgeführt.', 'payment_canceled_description' => 'Sie haben den Zahlungsvorgang abgebrochen. Ihre Bestellung wurde nicht ausgeführt und es wurde nichts belastet.',
'payment_error' => 'Zahlungsfehler', 'payment_canceled_hint' => 'Sie können jederzeit einen neuen Zahlungsversuch starten.',
'payment_error_description' => 'Bei der Zahlungsabwicklung ist ein Fehler aufgetreten. Ihre Bestellung konnte nicht abgeschlossen werden.', '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' => 'Unbekannter Zahlungsstatus',
'payment_unknown_status_description' => 'Der Zahlungsstatus konnte nicht ermittelt werden. Bitte kontaktieren Sie uns für weitere Informationen.', '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 // DHL Packstation/Paketbox
'packstation_delivery' => 'Lieferung an Packstation/Paketbox', 'packstation_delivery' => 'Lieferung an Packstation/Paketbox',

View file

@ -63,8 +63,12 @@ return [
'no_participants' => 'No participants yet.', 'no_participants' => 'No participants yet.',
'no_participants_with_points' => 'No participants with points yet.', 'no_participants_with_points' => 'No participants with points yet.',
'anonymous_consultant' => 'Anonymous consultant', '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_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.',
'ranking_extended_hint' => 'The list shows ranks 130. 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', 'calculation_details' => 'Calculation Details',
'close' => 'Close', 'close' => 'Close',
@ -136,6 +140,14 @@ return [
'you_participate' => 'You are participating!', 'you_participate' => 'You are participating!',
'your_rank' => 'Your current rank', 'your_rank' => 'Your current rank',
'participate_intro' => 'Ready for the challenge? Register once to be listed in the official ranking.', '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.', '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.', '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.', '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.',

View file

@ -86,4 +86,6 @@ return [
'my_abo' => 'My Abo', 'my_abo' => 'My Abo',
'my_subscriptions' => 'My Subscriptions', 'my_subscriptions' => 'My Subscriptions',
'team_customers' => 'Team Customers', 'team_customers' => 'Team Customers',
'payment_monitor' => 'Payment Monitor',
'payment_monitor_management' => 'Payment Monitor (Mgmt)',
]; ];

View file

@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];

View file

@ -154,12 +154,32 @@ return [
'payment_not_found' => 'Payment not found', '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_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' => 'Payment canceled',
'payment_canceled_description' => 'The payment process was canceled. Your order was not processed.', 'payment_canceled_description' => 'You have canceled the payment process. Your order was not processed and nothing has been charged.',
'payment_error' => 'Payment error', 'payment_canceled_hint' => 'You can start a new payment attempt at any time.',
'payment_error_description' => 'An error occurred during payment processing. Your order could not be completed.', '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' => 'Unknown payment status',
'payment_unknown_status_description' => 'The payment status could not be determined. Please contact us for more information.', '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 // DHL Packstation/Parcel Box
'packstation_delivery' => 'Delivery to Packstation/Parcel Box', 'packstation_delivery' => 'Delivery to Packstation/Parcel Box',

View file

@ -63,8 +63,12 @@ return [
'no_participants' => 'Aun no hay participantes.', 'no_participants' => 'Aun no hay participantes.',
'no_participants_with_points' => 'Aun no hay participantes con puntos.', 'no_participants_with_points' => 'Aun no hay participantes con puntos.',
'anonymous_consultant' => 'Consultor anonimo', '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_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.',
'ranking_extended_hint' => 'La lista muestra los puestos 130. 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', 'calculation_details' => 'Detalles del calculo',
'close' => 'Cerrar', 'close' => 'Cerrar',
@ -136,6 +140,14 @@ return [
'you_participate' => 'Estas participando!', 'you_participate' => 'Estas participando!',
'your_rank' => 'Tu puesto actual', 'your_rank' => 'Tu puesto actual',
'participate_intro' => 'Listo para el desafio? Registrate una vez para aparecer en el ranking oficial.', '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.', '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.', '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.', '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.',

View file

@ -86,4 +86,6 @@ return [
'my_abo' => 'Mi Suscripción', 'my_abo' => 'Mi Suscripción',
'my_subscriptions' => 'Mis Suscripciones', 'my_subscriptions' => 'Mis Suscripciones',
'team_customers' => 'Clientes del Equipo', 'team_customers' => 'Clientes del Equipo',
'payment_monitor' => 'Monitor de Pagos',
'payment_monitor_management' => 'Monitor de Pagos (Dir.)',
]; ];

View file

@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; Anterior',
'next' => 'Siguiente &raquo;',
];

View file

@ -155,12 +155,32 @@ return [
'payment_not_found' => 'Pago no encontrado', '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_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' => 'Pago cancelado',
'payment_canceled_description' => 'El proceso de pago fue cancelado. Su pedido no fue procesado.', 'payment_canceled_description' => 'Ha cancelado el proceso de pago. Su pedido no fue procesado y no se realizó ningún cargo.',
'payment_error' => 'Error de pago', 'payment_canceled_hint' => 'Puede iniciar un nuevo intento de pago en cualquier momento.',
'payment_error_description' => 'Se produjo un error durante el procesamiento del pago. Su pedido no pudo completarse.', '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' => '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.', '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 // DHL Packstation/Paketbox
'packstation_delivery' => 'Entrega a Packstation/Paketbox', 'packstation_delivery' => 'Entrega a Packstation/Paketbox',

View file

@ -0,0 +1,45 @@
<div class="timeline">
@forelse($incident->activities as $activity)
<div class="timeline-item {{ $activity->type === 'status_change' ? 'timeline-item-secondary' : '' }}">
<div class="timeline-indicator bg-{{ match($activity->type) {
'status_change' => 'info',
'email' => 'primary',
'call' => 'success',
'ticket' => 'warning',
'provider_response' => 'secondary',
default => 'light border'
} }}">
<i class="ion {{ $activity->type_icon }}"></i>
</div>
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge badge-light">{{ $activity->type_label }}</span>
<strong class="ml-1">{{ $activity->title }}</strong>
</div>
<small class="text-muted text-nowrap ml-2">
{{ $activity->created_at->format('d.m.Y H:i') }}
&mdash; {{ $activity->author }}
</small>
</div>
@if($activity->content)
<div class="mt-1 text-muted">{{ $activity->content }}</div>
@endif
</div>
</div>
@empty
<p class="text-muted">Noch keine Aktivitäten.</p>
@endforelse
</div>
<style>
.timeline { position: relative; padding-left: 2.5rem; }
.timeline::before { content: ''; position: absolute; left: 1rem; top: 0; bottom: 0; width: 2px; background: #e9ecef; }
.timeline-item { position: relative; margin-bottom: 1.25rem; }
.timeline-indicator {
position: absolute; left: -2.5rem; width: 2rem; height: 2rem;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 0.875rem; color: #fff;
}
.timeline-content { background: #f8f9fa; border-radius: 0.375rem; padding: 0.75rem 1rem; }
</style>

View file

@ -0,0 +1,100 @@
<div class="modal fade" id="createIncidentModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Neuen Incident anlegen</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form method="POST" action="{{ route('admin.payment-dashboard.store') }}">
@csrf
<div class="modal-body">
@if($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="form-row">
<div class="form-group col-md-8">
<label>Titel <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" value="{{ old('title') }}" required>
</div>
<div class="form-group col-md-4">
<label>Erkannt am <span class="text-danger">*</span></label>
<input type="datetime-local" name="detected_at" class="form-control"
value="{{ old('detected_at', now()->format('Y-m-d\TH:i')) }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label>Anbieter <span class="text-danger">*</span></label>
<select name="provider" class="custom-select" required>
<option value="payone" {{ old('provider', 'payone') === 'payone' ? 'selected' : '' }}>PAYONE</option>
{{-- <option value="stripe" {{ old('provider') === 'stripe' ? 'selected' : '' }}>Stripe</option> --}}
<option value="paypal" {{ old('provider') === 'paypal' ? 'selected' : '' }}>PayPal</option>
{{-- <option value="mollie" {{ old('provider') === 'mollie' ? 'selected' : '' }}>Mollie</option> --}}
<option value="other" {{ old('provider') === 'other' ? 'selected' : '' }}>Sonstige</option>
</select>
</div>
<div class="form-group col-md-4">
<label>Typ <span class="text-danger">*</span></label>
<select name="type" class="custom-select" required>
<option value="outage" {{ old('type') === 'outage' ? 'selected' : '' }}>Ausfall</option>
<option value="ipn_error" {{ old('type') === 'ipn_error' ? 'selected' : '' }}>IPN-Fehler</option>
<option value="payment_failure" {{ old('type', 'payment_failure') === 'payment_failure' ? 'selected' : '' }}>Zahlungsfehler</option>
<option value="slow_response" {{ old('type') === 'slow_response' ? 'selected' : '' }}>Langsame Antwort</option>
<option value="other" {{ old('type') === 'other' ? 'selected' : '' }}>Sonstiges</option>
</select>
</div>
<div class="form-group col-md-4">
<label>Schwere <span class="text-danger">*</span></label>
<select name="severity" class="custom-select" required>
<option value="low" {{ old('severity') === 'low' ? 'selected' : '' }}>Niedrig</option>
<option value="medium" {{ old('severity', 'medium') === 'medium' ? 'selected' : '' }}>Mittel</option>
<option value="high" {{ old('severity') === 'high' ? 'selected' : '' }}>Hoch</option>
<option value="critical" {{ old('severity') === 'critical' ? 'selected' : '' }}>Kritisch</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label>Betroffene Bestellungen</label>
<input type="number" name="affected_orders" class="form-control" min="0"
value="{{ old('affected_orders', 0) }}">
</div>
<div class="form-group col-md-4">
<label>Betroffener Umsatz ()</label>
<input type="number" name="affected_revenue" class="form-control" min="0" step="0.01"
value="{{ old('affected_revenue', '0.00') }}">
</div>
<div class="form-group col-md-4">
<label>Ticket-Nummer</label>
<input type="text" name="ticket_number" class="form-control"
value="{{ old('ticket_number') }}" placeholder="z.B. PAYONE-12345">
</div>
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea name="description" class="form-control" rows="3"
placeholder="Was ist passiert?">{{ old('description') }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-danger">
<i class="ion ion-md-alert"></i> Incident anlegen
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,71 @@
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th>Schwere</th>
<th>Titel</th>
<th>Anbieter</th>
<th>Typ</th>
<th>Status</th>
<th>Erkannt</th>
<th>Dauer</th>
@if(isset($showActions) && $showActions)
<th></th>
@endif
</tr>
</thead>
<tbody>
@forelse($incidents as $incident)
<tr>
<td>
<span class="badge badge-{{ $incident->severity_color }}">
{{ $incident->severity_label }}
</span>
</td>
<td>
<a href="{{ route('admin.payment-dashboard.show', $incident) }}">
{{ $incident->title }}
</a>
@if($incident->ticket_number)
<span class="text-muted small ml-1">#{{ $incident->ticket_number }}</span>
@endif
</td>
<td><span class="badge badge-secondary">{{ $incident->provider_label }}</span></td>
<td>
<i class="ion {{ $incident->type_icon }}"></i>
{{ $incident->type_label }}
</td>
<td>
<span class="badge badge-{{ $incident->status_color }}">
{{ $incident->status_label }}
</span>
</td>
<td class="text-nowrap">{{ $incident->detected_at->format('d.m.Y H:i') }}</td>
<td class="text-nowrap">{{ $incident->duration }}</td>
@if(isset($showActions) && $showActions)
<td>
<div class="d-flex gap-1">
<form method="POST" action="{{ route('admin.payment-dashboard.status.update', $incident) }}" class="d-inline">
@csrf
@method('PATCH')
<select name="status" class="custom-select custom-select-sm" onchange="this.form.submit()" style="width:auto">
@foreach(['open' => 'Offen', 'in_progress' => 'In Bearb.', 'waiting_provider' => 'Wartet', 'resolved' => 'Gelöst', 'closed' => 'Geschl.'] as $value => $label)
<option value="{{ $value }}" {{ $incident->status === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</form>
<a href="{{ route('admin.payment-dashboard.show', $incident) }}" class="btn btn-sm btn-outline-secondary">
<i class="ion ion-md-open"></i>
</a>
</div>
</td>
@endif
</tr>
@empty
<tr>
<td colspan="8" class="text-center text-muted py-3">Keine Incidents vorhanden.</td>
</tr>
@endforelse
</tbody>
</table>
</div>

View file

@ -0,0 +1,64 @@
<div class="row mb-4">
<div class="col-sm-6 col-xl-2">
<div class="card {{ $stats['critical_open'] > 0 ? 'border-danger' : '' }}">
<div class="card-body text-center">
<div class="text-muted small">Offene Incidents</div>
<div class="display-4 font-weight-bold {{ $stats['open_incidents'] > 0 ? 'text-danger' : 'text-success' }}">
{{ $stats['open_incidents'] }}
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">In Bearbeitung</div>
<div class="display-4 font-weight-bold {{ $stats['in_progress'] > 0 ? 'text-warning' : 'text-muted' }}">
{{ $stats['in_progress'] }}
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">PAYONE (30 Tage)</div>
<div class="display-4 font-weight-bold text-info">
{{ $stats['payone_incidents_30d'] }}
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">Gelöst (Monat)</div>
<div class="display-4 font-weight-bold text-success">
{{ $stats['resolved_this_month'] }}
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">Erfolgsrate Zahlung</div>
<div class="display-4 font-weight-bold {{ $transactionStats['success_rate'] < 90 ? 'text-warning' : 'text-success' }}">
{{ $transactionStats['success_rate'] }}%
</div>
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-warning' : '' }}">
<div class="card-body text-center">
<div class="text-muted small">Fehlgeschlagen</div>
<div class="display-4 font-weight-bold {{ $transactionStats['failed'] > 0 ? 'text-danger' : 'text-success' }}">
{{ $transactionStats['failed'] }}
</div>
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,457 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<strong>Abbruch-Analyse</strong>
<small class="text-muted ml-2">Nicht gestartete, abgebrochene und technisch fehlerhafte Zahlungen</small>
</div>
{{-- Zeitraum-Filter --}}
<form method="GET" class="form-inline">
<label class="mr-2 text-muted small">Zeitraum:</label>
<select name="days" class="form-control form-control-sm mr-2" onchange="this.form.submit()">
@foreach ([7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 60 => '60 Tage', 90 => '90 Tage', 0 => 'Alle'] as $value => $label)
<option value="{{ $value }}" {{ $days == $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</form>
</div>
{{-- Stat-Karten --}}
<div class="row mb-4">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body text-center py-3">
<div class="text-muted small mb-1">Zahlung nie gestartet</div>
<div class="h3 font-weight-bold mb-0 text-warning">{{ $abandonedStats['no_payment'] }}</div>
<small class="text-muted">Orders mit txaction=prev ohne Payment</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body text-center py-3">
<div class="text-muted small mb-1">Abgebrochen / Fehler</div>
<div class="h3 font-weight-bold mb-0 text-danger">{{ $abandonedStats['cancelled'] }}</div>
<small class="text-muted">cancel + error Payments</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-secondary">
<div class="card-body text-center py-3">
<div class="text-muted small mb-1">Kein PAYONE-Callback</div>
<div class="h3 font-weight-bold mb-0 text-secondary">{{ $abandonedStats['no_callback'] }}</div>
<small class="text-muted">Payments ohne Transaktion (>2h)</small>
</div>
</div>
</div>
</div>
{{-- Tabs --}}
<ul class="nav nav-tabs mb-0" id="abandonedTabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-no-payment" data-toggle="tab" href="#no-payment" role="tab">
Nie gestartet
@if ($abandonedStats['no_payment'] > 0)
<span class="badge badge-warning ml-1">{{ $abandonedStats['no_payment'] }}</span>
@endif
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-cancelled" data-toggle="tab" href="#cancelled" role="tab">
Abgebrochen / Fehler
@if ($abandonedStats['cancelled'] > 0)
<span class="badge badge-danger ml-1">{{ $abandonedStats['cancelled'] }}</span>
@endif
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-no-callback" data-toggle="tab" href="#no-callback" role="tab">
Kein Callback
@if ($abandonedStats['no_callback'] > 0)
<span class="badge badge-secondary ml-1">{{ $abandonedStats['no_callback'] }}</span>
@endif
</a>
</li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom bg-white px-3 pt-3 pb-2 mb-4">
{{-- Tab 1: Orders ohne Payment --}}
<div class="tab-pane fade show active" id="no-payment" role="tabpanel">
<p class="text-muted small mt-1 mb-3">
Bestellungen, bei denen der Benutzer den Checkout-Prozess zwar abgeschlossen hat (txaction=prev),
aber die Zahlung nie initiiert wurde. Mindestens 30 Minuten alt.
</p>
@if ($ordersWithoutPayment->isEmpty())
<div class="alert alert-success">Keine offenen Bestellungen ohne Zahlung im gewählten Zeitraum.</div>
@else
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th>Order-ID</th>
<th>Kunde / Berater</th>
<th>Typ</th>
<th>Betrag</th>
<th>Erstellt</th>
<th>Vor</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td>
<a href="{{ $order->auth_user_id ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
target="_blank" class="text-monospace">
#{{ $order->id }}
</a>
</td>
<td>
@if ($isConsultant)
<span class="badge badge-primary badge-sm mr-1">Berater</span>
@else
<span class="badge badge-info badge-sm mr-1">Kunde</span>
@endif
<strong>{{ $name ?: '' }}</strong>
<br><small class="text-muted">{{ $email }}</small>
</td>
<td><small>{{ $order->payment_for ?? '' }}</small></td>
<td class="font-weight-bold">
{{ $order->price_total ? number_format($order->price_total, 2, ',', '.') . ' €' : '' }}
</td>
<td class="text-muted small">
{{ $order->created_at ? $order->created_at->format('d.m.Y H:i') : '' }}</td>
<td class="text-muted small">
{{ $order->created_at ? $order->created_at->diffForHumans() : '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $ordersWithoutPayment->links() }}
@endif
</div>
{{-- Tab 2: Abgebrochene / Fehler --}}
<div class="tab-pane fade" id="cancelled" role="tabpanel">
<p class="text-muted small mt-1 mb-3">
Zahlungen, bei denen der Nutzer aktiv abgebrochen hat (<code>cancel</code>) oder bei denen PAYONE
einen Fehler zurückgemeldet hat (<code>error</code>). Zeile anklicken für PAYONE-Fehlerdetails.
</p>
@if ($cancelledPayments->isEmpty())
<div class="alert alert-success">Keine abgebrochenen Zahlungen im gewählten Zeitraum.</div>
@else
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th style="width:1rem"></th>
<th>Referenz</th>
<th>Order-ID</th>
<th>Kunde / Berater</th>
<th>Betrag</th>
<th>Status</th>
<th>Zahlungsart</th>
<th>Zeitpunkt</th>
<th>Vor</th>
</tr>
</thead>
<tbody>
@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 --}}
<tr class="{{ $hasTransactions ? 'cursor-pointer' : '' }}"
@if($hasTransactions) data-toggle="collapse" data-target="#{{ $collapseId }}" @endif>
<td class="text-center text-muted">
@if($hasTransactions)
<i class="ion ion-md-chevron-forward toggle-icon" style="font-size:0.85rem"></i>
@endif
</td>
<td class="text-monospace small">{{ $payment->reference }}</td>
<td>
@if ($order)
<a href="{{ $isConsultant ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
target="_blank" onclick="event.stopPropagation()">#{{ $order->id }}</a>
@else
<span class="text-muted"></span>
@endif
</td>
<td>
@if ($order)
@if ($isConsultant)
<span class="badge badge-primary badge-sm mr-1">Berater</span>
@else
<span class="badge badge-info badge-sm mr-1">Kunde</span>
@endif
<strong>{{ $name ?: '' }}</strong>
<br><small class="text-muted">{{ $email }}</small>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="font-weight-bold">
{{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '' }}
</td>
<td>
@if ($payment->status === 'cancel')
<span class="badge badge-warning">Abgebrochen</span>
@elseif($payment->status === 'error')
<span class="badge badge-danger">Fehler</span>
@else
<span class="badge badge-secondary">{{ $payment->status }}</span>
@endif
</td>
<td class="small">{{ $payment->payment_type ?? '' }}</td>
<td class="text-muted small">
{{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '' }}</td>
<td class="text-muted small">
{{ $payment->created_at ? $payment->created_at->diffForHumans() : '' }}</td>
</tr>
{{-- Aufklappbare Fehlerdetails --}}
@if($hasTransactions)
<tr class="collapse" id="{{ $collapseId }}">
<td colspan="9" class="p-0">
<div class="bg-light border-top border-bottom px-3 py-2">
<small class="text-muted font-weight-bold d-block mb-2">
PAYONE-Transaktionen ({{ $payment->payment_transactions->count() }})
</small>
@foreach($payment->payment_transactions as $tx)
<div class="card card-body p-2 mb-2 {{ $tx->errorcode ? 'border-danger' : 'border-secondary' }}" style="font-size:0.78rem">
<div class="row">
<div class="col-md-3">
<span class="text-muted">TX-ID:</span>
<strong>{{ $tx->txid ?? '' }}</strong><br>
<span class="text-muted">Action:</span>
<code>{{ $tx->txaction ?? '' }}</code><br>
<span class="text-muted">Request:</span>
<code>{{ $tx->request ?? '' }}</code><br>
<span class="text-muted">Status:</span>
@if($tx->status === 'approved')
<span class="badge badge-success">approved</span>
@elseif($tx->status === 'error')
<span class="badge badge-danger">error</span>
@else
<span class="badge badge-secondary">{{ $tx->status ?? '' }}</span>
@endif
</div>
<div class="col-md-4">
@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)
<span class="text-danger font-weight-bold">
<i class="ion ion-md-warning"></i>
Fehlercode {{ $errorcode }}
</span><br>
@if($description)
<span class="font-weight-bold text-dark">{{ $description }}</span><br>
@endif
@if($errormessage)
<span class="text-muted">PAYONE-Meldung:</span>
<span class="text-danger">{{ $errormessage }}</span><br>
@endif
@if($failedcause && $failedcause != '-'.$errorcode)
<span class="text-muted">Ursache:</span>
<code>{{ $failedcause }}</code><br>
@endif
@if($customermessage)
<span class="text-muted">Kundennachricht:</span>
<em>{{ $customermessage }}</em>
@endif
@else
<span class="text-muted font-italic">
@if($tx->txaction === 'failed')
Fehlercode nicht übermittelt
<br><small>(txaction=failed ohne Fehlercode)</small>
@elseif($tx->status === 'REDIRECT')
Nutzer zu PAYONE weitergeleitet
<br><small>(kein Fehler, Redirect)</small>
@else
Kein Fehlercode in diesem Callback
@endif
</span>
@endif
</div>
<div class="col-md-3">
<span class="text-muted">Modus:</span>
@if($tx->mode === 'test')
<span class="badge badge-warning">TEST</span>
@elseif($tx->mode === 'live')
<span class="badge badge-success">LIVE</span>
@else
<span class="text-muted"></span>
@endif
<br>
<span class="text-muted">Zeitpunkt:</span>
{{ $tx->created_at ? $tx->created_at->format('d.m.Y H:i:s') : '' }}
</div>
</div>
</div>
@endforeach
</div>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
</div>
{{ $cancelledPayments->links() }}
@endif
</div>
{{-- Tab 3: Kein Callback --}}
<div class="tab-pane fade" id="no-callback" role="tabpanel">
<p class="text-muted small mt-1 mb-3">
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.
</p>
@if ($pendingPayments->isEmpty())
<div class="alert alert-success">Keine offenen Zahlungen ohne Callback im gewählten Zeitraum.</div>
@else
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th>Referenz</th>
<th>Order-ID</th>
<th>Kunde / Berater</th>
<th>Betrag</th>
<th>Zahlungsart</th>
<th>Modus</th>
<th>Gestartet</th>
<th>Vor</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="text-monospace small">{{ $payment->reference }}</td>
<td>
@if ($order)
<a href="{{ $isConsultant ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
target="_blank">#{{ $order->id }}</a>
@else
<span class="text-muted"></span>
@endif
</td>
<td>
@if ($order)
@if ($isConsultant)
<span class="badge badge-primary badge-sm mr-1">Berater</span>
@else
<span class="badge badge-info badge-sm mr-1">Kunde</span>
@endif
<strong>{{ $name ?: '' }}</strong>
<br><small class="text-muted">{{ $email }}</small>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="font-weight-bold">
{{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '' }}
</td>
<td class="small">{{ $payment->payment_type ?? '' }}</td>
<td>
@if (($payment->mode ?? '') === 'test')
<span class="badge badge-warning">TEST</span>
@elseif(($payment->mode ?? '') === 'live')
<span class="badge badge-success">LIVE</span>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-muted small">
{{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '' }}</td>
<td class="text-muted small">
{{ $payment->created_at ? $payment->created_at->diffForHumans() : '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $pendingPayments->links() }}
@endif
</div>
</div>
@endsection

View file

@ -0,0 +1,302 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
<div class="mb-2">
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<strong>Checkout-Funnel Tracking</strong>
<small class="text-muted ml-2">Internes Tracking aller Checkout-Schritte</small>
</div>
<form method="GET" class="form-inline">
<label class="mr-2 text-muted small">Zeitraum:</label>
<select name="days" class="form-control form-control-sm" onchange="this.form.submit()">
@foreach ([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 60 => '60 Tage', 90 => '90 Tage', 0 => 'Alle'] as $value => $label)
<option value="{{ $value }}" {{ $days == $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</form>
</div>
@php $topCount = $funnelSteps[0]['count'] > 0 ? $funnelSteps[0]['count'] : 1; @endphp
<div class="row">
{{-- ── Funnel ──────────────────────────────────────────────────────── --}}
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header"><strong>Checkout-Funnel</strong></div>
<div class="card-body">
@foreach ($funnelSteps as $i => $step)
@php $barWidth = $topCount > 0 ? round($step['count'] / $topCount * 100) : 0; @endphp
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<div>
<span class="badge badge-secondary mr-1">{{ $i + 1 }}</span>
<strong>{{ $step['label'] }}</strong>
</div>
<div class="text-right">
<span class="font-weight-bold">{{ number_format($step['count'], 0, ',', '.') }}</span>
@if ($step['conversion'] !== null)
<span
class="ml-2 small {{ $step['conversion'] >= 70 ? 'text-success' : ($step['conversion'] >= 40 ? 'text-warning' : 'text-danger') }}">
{{ $step['conversion'] }}%
</span>
@endif
</div>
</div>
<div class="progress" style="height:20px;">
<div class="progress-bar {{ $i === 0 ? 'bg-primary' : ($i === count($funnelSteps) - 1 ? 'bg-success' : 'bg-info') }}"
style="width:{{ max($barWidth, $step['count'] > 0 ? 2 : 0) }}%" role="progressbar">
@if ($barWidth > 8)
{{ $barWidth }}%
@endif
</div>
</div>
</div>
@endforeach
@php
$totalConversion =
$funnelSteps[0]['count'] > 0
? round(($funnelSteps[4]['count'] / $funnelSteps[0]['count']) * 100, 1)
: 0;
@endphp
<div
class="alert {{ $totalConversion >= 50 ? 'alert-success' : ($totalConversion >= 25 ? 'alert-warning' : 'alert-danger') }} mt-2 mb-0 py-2">
<strong>Gesamt-Konversionsrate: {{ $totalConversion }}%</strong>
<small class="text-muted ml-2">(Checkout aufgerufen Zahlung bestätigt)</small>
</div>
</div>
</div>
</div>
{{-- ── Rechte Spalte ────────────────────────────────────────────────── --}}
<div class="col-lg-4">
{{-- Rückkehr-Status --}}
<div class="card mb-4">
<div class="card-header"><strong>Rückkehr von PAYONE</strong></div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
@forelse($returnStats as $status => $count)
<tr>
<td>
@if ($status === 'success')
<span class="badge badge-success">success</span>
@elseif($status === 'cancel')
<span class="badge badge-warning">cancel</span>
@elseif($status === 'error')
<span class="badge badge-danger">error</span>
@else
<span class="badge badge-secondary">{{ $status ?? '?' }}</span>
@endif
</td>
<td class="text-right font-weight-bold">{{ number_format($count, 0, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="2" class="text-muted text-center py-3 small">Noch keine Daten</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Quell-Kanal --}}
<div class="card mb-4">
<div class="card-header"><strong>Quelle (Checkout-Aufrufe)</strong></div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
@forelse($sourceBreakdown as $key => $source)
<tr>
<td style="font-size:0.82rem">
@if ($key === 'kundenshop')
<i class="ion ion-md-cart text-success mr-1"></i>
@elseif($key === 'salescenter')
<i class="ion ion-md-briefcase text-primary mr-1"></i>
@elseif($key === 'beraterzugang')
<i class="ion ion-md-person text-info mr-1"></i>
@elseif($key === 'testserver')
<i class="ion ion-md-flask text-warning mr-1"></i>
@else
<i class="ion ion-md-help text-muted mr-1"></i>
@endif
{{ $source['label'] }}
</td>
<td class="text-right font-weight-bold">
{{ number_format($source['count'], 0, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="2" class="text-muted text-center py-3 small">Noch keine Daten</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
{{-- ── Ereignisse (gefiltert + paginiert) ─────────────────────────────────── --}}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<strong>Ereignisse</strong>
{{-- Filter-Leiste --}}
<form method="GET" class="form-inline mt-1 mt-md-0">
<input type="hidden" name="days" value="{{ $days }}">
<select name="event" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
<option value="">Alle Ereignisse</option>
@foreach (\App\Models\CheckoutFunnelEvent::eventLabels() as $val => $label)
<option value="{{ $val }}" {{ $filterEvent == $val ? 'selected' : '' }}>
{{ $label }}</option>
@endforeach
</select>
<select name="return_status" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
<option value="">Alle Status</option>
<option value="success" {{ $filterStatus == 'success' ? 'selected' : '' }}>success</option>
<option value="cancel" {{ $filterStatus == 'cancel' ? 'selected' : '' }}>cancel</option>
<option value="error" {{ $filterStatus == 'error' ? 'selected' : '' }}>error</option>
</select>
<select name="source" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
<option value="">Alle Quellen</option>
@foreach (\App\Models\CheckoutFunnelEvent::sourceLabels() as $val => $label)
<option value="{{ $val }}" {{ $filterSource == $val ? 'selected' : '' }}>
{{ $label }}</option>
@endforeach
</select>
@if ($filterEvent || $filterStatus || $filterSource)
<a href="{{ route('admin.payment-dashboard.funnel', ['days' => $days]) }}"
class="btn btn-sm btn-outline-secondary"> Reset</a>
@endif
</form>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th>Zeitpunkt</th>
<th>Ereignis</th>
<th>Quelle</th>
<th>Domain</th>
<th>Berater</th>
<th>Order-ID</th>
<th>Zahlungsart</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@forelse($recentEvents as $event)
<tr>
<td class="text-muted small text-nowrap">
{{ $event->created_at->format('d.m. H:i:s') }}<br>
<small>{{ $event->created_at->diffForHumans() }}</small>
</td>
<td>
@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
<span class="badge {{ $badgeClass }}">{{ $event->event_label }}</span>
@if ($event->metadata && isset($event->metadata['txaction']))
<small class="text-muted ml-1">{{ $event->metadata['txaction'] }}</small>
@endif
</td>
<td class="small">
@php $src = $event->source_type; @endphp
@if ($src === 'kundenshop')
<span class="badge badge-success" title="Kundenshop">Shop</span>
@elseif($src === 'salescenter')
<span class="badge badge-primary" title="Salescenter">SC</span>
@elseif($src === 'beraterzugang')
<span class="badge badge-info" title="Beraterzugang">BZ</span>
@elseif($src === 'testserver')
<span class="badge badge-warning" title="Testserver">TEST</span>
@else
<span class="badge badge-secondary">?</span>
@endif
</td>
<td class="text-muted small">{{ $event->domain ?? '' }}</td>
<td class="small">
@if ($event->consultant)
{{ $event->consultant->firstname }} {{ $event->consultant->lastname }}
@else
<span class="text-muted"></span>
@endif
</td>
<td class="small">
@if ($event->shopping_order_id)
<a href="{{ route('admin_sales_users_detail', $event->shopping_order_id) }}"
target="_blank">
#{{ $event->shopping_order_id }}
</a>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="small">{{ $event->payment_method ?? '' }}</td>
<td class="small">
{{ $event->amount_cents ? number_format($event->amount_cents / 100, 2, ',', '.') . ' €' : '' }}
</td>
<td class="small">
@if ($event->return_status)
@if ($event->return_status === 'success')
<span class="badge badge-success">{{ $event->return_status }}</span>
@elseif($event->return_status === 'cancel')
<span class="badge badge-warning">{{ $event->return_status }}</span>
@else
<span class="badge badge-danger">{{ $event->return_status }}</span>
@endif
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-muted py-4">
Keine Ereignisse gefunden.
@if ($filterEvent || $filterStatus || $filterSource)
<a href="{{ route('admin.payment-dashboard.funnel', ['days' => $days]) }}">Filter
zurücksetzen</a>
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($recentEvents->hasPages())
<div class="card-footer">
{{ $recentEvents->links() }}
</div>
@endif
</div>
{{-- Tracking-Hinweis --}}
<div class="alert alert-info small py-2">
<i class="ion ion-md-information-circle mr-1"></i>
<strong>Hinweis:</strong> Das Tracking ist ab dem Aktivierungszeitpunkt aktiv. Ältere Checkouts sind nicht
enthalten.
Schritt 5 „PAYONE Callback" wird sowohl bei synchroner Bestätigung (<code>transactionApproved</code>)
als auch bei asynchronem IPN-Callback (<code>txaction=paid</code> und <code>appointed</code>) erfasst.
</div>
@endsection

View file

@ -0,0 +1,143 @@
@extends('layouts.layout-2')
@section('content')
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
@endif
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">
<i class="ion ion-md-alert text-danger"></i> Payment Monitor
</h4>
<small class="text-muted">Entwickler-Ansicht &mdash; {{ now()->format('d.m.Y H:i') }}</small>
</div>
<div>
<button type="button" class="btn btn-danger btn-sm" data-toggle="modal" data-target="#createIncidentModal">
<i class="ion ion-md-add"></i> Neuer Incident
</button>
</div>
</div>
{{-- Stat-Karten --}}
@include('admin.payment-dashboard._partials.stats-cards')
{{-- Anbieter-Status --}}
<div class="row mb-4">
@foreach($providerStats as $key => $provider)
@php $uptime = $uptimeStats[$key] ?? null; @endphp
<div class="col-sm-6 col-xl-3">
<div class="card {{ $provider['open_incidents'] > 0 ? 'border-danger' : '' }}">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ $provider['label'] }}</strong>
<div>
@if($uptime && $uptime['last_check'])
@if($uptime['last_check']->is_up)
<span class="badge badge-success mr-1" title="Letzte Prüfung: {{ $uptime['last_check']->checked_at->diffForHumans() }}">
<i class="ion ion-md-checkmark"></i> Online
</span>
@else
<span class="badge badge-danger mr-1" title="{{ $uptime['last_check']->error_message }}">
<i class="ion ion-md-close"></i> Offline
</span>
@endif
@endif
@if($provider['open_incidents'] > 0)
<span class="badge badge-warning">{{ $provider['open_incidents'] }} Incident</span>
@endif
</div>
</div>
<div class="text-muted small mt-1">{{ $provider['total_30d'] }} Incidents (30 Tage)</div>
@if($uptime && $uptime['checks_24h'] > 0)
<div class="text-muted" style="font-size:0.7rem">
Uptime 24h: {{ $uptime['uptime_24h'] }}%
@if($uptime['failures_24h'] > 0)
&mdash; <span class="text-danger">{{ $uptime['failures_24h'] }} Ausfälle</span>
@endif
</div>
@elseif($uptime)
<div class="text-muted" style="font-size:0.7rem">Noch keine Uptime-Daten</div>
@endif
</div>
</div>
</div>
@endforeach
</div>
{{-- Tabs --}}
<ul class="nav nav-tabs" id="dashboardTabs">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#tab-open">
Offene Incidents
@if($openIncidents->count() > 0)
<span class="badge badge-danger ml-1">{{ $openIncidents->count() }}</span>
@endif
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#tab-all">Alle Incidents</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.payments') }}">
<i class="ion ion-md-card"></i> Zahlungen &amp; Transaktionen
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.transactions') }}">
<i class="ion ion-md-swap"></i> Rohe Transaktionen
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.funnel') }}">
<i class="ion ion-md-funnel"></i> Funnel-Tracking
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.abandoned') }}">
<i class="ion ion-md-alert"></i> Abbruch-Analyse
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.logs') }}">
<i class="ion ion-md-list"></i> PAYONE Logs
</a>
</li>
</ul>
<div class="tab-content">
{{-- Tab: Offene Incidents --}}
<div class="tab-pane fade show active pt-3" id="tab-open">
<div class="card">
<div class="card-body p-0">
@include('admin.payment-dashboard._partials.incident-table', [
'incidents' => $openIncidents,
'showActions' => true,
])
</div>
</div>
</div>
{{-- Tab: Alle Incidents --}}
<div class="tab-pane fade pt-3" id="tab-all">
<div class="card">
<div class="card-body p-0">
@include('admin.payment-dashboard._partials.incident-table', [
'incidents' => $allIncidents,
'showActions' => true,
])
</div>
</div>
<div class="mt-3">
{{ $allIncidents->links() }}
</div>
</div>
</div>
@include('admin.payment-dashboard._partials.create-incident-modal')
@endsection

View file

@ -0,0 +1,86 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<strong>PAYONE Log-Viewer</strong>
</div>
@if(count($availableDates) > 1)
<form method="GET" action="{{ route('admin.payment-dashboard.logs') }}">
<select name="date" class="custom-select custom-select-sm" onchange="this.form.submit()">
@foreach($availableDates as $date)
<option value="{{ $date }}" {{ $selectedDate === $date ? 'selected' : '' }}>
{{ \Carbon\Carbon::parse($date)->format('d.m.Y') }}
</option>
@endforeach
</select>
</form>
@endif
</div>
@if(count($entries) === 0)
<div class="alert alert-info">
<i class="ion ion-md-information-circle"></i>
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 <code>payone</code> hat noch keine Einträge geschrieben.
@endif
</div>
@else
<div class="mb-2 text-muted small">
<i class="ion ion-md-list"></i> {{ count($entries) }} Einträge (neueste zuerst)
</div>
{{-- Filter --}}
<div class="mb-3">
<input type="text" id="logFilter" class="form-control form-control-sm"
placeholder="Filter: Stichwort oder Fehlercode (z.B. Error:2003)..."
oninput="filterLogs(this.value)">
</div>
<div class="card">
<div class="card-body p-0">
<div id="logEntries">
@foreach($entries as $entry)
@php
$levelColor = match($entry['level']) {
'error' => 'danger',
'warning' => 'warning',
'info' => 'info',
'notice' => 'secondary',
default => 'secondary',
};
@endphp
<div class="log-entry border-bottom px-3 py-2 {{ $entry['level'] === 'error' ? 'bg-light' : '' }}"
data-search="{{ strtolower($entry['timestamp'] . ' ' . $entry['level'] . ' ' . $entry['message']) }}">
<div class="d-flex align-items-start">
<span class="badge badge-{{ $levelColor }} mr-2 mt-1 flex-shrink-0">{{ strtoupper($entry['level']) }}</span>
<div style="min-width:0">
<div class="text-muted small">{{ $entry['timestamp'] }}</div>
<div class="small" style="word-break:break-all; font-family:monospace">{{ $entry['message'] }}</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
<script>
function filterLogs(term) {
const entries = document.querySelectorAll('.log-entry');
const search = term.toLowerCase();
entries.forEach(el => {
el.style.display = el.dataset.search.includes(search) ? '' : 'none';
});
}
</script>
@endsection

View file

@ -0,0 +1,111 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-0">
<i class="ion ion-md-alert text-danger"></i> Payment Monitor
</h4>
<small class="text-muted">Stand: {{ now()->format('d.m.Y H:i') }}</small>
</div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="ion ion-md-code-working"></i> Entwickler-Ansicht
</a>
</div>
{{-- Ampel-Karten --}}
<div class="row mb-4">
<div class="col-md-4">
@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
<div class="card border-{{ $level }}">
<div class="card-body text-center py-4">
<i class="ion ion-md-{{ $level === 'success' ? 'checkmark-circle' : 'alert' }} text-{{ $level }}" style="font-size: 3rem"></i>
<h5 class="mt-2 text-{{ $level }}">{{ $levelText }}</h5>
<div class="display-4 font-weight-bold">{{ $stats['open_incidents'] }}</div>
<div class="text-muted">Offene Störungen</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-warning' : '' }}">
<div class="card-body text-center py-4">
<i class="ion ion-md-card text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}" style="font-size: 3rem"></i>
<h5 class="mt-2">Zahlungsquote</h5>
<div class="display-4 font-weight-bold text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}">
{{ $transactionStats['success_rate'] }}%
</div>
<div class="text-muted">
{{ $transactionStats['failed'] }} fehlgeschlagen (30 Tage)
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center py-4">
<i class="ion ion-md-cash text-info" style="font-size: 3rem"></i>
<h5 class="mt-2">Betroffener Umsatz</h5>
<div class="display-4 font-weight-bold text-{{ $stats['total_affected_revenue'] > 0 ? 'warning' : 'success' }}">
{{ number_format($stats['total_affected_revenue'], 0, ',', '.') }}
</div>
<div class="text-muted">bei offenen Incidents</div>
</div>
</div>
</div>
</div>
{{-- Anbieter-Status --}}
<div class="card mb-4">
<h6 class="card-header">Anbieter-Status</h6>
<div class="card-body">
<div class="row">
@foreach($providerStats as $key => $provider)
<div class="col-sm-6 col-md-3 text-center mb-3">
<div class="h5 mb-1">{{ $provider['label'] }}</div>
@if($provider['open_incidents'] > 0)
<span class="badge badge-danger badge-pill" style="font-size:1rem; padding: 0.5rem 1rem">
<i class="ion ion-md-alert"></i> {{ $provider['open_incidents'] }} Störung(en)
</span>
@else
<span class="badge badge-success badge-pill" style="font-size:1rem; padding: 0.5rem 1rem">
<i class="ion ion-md-checkmark"></i> OK
</span>
@endif
<div class="text-muted small mt-1">{{ $provider['total_30d'] }} Incidents (30d)</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Aktive Störungen --}}
@if($openIncidents->count() > 0)
<div class="card mb-4 border-danger">
<h6 class="card-header bg-danger text-white">
<i class="ion ion-md-alert"></i> Aktive Störungen ({{ $openIncidents->count() }})
</h6>
<div class="card-body p-0">
@include('admin.payment-dashboard._partials.incident-table', [
'incidents' => $openIncidents,
'showActions' => false,
])
</div>
</div>
@endif
{{-- Letzte Vorfälle --}}
<div class="card">
<h6 class="card-header">Letzte Vorfälle</h6>
<div class="card-body p-0">
@include('admin.payment-dashboard._partials.incident-table', [
'incidents' => $recentIncidents,
'showActions' => false,
])
</div>
</div>
@endsection

View file

@ -0,0 +1,328 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<strong>Zahlungs-Übersicht</strong>
<small class="text-muted ml-2">ShoppingPayments mit Transaktionen und Bestellung</small>
</div>
</div>
{{-- Stat-Karten --}}
<div class="row mb-4">
<div class="col-6 col-xl-2">
<div class="card">
<div class="card-body text-center py-2">
<div class="text-muted small">Zahlungen gesamt</div>
<div class="h4 font-weight-bold mb-0">{{ $paymentStats['total'] }}</div>
<div class="text-muted" style="font-size:0.7rem">{{ $paymentStats['days'] }} Tage</div>
</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="card">
<div class="card-body text-center py-2">
<div class="text-muted small">Bezahlt</div>
<div class="h4 font-weight-bold mb-0 text-success">{{ $paymentStats['paid'] }}</div>
</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="card {{ $paymentStats['failed'] > 0 ? 'border-danger' : '' }}">
<div class="card-body text-center py-2">
<div class="text-muted small">Mit Fehler</div>
<div class="h4 font-weight-bold mb-0 {{ $paymentStats['failed'] > 0 ? 'text-danger' : 'text-muted' }}">
{{ $paymentStats['failed'] }}
</div>
</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="card">
<div class="card-body text-center py-2">
<div class="text-muted small">Ausstehend</div>
<div class="h4 font-weight-bold mb-0 text-warning">{{ $paymentStats['pending'] }}</div>
</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="card">
<div class="card-body text-center py-2">
<div class="text-muted small">Volumen gesamt</div>
<div class="h5 font-weight-bold mb-0">{{ number_format($paymentStats['total_amount'], 2, ',', '.') }} </div>
</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="card {{ $paymentStats['failed_amount'] > 0 ? 'border-danger' : '' }}">
<div class="card-body text-center py-2">
<div class="text-muted small">Fehlvolumen</div>
<div class="h5 font-weight-bold mb-0 {{ $paymentStats['failed_amount'] > 0 ? 'text-danger' : 'text-muted' }}">
{{ number_format($paymentStats['failed_amount'], 2, ',', '.') }}
</div>
</div>
</div>
</div>
</div>
{{-- Filter --}}
<div class="card mb-3">
<div class="card-body py-2">
<form method="GET" action="{{ route('admin.payment-dashboard.payments') }}" class="form-inline flex-wrap">
<label class="mr-2 small">Zeitraum:</label>
<select name="days" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
@foreach([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 0 => 'Alle'] as $val => $label)
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Status:</label>
<select name="txaction" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
<option value="">Alle</option>
@foreach(['paid' => 'Bezahlt', 'appointed' => 'Vorgemerkt', 'pending' => 'Ausstehend', 'failed' => 'Fehlgeschlagen'] as $val => $label)
<option value="{{ $val }}" {{ $statusFilter === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Modus:</label>
<select name="mode" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
<option value="">Alle</option>
<option value="live" {{ $modeFilter === 'live' ? 'selected' : '' }}>Live</option>
<option value="test" {{ $modeFilter === 'test' ? 'selected' : '' }}>Test</option>
</select>
</form>
</div>
</div>
{{-- Zahlungs-Tabelle --}}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th style="width:30px"></th>
<th>Payment ID</th>
<th>Bestellung</th>
<th>Kunde</th>
<th>Zahlart</th>
<th>Betrag</th>
<th>Status</th>
<th>Modus</th>
<th>Transaktionen</th>
<th>Datum</th>
</tr>
</thead>
<tbody>
@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
<tr class="{{ $rowClass }}">
<td class="text-center align-middle">
@if($payment->payment_transactions->count() > 0)
<button class="btn btn-sm btn-link p-0 text-muted"
data-toggle="collapse"
data-target="#tx-{{ $payment->id }}"
title="{{ $payment->payment_transactions->count() }} Transaktionen">
<i class="ion ion-md-arrow-dropdown"></i>
</button>
@endif
</td>
<td class="align-middle">
<code class="small">{{ $payment->reference }}</code>
</td>
<td class="align-middle">
@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
<a href="{{ $orderRoute }}" class="font-weight-bold" target="_blank">
#{{ $payment->shopping_order->id }}
</a>
@if($payment->shopping_order->paid)
<i class="ion ion-md-checkmark-circle text-success ml-1" title="Bezahlt"></i>
@endif
<div class="text-muted" style="font-size:0.7rem">
{{ number_format($payment->shopping_order->total ?? 0, 2, ',', '.') }}
</div>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="align-middle small">
@if($payment->shopping_order?->auth_user_id && $payment->shopping_order->auth_user)
{{-- Berater-Bestellung --}}
<span class="badge badge-primary mb-1">Berater</span>
<div>{{ $payment->shopping_order->auth_user->name }}</div>
<div class="text-muted" style="font-size:0.7rem">
{{ $payment->shopping_order->auth_user->email }}
</div>
@elseif($payment->shopping_order?->shopping_user)
{{-- Kunden-Bestellung --}}
@php $su = $payment->shopping_order->shopping_user; @endphp
<span class="badge badge-info mb-1">Kunde</span>
<div>
{{ trim($su->billing_firstname . ' ' . $su->billing_lastname) ?: '—' }}
</div>
<div class="text-muted" style="font-size:0.7rem">
{{ $su->billing_email }}
</div>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="align-middle small">
{{ $payment->getPaymentType() }}
@if($payment->clearingtype)
<span class="badge badge-secondary ml-1">{{ $payment->clearingtype }}</span>
@endif
</td>
<td class="align-middle text-nowrap">
<strong>{{ number_format($payment->amount / 100, 2, ',', '.') }}</strong>
<small class="text-muted">{{ $payment->currency }}</small>
</td>
<td class="align-middle">
@php
$txColor = match($payment->txaction) {
'paid' => 'success',
'failed' => 'danger',
'appointed' => 'info',
'pending' => 'warning',
default => 'secondary',
};
@endphp
<span class="badge badge-{{ $txColor }}">{{ $payment->txaction ?? '—' }}</span>
@if($hasFailed && $isPaid)
<span class="badge badge-warning ml-1" title="Hatte fehlerhafte Transaktionen">
<i class="ion ion-md-warning"></i>
</span>
@endif
</td>
<td class="align-middle">
@if($payment->mode)
<span class="badge badge-{{ $payment->mode === 'test' ? 'warning' : 'light' }}">
{{ $payment->mode }}
</span>
@endif
</td>
<td class="align-middle text-center">
@if($payment->payment_transactions->count() > 0)
<span class="badge badge-{{ $hasFailed ? 'danger' : 'secondary' }}">
{{ $payment->payment_transactions->count() }}
</span>
@else
<span class="text-muted">0</span>
@endif
</td>
<td class="align-middle text-nowrap small text-muted">
{{ $payment->created_at->format('d.m.Y H:i') }}
</td>
</tr>
{{-- Aufklappbare Transaktions-Sub-Tabelle --}}
@if($payment->payment_transactions->count() > 0)
<tr class="collapse" id="tx-{{ $payment->id }}">
<td colspan="10" class="p-0">
<div class="bg-light border-bottom px-3 py-2">
<small class="font-weight-bold text-muted text-uppercase">
Transaktionen zu Payment #{{ $payment->id }} / Referenz {{ $payment->reference }}
</small>
</div>
<table class="table table-sm table-bordered mb-0" style="background:#fafafa">
<thead>
<tr class="bg-light">
<th class="pl-4">TX-ID</th>
<th>Aktion</th>
<th>Status</th>
<th>Fehlercode</th>
<th>Fehlermeldung</th>
<th>Kundennachricht</th>
<th>Modus</th>
<th>Datum</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($payment->payment_transactions as $tx)
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
<td class="pl-4 small">{{ $tx->txid ?? '—' }}</td>
<td>
@php
$txaColor = match($tx->txaction) {
'paid' => 'success', 'failed' => 'danger',
'appointed' => 'info', 'pending' => 'warning',
default => 'secondary',
};
@endphp
<span class="badge badge-{{ $txaColor }}">{{ $tx->txaction ?? '—' }}</span>
</td>
<td class="small">{{ $tx->status ?? '—' }}</td>
<td>
@if($tx->errorcode)
<span class="text-danger font-weight-bold">{{ $tx->errorcode }}</span>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="small text-muted" style="max-width:200px">
{{ \Illuminate\Support\Str::limit($tx->errormessage, 60) }}
</td>
<td class="small text-muted" style="max-width:150px">
{{ \Illuminate\Support\Str::limit($tx->customermessage, 50) }}
</td>
<td>
@if($tx->mode)
<span class="badge badge-{{ $tx->mode === 'test' ? 'warning' : 'light' }}">{{ $tx->mode }}</span>
@endif
</td>
<td class="text-nowrap small text-muted">{{ $tx->created_at->format('d.m.Y H:i') }}</td>
<td>
@if($tx->transmitted_data)
<button class="btn btn-xs btn-outline-secondary"
data-toggle="collapse"
data-target="#raw-{{ $tx->id }}"
title="Rohdaten">
<i class="ion ion-md-code"></i>
</button>
@endif
</td>
</tr>
@if($tx->transmitted_data)
<tr class="collapse" id="raw-{{ $tx->id }}">
<td colspan="9" class="bg-white">
<pre class="mb-0 small" style="max-height:150px; overflow-y:auto; font-size:0.75rem">{{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
</td>
</tr>
@endif
@empty
<tr>
<td colspan="10" class="text-center text-muted py-4">
Keine Zahlungen im gewählten Zeitraum gefunden.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3">
{{ $payments->links() }}
</div>
@endsection

View file

@ -0,0 +1,165 @@
@extends('layouts.layout-2')
@section('content')
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
@endif
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
</div>
<div>
<span class="badge badge-{{ $incident->severity_color }} badge-pill px-3 py-2" style="font-size:0.9rem">
{{ $incident->severity_label }}
</span>
<span class="badge badge-{{ $incident->status_color }} badge-pill px-3 py-2 ml-1" style="font-size:0.9rem">
{{ $incident->status_label }}
</span>
</div>
</div>
<div class="row">
{{-- Linke Spalte: Details + Timeline --}}
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header d-flex align-items-center">
<i class="ion {{ $incident->type_icon }} mr-2 text-secondary"></i>
<h5 class="mb-0">{{ $incident->title }}</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-4">
<small class="text-muted d-block">Anbieter</small>
<span class="badge badge-secondary">{{ $incident->provider_label }}</span>
</div>
<div class="col-sm-4">
<small class="text-muted d-block">Typ</small>
{{ $incident->type_label }}
</div>
<div class="col-sm-4">
<small class="text-muted d-block">Erkannt am</small>
{{ $incident->detected_at->format('d.m.Y H:i') }}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-4">
<small class="text-muted d-block">Dauer</small>
{{ $incident->duration }}
</div>
<div class="col-sm-4">
<small class="text-muted d-block">Betroffene Bestellungen</small>
{{ $incident->affected_orders }}
</div>
<div class="col-sm-4">
<small class="text-muted d-block">Betroffener Umsatz</small>
{{ number_format($incident->affected_revenue, 2, ',', '.') }}
</div>
</div>
@if($incident->ticket_number)
<div class="mb-3">
<small class="text-muted d-block">Ticket-Nummer</small>
<code>{{ $incident->ticket_number }}</code>
</div>
@endif
@if($incident->description)
<div class="mb-3">
<small class="text-muted d-block">Beschreibung</small>
<p class="mb-0">{{ $incident->description }}</p>
</div>
@endif
@if($incident->notes)
<div>
<small class="text-muted d-block">Interne Notizen</small>
<p class="mb-0 text-muted">{{ $incident->notes }}</p>
</div>
@endif
</div>
</div>
{{-- Aktivitäten-Timeline --}}
<div class="card">
<h6 class="card-header">Kommunikationsverlauf ({{ $incident->activities->count() }})</h6>
<div class="card-body">
@include('admin.payment-dashboard._partials.activity-timeline')
</div>
</div>
</div>
{{-- Rechte Spalte: Aktionen --}}
<div class="col-lg-4">
{{-- Status ändern --}}
<div class="card mb-4">
<h6 class="card-header">Status ändern</h6>
<div class="card-body">
<form method="POST" action="{{ route('admin.payment-dashboard.status.update', $incident) }}">
@csrf
@method('PATCH')
<div class="form-group">
<select name="status" class="custom-select">
@foreach(['open' => 'Offen', 'in_progress' => 'In Bearbeitung', 'waiting_provider' => 'Wartet auf Anbieter', 'resolved' => 'Gelöst', 'closed' => 'Geschlossen'] as $value => $label)
<option value="{{ $value }}" {{ $incident->status === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">Status speichern</button>
</form>
</div>
</div>
{{-- Aktivität hinzufügen --}}
<div class="card">
<h6 class="card-header">Aktivität hinzufügen</h6>
<div class="card-body">
@if($errors->has('type') || $errors->has('title'))
<div class="alert alert-danger small">
@foreach($errors->all() as $error)
<div>{{ $error }}</div>
@endforeach
</div>
@endif
<form method="POST" action="{{ route('admin.payment-dashboard.activity.store', $incident) }}">
@csrf
<div class="form-group">
<label class="small">Typ</label>
<select name="type" class="custom-select custom-select-sm">
<option value="note">Notiz</option>
<option value="email">E-Mail</option>
<option value="call">Telefonat</option>
<option value="ticket">Ticket</option>
<option value="provider_response">Anbieter-Antwort</option>
</select>
</div>
<div class="form-group">
<label class="small">Titel</label>
<input type="text" name="title" class="form-control form-control-sm"
placeholder="Kurze Beschreibung" value="{{ old('title') }}" required>
</div>
<div class="form-group">
<label class="small">Details</label>
<textarea name="content" class="form-control form-control-sm" rows="3"
placeholder="Inhalt der Aktivität...">{{ old('content') }}</textarea>
</div>
<button type="submit" class="btn btn-secondary btn-block btn-sm">
<i class="ion ion-md-add"></i> Aktivität speichern
</button>
</form>
</div>
</div>
@if($incident->resolved_at)
<div class="mt-3 text-center text-muted small">
<i class="ion ion-md-checkmark-circle text-success"></i>
Gelöst am {{ $incident->resolved_at->format('d.m.Y H:i') }}
</div>
@endif
</div>
</div>
@endsection

View file

@ -0,0 +1,183 @@
@extends('layouts.layout-2')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<a href="{{ route('admin.payment-dashboard.payments') }}" class="btn btn-sm btn-outline-primary mr-2">
<i class="ion ion-md-card"></i> Zahlungen &amp; Transaktionen
</a>
<strong>Rohe Transaktionen</strong>
</div>
</div>
{{-- Stat-Karten --}}
<div class="row mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">Gesamt</div>
<div class="display-4 font-weight-bold">{{ $transactionStats['total'] }}</div>
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">Erfolgsrate</div>
<div class="display-4 font-weight-bold text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}">
{{ $transactionStats['success_rate'] }}%
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">Bezahlt</div>
<div class="display-4 font-weight-bold text-success">{{ $transactionStats['paid'] }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-danger' : '' }}">
<div class="card-body text-center">
<div class="text-muted small">Fehlgeschlagen</div>
<div class="display-4 font-weight-bold {{ $transactionStats['failed'] > 0 ? 'text-danger' : 'text-muted' }}">
{{ $transactionStats['failed'] }}
</div>
</div>
</div>
</div>
</div>
@if($transactionStats['error_distribution']->count() > 0)
<div class="card mb-4">
<h6 class="card-header">Fehlercodes (letzte {{ $transactionStats['days'] }} Tage)</h6>
<div class="card-body py-2">
@foreach($transactionStats['error_distribution'] as $error)
<span class="badge badge-danger mr-2 mb-1" style="font-size:0.85rem; padding: 0.4rem 0.7rem">
Code {{ $error->errorcode }}: {{ $error->count }}×
@if($error->errormessage) &mdash; {{ \Illuminate\Support\Str::limit($error->errormessage, 60) }} @endif
</span>
@endforeach
</div>
</div>
@endif
{{-- Filter --}}
<div class="card mb-3">
<div class="card-body py-2">
<form method="GET" action="{{ route('admin.payment-dashboard.transactions') }}" class="form-inline">
<label class="mr-2 small">Zeitraum:</label>
<select name="days" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
@foreach([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 0 => 'Alle'] as $val => $label)
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Aktion:</label>
<select name="txaction" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
<option value="">Alle</option>
@foreach(['paid' => 'Bezahlt', 'appointed' => 'Vorgemerkt', 'pending' => 'Ausstehend', 'failed' => 'Fehlgeschlagen'] as $val => $label)
<option value="{{ $val }}" {{ $txactionFilter == $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</form>
</div>
</div>
{{-- Transaktions-Tabelle --}}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th>ID</th>
<th>Datum</th>
<th>Aktion</th>
<th>TX-ID</th>
<th>Referenz</th>
<th>Modus</th>
<th>Fehlercode</th>
<th>Fehlermeldung</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($transactions as $tx)
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
<td class="text-muted small">{{ $tx->id }}</td>
<td class="text-nowrap small">{{ $tx->created_at->format('d.m.Y H:i') }}</td>
<td>
@php
$actionColor = match($tx->txaction) {
'paid' => 'success',
'failed' => 'danger',
'appointed' => 'info',
'pending' => 'warning',
default => 'secondary',
};
@endphp
<span class="badge badge-{{ $actionColor }}">{{ $tx->txaction ?? '—' }}</span>
</td>
<td class="small">{{ $tx->txid ?? '—' }}</td>
<td class="small">
@if($tx->shopping_payment)
<code>{{ $tx->shopping_payment->reference }}</code>
@else
@endif
</td>
<td>
@if($tx->mode)
<span class="badge badge-{{ $tx->mode === 'test' ? 'warning' : 'secondary' }}">{{ $tx->mode }}</span>
@endif
</td>
<td>
@if($tx->errorcode)
<span class="text-danger font-weight-bold">{{ $tx->errorcode }}</span>
@endif
</td>
<td class="small text-muted" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
{{ $tx->errormessage }}
@if($tx->customermessage)
<span class="text-info">({{ \Illuminate\Support\Str::limit($tx->customermessage, 40) }})</span>
@endif
</td>
<td>
@if($tx->transmitted_data)
<button class="btn btn-sm btn-outline-secondary" type="button"
data-toggle="collapse" data-target="#tx-data-{{ $tx->id }}">
<i class="ion ion-md-code"></i>
</button>
@endif
</td>
</tr>
@if($tx->transmitted_data)
<tr class="collapse" id="tx-data-{{ $tx->id }}">
<td colspan="9" class="bg-light">
<pre class="mb-0 small" style="max-height:200px; overflow-y:auto">{{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</td>
</tr>
@endif
@empty
<tr>
<td colspan="9" class="text-center text-muted py-3">Keine Transaktionen gefunden.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3">
{{ $transactions->links() }}
</div>
@endsection

View file

@ -138,6 +138,148 @@
</div> </div>
</div> </div>
</div> </div>
<hr>
<!-- Umsätze nach Ländern - Jährlich -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Umsätze nach Ländern {{ session('revenue_filter_year') }}</h6>
</div>
<div class="card-body p-0">
@if(isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0)
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th>Land</th>
<th class="text-right">Netto</th>
<th class="text-right">Steuer</th>
<th class="text-right">Brutto</th>
</tr>
</thead>
<tbody>
@foreach($revenue_summary['country_yearly'] as $item)
<tr>
<td>{{ $item->country_name }}</td>
<td class="text-right">{{ number_format($item->total_net, 2, ',', '.') }} </td>
<td class="text-right">{{ number_format($item->total_tax, 2, ',', '.') }} </td>
<td class="text-right"><strong>{{ number_format($item->total_gross, 2, ',', '.') }} </strong></td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="p-3"><p class="text-muted mb-0">Keine Umsätze nach Ländern für {{ session('revenue_filter_year') }} gefunden</p></div>
@endif
</div>
</div>
</div>
<!-- Gutschriften nach Ländern - Jährlich -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Gutschriften nach Ländern {{ session('revenue_filter_year') }}</h6>
</div>
<div class="card-body p-0">
@if(isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0)
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th>Land</th>
<th class="text-right">Netto</th>
<th class="text-right">Steuer</th>
<th class="text-right">Brutto</th>
</tr>
</thead>
<tbody>
@foreach($credit_summary['country_yearly'] as $item)
<tr>
<td>{{ $item->country_name }}</td>
<td class="text-right">{{ number_format($item->total_net, 2, ',', '.') }} </td>
<td class="text-right">{{ number_format($item->total_tax, 2, ',', '.') }} </td>
<td class="text-right"><strong>{{ number_format($item->total_gross, 2, ',', '.') }} </strong></td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="p-3"><p class="text-muted mb-0">Keine Gutschriften nach Ländern für {{ session('revenue_filter_year') }} gefunden</p></div>
@endif
</div>
</div>
</div>
</div>
<!-- Umsätze nach Ländern - Monatlich -->
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Umsätze nach Ländern monatliche Aufschlüsselung</h6>
</div>
<div class="card-body p-0">
@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)
<div class="px-3 pt-2 pb-1">
<strong class="text-primary">{{ $countries->first()->month_label }}</strong>
</div>
<table class="table table-sm mb-1">
<tbody>
@foreach($countries as $item)
<tr>
<td class="pl-4">{{ $item->country_name }}</td>
<td class="text-right text-muted"><small>{{ number_format($item->total_net, 2, ',', '.') }} </small></td>
<td class="text-right text-muted"><small>{{ number_format($item->total_tax, 2, ',', '.') }} </small></td>
<td class="text-right"><small><strong>{{ number_format($item->total_gross, 2, ',', '.') }} </strong></small></td>
</tr>
@endforeach
</tbody>
</table>
@endforeach
@else
<div class="p-3"><p class="text-muted mb-0">Keine monatlichen Umsätze nach Ländern gefunden</p></div>
@endif
</div>
</div>
</div>
<!-- Gutschriften nach Ländern - Monatlich -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Gutschriften nach Ländern monatliche Aufschlüsselung</h6>
</div>
<div class="card-body p-0">
@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)
<div class="px-3 pt-2 pb-1">
<strong class="text-primary">{{ $countries->first()->month_label }}</strong>
</div>
<table class="table table-sm mb-1">
<tbody>
@foreach($countries as $item)
<tr>
<td class="pl-4">{{ $item->country_name }}</td>
<td class="text-right text-muted"><small>{{ number_format($item->total_net, 2, ',', '.') }} </small></td>
<td class="text-right text-muted"><small>{{ number_format($item->total_tax, 2, ',', '.') }} </small></td>
<td class="text-right"><small><strong>{{ number_format($item->total_gross, 2, ',', '.') }} </strong></small></td>
</tr>
@endforeach
</tbody>
</table>
@endforeach
@else
<div class="p-3"><p class="text-muted mb-0">Keine monatlichen Gutschriften nach Ländern gefunden</p></div>
@endif
</div>
</div>
</div>
</div>
</div> </div>
<script> <script>

View file

@ -1,4 +1,7 @@
@if (isset($activeIncentive) && $activeIncentive) @if (isset($activeIncentive) && $activeIncentive)
@php
$hasConfirmedDash = $incentiveParticipant && $incentiveParticipant->accepted_terms_at !== null;
@endphp
<div class="d-flex col-xl-12 align-items-stretch"> <div class="d-flex col-xl-12 align-items-stretch">
<div class="w-100 mb-4 inc-dash-widget"> <div class="w-100 mb-4 inc-dash-widget">
@ -60,6 +63,33 @@
</div> </div>
@endif @endif
{{-- Notice: Teilnahme noch nicht bestätigt --}}
@if (!$hasConfirmedDash)
<div class="inc-dash-notice mb-3">
<div class="d-flex align-items-start">
<i class="ion ion-md-alert inc-dash-notice-icon mr-2 mt-1"></i>
<div class="flex-grow-1">
@if ($incentiveParticipant)
<strong
class="inc-dash-notice-title">{{ __('incentive.dash_notice_unconfirmed_title') }}</strong>
<p class="mb-2 small">{{ __('incentive.dash_notice_unconfirmed_body') }}</p>
@else
<strong
class="inc-dash-notice-title">{{ __('incentive.dash_notice_unregistered_title') }}</strong>
<p class="mb-2 small">{{ __('incentive.dash_notice_unregistered_body') }}</p>
@endif
@if ($activeIncentive->isActive())
<button type="button" class="btn inc-dash-btn-notice" data-toggle="modal"
data-target="#incParticipateModal">
<i class="ion ion-md-checkmark-circle mr-1"></i>
{{ __('incentive.dash_notice_btn') }}
</button>
@endif
</div>
</div>
</div>
@endif
{{-- Bilder-Leiste --}} {{-- Bilder-Leiste --}}
@php @php
$dashGallery = []; $dashGallery = [];
@ -99,6 +129,13 @@
<i class="ion ion-md-list mr-1"></i> <i class="ion ion-md-list mr-1"></i>
{{ __('incentive.dashboard_btn_ranking') }} {{ __('incentive.dashboard_btn_ranking') }}
</a> </a>
@if ($incentiveParticipant)
<a href="{{ route('user_incentive_details', [$activeIncentive->slug]) }}"
class="btn inc-dash-btn-secondary">
<i class="ion ion-md-list mr-1"></i>
{{ __('incentive.my_calculation') }}
</a>
@endif
</div> </div>
</div> </div>
@ -106,7 +143,117 @@
</div> </div>
</div> </div>
{{-- Teilnahme-Modal --}}
@if (!$hasConfirmedDash && $activeIncentive->isActive())
<div class="modal fade" id="incParticipateModal" tabindex="-1" role="dialog"
aria-labelledby="incParticipateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header" style="background: linear-gradient(135deg, #6b7758, #4a5340);">
<h5 class="modal-title text-white" id="incParticipateModalLabel">
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>
{{ __('incentive.dash_modal_title') }}
</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="incDashParticipateForm"
action="{{ route('user_incentive_participate', [$activeIncentive->slug]) }}" method="POST">
@csrf
<div class="modal-body">
<p class="mb-3 small">{{ __('incentive.dash_modal_intro') }}</p>
@if ($activeIncentive->getLang('description'))
<div class="mb-3" style="line-height: 1.6;">
{!! $activeIncentive->getLang('description') !!}
</div>
<hr>
@endif
@if ($activeIncentive->getLang('terms'))
<div class="card mb-4" style="border: 1px solid #e0e0d8;">
<div class="card-header py-2 px-3" style="cursor: pointer; background: #f4f5f0;"
data-toggle="collapse" data-target="#dashTermsCollapse">
<div class="d-flex align-items-center">
<i class="ion ion-md-document mr-2" style="color: #6b7758;"></i>
<strong class="small">{{ __('incentive.terms') }}</strong>
<i class="ion ion-md-chevron-down ml-auto text-muted"></i>
</div>
</div>
<div id="dashTermsCollapse" class="collapse">
<div class="card-body small"
style="max-height: 280px; overflow-y: auto; line-height: 1.6;">
{!! $activeIncentive->getLang('terms') !!}
</div>
</div>
</div>
@endif
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms"
name="accept_terms" value="1" required>
<label class="custom-control-label" for="dashAcceptTerms">
{{ __('incentive.accept_terms') }}
@if ($activeIncentive->getLang('terms'))
(<a href="#dashTermsCollapse" data-toggle="collapse"
class="text-muted">{{ __('incentive.show_terms') }}</a>)
@endif
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
{{ __('incentive.dash_modal_cancel') }}
</button>
<button type="submit" class="btn inc-dash-btn-primary px-4">
<i class="ion ion-md-checkmark mr-1"></i>
{{ __('incentive.participate_now') }}
</button>
</div>
</form>
</div>
</div>
</div>
@endif
<style> <style>
.inc-dash-notice {
background: rgba(215, 185, 0, 0.10);
border: 1px solid rgba(215, 185, 0, 0.35);
border-radius: .6rem;
padding: .9rem 1rem;
color: #555;
}
.inc-dash-notice-icon {
color: #c8a000;
font-size: 1.2rem;
}
.inc-dash-notice-title {
display: block;
color: #444;
margin-bottom: .2rem;
font-size: .9rem;
}
.inc-dash-btn-notice {
background: linear-gradient(135deg, #6b7758, #4a5340);
color: #fff !important;
border: none;
border-radius: 50px;
padding: .4rem 1.2rem;
font-weight: 700;
font-size: .82rem;
transition: transform .2s, box-shadow .2s;
}
.inc-dash-btn-notice:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(107, 119, 88, 0.35);
}
.inc-dash-widget { .inc-dash-widget {
border-radius: .75rem; border-radius: .75rem;
overflow: hidden; overflow: hidden;

View file

@ -0,0 +1,114 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Payment Incident Alert mivita.care</title>
<style type="text/css">
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
table { border-collapse: separate !important; }
.severity-critical { color: #dc3545; }
.severity-high { color: #fd7e14; }
.severity-medium { color: #ffc107; }
.severity-low { color: #28a745; }
.label { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; }
.label-danger { background: #dc3545; color: #fff; }
.label-warning { background: #ffc107; color: #212529; }
.label-success { background: #28a745; color: #fff; }
a { color: #919f7a; text-decoration: none; }
</style>
</head>
<body style="padding:0; margin:0; display:block; background:#f8f8f8;">
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
<tr>
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
<br>
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700">
<tr>
<td align="center" style="padding-bottom: 20px;">
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" width="200">
</td>
</tr>
<tr>
<td>
<table cellpadding="10" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
<tr>
<td style="font-family: Helvetica, sans-serif; font-size: 18px; font-weight: bold; padding-bottom: 5px;">
🚨 {{ $title }}
</td>
</tr>
<tr>
<td>
<table style="padding: 20px; border: 1px solid #eee; background-color: #fff8f8; line-height: 1.8em; width: 100%;" cellpadding="4" cellspacing="0">
<tr>
<td width="160"><strong>Titel</strong></td>
<td>{{ $incident->title }}</td>
</tr>
<tr>
<td><strong>Schwere</strong></td>
<td>
<span class="label label-{{ $incident->severity === 'critical' ? 'danger' : ($incident->severity === 'high' ? 'warning' : 'success') }}">
{{ $incident->severity_label }}
</span>
</td>
</tr>
<tr>
<td><strong>Anbieter</strong></td>
<td>{{ $incident->provider_label }}</td>
</tr>
<tr>
<td><strong>Typ</strong></td>
<td>{{ $incident->type_label }}</td>
</tr>
<tr>
<td><strong>Erkannt am</strong></td>
<td>{{ $incident->detected_at->format('d.m.Y H:i') }} Uhr</td>
</tr>
@if($incident->description)
<tr>
<td><strong>Beschreibung</strong></td>
<td>{{ $incident->description }}</td>
</tr>
@endif
@if($incident->affected_orders > 0)
<tr>
<td><strong>Betroffene Bestellungen</strong></td>
<td>{{ $incident->affected_orders }}</td>
</tr>
@endif
@if($incident->affected_revenue > 0)
<tr>
<td><strong>Betroffener Umsatz</strong></td>
<td>{{ number_format($incident->affected_revenue, 2, ',', '.') }} </td>
</tr>
@endif
</table>
</td>
</tr>
<tr>
<td style="text-align: center; padding: 20px 0;">
<a href="{{ $dashboardUrl }}"
style="display: inline-block; padding: 10px 24px; background-color: #dc3545; color: #ffffff; font-family: Helvetica, sans-serif; font-size: 14px; font-weight: bold; border-radius: 4px; text-decoration: none;">
Incident im Dashboard öffnen
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="color:#7B7B7E; font-size:13px; text-align: center; padding: 20px 0;">
Diese Nachricht wurde automatisch von mivita.care generiert.<br>
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -617,6 +617,25 @@
<div>{{ __('navigation.tools') }}</div> <div>{{ __('navigation.tools') }}</div>
</a> </a>
</li> </li>
<li class="sidenav-item{{ Request::is('admin/payment-dashboard*') ? ' active' : '' }}">
<a href="{{ route('admin.payment-dashboard.index') }}" class="sidenav-link">
<i class="sidenav-icon ion ion-md-alert"></i>
<div>{{ __('navigation.payment_monitor') }}</div>
@php $openIncidentCount = Cache::remember('open_incident_count', 60, fn() => \App\Models\PaymentIncident::whereIn('status', ['open','in_progress','waiting_provider'])->count()); @endphp
@if($openIncidentCount > 0)
<div class="pl-1 ml-auto">
<div class="badge badge-danger">{{ $openIncidentCount }}</div>
</div>
@endif
</a>
</li>
{{-- GF-Ansicht: vorerst auskommentiert --}}
{{-- <li class="sidenav-item{{ Request::is('admin/payment-dashboard/management') ? ' active' : '' }}">
<a href="{{ route('admin.payment-dashboard.management') }}" class="sidenav-link">
<i class="sidenav-icon ion ion-md-stats"></i>
<div>{{ __('navigation.payment_monitor_management') }}</div>
</a>
</li> --}}
@endif @endif
@if (Auth::user()->isSySAdmin()) @if (Auth::user()->isSySAdmin())
<li class="sidenav-divider mb-1"></li> <li class="sidenav-divider mb-1"></li>

View file

@ -386,6 +386,49 @@
font-weight: 600; font-weight: 600;
} }
.vip-ranking-notice {
background: rgba(107, 119, 88, 0.08);
border-bottom: 1px solid rgba(107, 119, 88, 0.15);
padding: .6rem 1.5rem;
font-size: .8rem;
color: #6b7758;
font-weight: 600;
}
.vip-terms-accepted {
color: #5a8a5a;
font-size: 1rem;
vertical-align: middle;
}
.vip-terms-pending {
color: #c0392b;
font-size: 1rem;
vertical-align: middle;
}
.inc-ranking-card .pagination {
margin: 0;
justify-content: center;
}
.inc-ranking-card .page-item .page-link {
color: #6b7758;
border-color: #e0e0d8;
font-size: .85rem;
}
.inc-ranking-card .page-item.active .page-link {
background-color: #6b7758;
border-color: #6b7758;
color: #fff;
}
.inc-ranking-card .page-item.disabled .page-link {
color: #bbb;
border-color: #e0e0d8;
}
.pending-banner { .pending-banner {
background: rgba(215, 215, 0, 0.12); background: rgba(215, 215, 0, 0.12);
border: 1px solid rgba(215, 215, 0, 0.3); border: 1px solid rgba(215, 215, 0, 0.3);
@ -857,7 +900,7 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<i class="ion ion-md-list mr-2" style="font-size: 1.2rem; color: #6b7758;"></i> <i class="ion ion-md-list mr-2" style="font-size: 1.2rem; color: #6b7758;"></i>
<span class="ranking-title">{{ __('incentive.section_ranking') }}</span> <span class="ranking-title">{{ __('incentive.section_ranking') }}</span>
<span class="badge-top ml-2">Top {{ $rankingDisplayLimit }}</span> <span class="badge-top ml-2">{{ __('incentive.ranking_all_active') }}</span>
</div> </div>
<span <span
class="hint-text">{{ __('incentive.ranking_winners_hint', ['n' => $incentive->max_winners]) }}</span> class="hint-text">{{ __('incentive.ranking_winners_hint', ['n' => $incentive->max_winners]) }}</span>
@ -868,6 +911,12 @@
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if ($isVipView)
<div class="vip-ranking-notice">
<i class="ion ion-md-eye mr-1"></i>
{{ __('incentive.vip_view_notice') }}
</div>
@endif
@if ($ranking->isEmpty()) @if ($ranking->isEmpty())
<div class="p-4 text-center text-muted"> <div class="p-4 text-center text-muted">
<i class="ion ion-md-people mb-2 d-block" style="font-size: 2.5rem; opacity: .4;"></i> <i class="ion ion-md-people mb-2 d-block" style="font-size: 2.5rem; opacity: .4;"></i>
@ -904,7 +953,16 @@
@endif @endif
</td> </td>
<td> <td>
@if ($p->accepted_terms_at) @if ($p->accepted_terms_at || $isVipView)
@if ($isVipView)
@if ($p->accepted_terms_at)
<i class="ion ion-md-checkmark-circle vip-terms-accepted ml-1"
title="{{ __('incentive.vip_terms_accepted') }}"></i>&nbsp;
@else
<i class="ion ion-md-close-circle vip-terms-pending ml-1"
title="{{ __('incentive.vip_terms_pending') }}"></i>&nbsp;
@endif
@endif
@if ($p->user && $p->user->account) @if ($p->user && $p->user->account)
{{ $p->user->account->first_name }} {{ $p->user->account->last_name }} {{ $p->user->account->first_name }} {{ $p->user->account->last_name }}
@else @else
@ -947,6 +1005,11 @@
</table> </table>
</div> </div>
@endif @endif
@if ($ranking->hasPages())
<div class="p-3">
{{ $ranking->links() }}
</div>
@endif
</div> </div>
</div> </div>

View file

@ -1,73 +1,148 @@
@extends($user_shop ?'web.user.layouts.layout' : 'web.layouts.layout') @extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout')
@section('content') @section('content')
@php
$isCancel = ($error_type ?? 'error') === 'cancel';
$errorcode = $errorcode ?? null;
$errorDescription = $error_description ?? null;
// Fehlercode → konkreten Hinweistext ermitteln
$errorReason = null;
if (!$isCancel && $errorcode) {
$code = (int) $errorcode;
if (in_array($code, [33])) {
$errorReason = __('payment.payment_error_reasons.card_expired');
} elseif (in_array($code, [4, 34])) {
$errorReason = __('payment.payment_error_reasons.card_blocked');
} elseif (in_array($code, [12, 14, 105])) {
$errorReason = __('payment.payment_error_reasons.card_invalid');
} elseif (in_array($code, [5, 902, 4219])) {
$errorReason = __('payment.payment_error_reasons.card_declined');
} elseif (in_array($code, [130])) {
$errorReason = __('payment.payment_error_reasons.insufficient_funds');
} elseif (in_array($code, [120])) {
$errorReason = __('payment.payment_error_reasons.cvv_invalid');
} elseif (in_array($code, [900])) {
$errorReason = __('payment.payment_error_reasons.3ds_failed');
} elseif (in_array($code, [970, 135])) {
$errorReason = __('payment.payment_error_reasons.timeout');
} elseif (in_array($code, [4218])) {
$errorReason = __('payment.payment_error_reasons.fraud');
} else {
$errorReason = __('payment.payment_error_reasons.general');
}
} elseif (!$isCancel) {
$errorReason = __('payment.payment_error_reasons.general');
}
@endphp
<section class="page-header page-header-xlg parallax parallax-3" <section class="page-header page-header-xlg parallax parallax-3"
style="background-image:url('/assets/images/vision-min.jpg')"> style="background-image:url('/assets/images/vision-min.jpg')">
<div class="overlay dark-1"><!-- dark overlay [1 to 9 opacity] --></div> <div class="overlay dark-1"></div>
<div class="container"></div>
<div class="container">
</div>
</section> </section>
<!-- /PAGE HEADER -->
<style> <section class="py-5">
div.shop-item {
margin-bottom:30px;
border: 1px solid #ddd;
}
div.shop-item > .thumbnail, .thumbnail {
border: none;
}
div.shop-item-summary {
padding: 8px;
}
div.shop-item-summary h2 a {
color: #9aa983;
font-size: 1.2em;
margin: 0 0 10px 0;
}
div.shop-item-buttons {
padding: 0 8px 10px 8px;
}
div.shop-item-buttons .btn-xs{
padding: 4px;
}
</style>
<!-- -->
<!-- -->
<section>
<div class="container"> <div class="container">
<div class="row justify-content-center">
<div class="col-lg-2 col-md-12"></div>
<div class="col-md-12 col-lg-8">
<!-- CHECKOUT ERROR MESSAGE --> @if ($isCancel)
<div class="panel panel-default"> {{-- ── ABGEBROCHEN ──────────────────────────────── --}}
<div class="panel-body"> <div class="panel panel-default">
<div class="alert alert-danger"> <div class="panel-body text-center py-4">
<h3><i class="fa fa-exclamation-triangle"></i> {{ $error_title ?? __('payment.payment_error') }}</h3> <div style="font-size:3rem; color:#f0ad4e;" class="mb-3">
<p>{{ $error_message ?? __('payment.payment_error_description') }}</p> <i class="fa fa-ban"></i>
</div> </div>
<h3 class="mb-2">{{ $error_title }}</h3>
<hr /> <p class="text-muted mb-1">{{ $error_message }}</p>
<p class="text-muted small mb-4">{{ __('payment.nothing_was_charged') }}</p>
<p>{{ __('payment.contact_support_if_needed') }}</p> <div class="alert alert-info text-left py-2 mb-4" style="font-size:0.9rem;">
<i class="fa fa-info-circle mr-1"></i>
<p> {{ __('payment.payment_canceled_hint') }}
<strong>{{ __('payment.your_mivita_team') }}</strong> </div>
</p>
@if($user_shop) @if (isset($checkout_url))
<div class="mt-4"> <a href="{{ $checkout_url }}" class="btn btn-primary btn-lg btn-block mb-2">
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}" class="btn btn-primary"> <i class="fa fa-refresh mr-1"></i>
<i class="fa fa-arrow-left"></i> {{ __('payment.back_to_shop') }} {{ __('payment.try_again') }}
</a> </a>
</div> @endif
@if ($user_shop)
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}"
class="btn btn-default btn-block">
<i class="fa fa-arrow-left mr-1"></i> {{ __('payment.back_to_shop') }}
</a>
@endif
</div>
</div>
@else
{{-- ── FEHLER ───────────────────────────────────── --}}
<div class="panel panel-default">
<div class="panel-body py-4">
<div class="text-center mb-3">
<div style="font-size:3rem; color:#d9534f;">
<i class="fa fa-exclamation-circle"></i>
</div>
<h3 class="mt-2 mb-1">{{ $error_title }}</h3>
<p class="text-muted mb-0">{{ $error_message }}</p>
<small class="text-muted">{{ __('payment.nothing_was_charged') }}</small>
</div>
<hr>
{{-- Konkreter Hinweis basierend auf Fehlercode --}}
<div class="alert alert-warning mb-3" style="font-size:0.9rem;">
<strong><i
class="fa fa-lightbulb-o mr-1"></i>{{ __('payment.payment_error_what_to_do') }}</strong><br>
{{ $errorReason }}
</div>
{{-- Fehlerbeschreibung + Code (für Transparenz) --}}
@if ($errorcode || $errorDescription)
<div class="panel panel-default mb-3" style="font-size:0.82rem;">
<div class="panel-heading py-1 px-3" style="font-size:0.82rem;">
<strong>{{ __('payment.payment_error_code') }}</strong>
</div>
<div class="panel-body py-2 px-3">
@if ($errorcode)
<span class="label label-danger mr-2">{{ $errorcode }}</span>
@endif
@if ($errorDescription)
<span class="text-muted">{{ $errorDescription }}</span>
@endif
</div>
</div>
@endif
{{-- Aktionsbuttons --}}
@if (isset($checkout_url))
<a href="{{ $checkout_url }}" class="btn btn-primary btn-block btn-lg mb-2">
<i class="fa fa-refresh mr-1"></i>
{{ __('payment.payment_error_retry') }}
</a>
@endif
@if ($user_shop)
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}"
class="btn btn-default btn-block mb-3">
<i class="fa fa-arrow-left mr-1"></i> {{ __('payment.back_to_shop') }}
</a>
@endif
<p class="text-muted text-center small mb-0">
<i class="fa fa-envelope-o mr-1"></i>
{{ __('payment.contact_support_if_needed') }}
</p>
</div>
</div>
@endif @endif
</div> </div>
</div> </div>
<!-- /CHECKOUT ERROR MESSAGE -->
</div> </div>
</section> </section>
<!-- / -->
@endsection @endsection

View file

@ -210,8 +210,16 @@
@if (\Session::has('errormessage')) @if (\Session::has('errormessage'))
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<div class="alert alert-danger"> <div class="alert alert-danger" role="alert">
{{ \Session::get('customermessage') }} <h5 class="alert-heading mb-1">
<i class="fa fa-exclamation-circle mr-1"></i>
{{ __('payment.payment_error') }}
</h5>
@if(\Session::get('customermessage'))
<p class="mb-1">{{ \Session::get('customermessage') }}</p>
@endif
<hr class="my-2">
<p class="mb-0 small">{{ __('payment.payment_error_hint') }}</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -390,6 +390,21 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/admin/revenue', 'RevenueReportController@index')->name('admin_revenue'); Route::get('/admin/revenue', 'RevenueReportController@index')->name('admin_revenue');
Route::post('/admin/revenue/export', 'RevenueReportController@export')->name('admin_revenue_export'); Route::post('/admin/revenue/export', 'RevenueReportController@export')->name('admin_revenue_export');
// Payment Dashboard / Monitor
Route::prefix('/admin/payment-dashboard')->name('admin.payment-dashboard.')->group(function () {
Route::get('/', 'Admin\PaymentDashboardController@index')->name('index');
Route::get('/management', 'Admin\PaymentDashboardController@management')->name('management');
Route::get('/transactions', 'Admin\PaymentDashboardController@transactions')->name('transactions');
Route::get('/payments', 'Admin\PaymentDashboardController@payments')->name('payments');
Route::get('/abandoned', 'Admin\PaymentDashboardController@abandoned')->name('abandoned');
Route::get('/funnel', 'Admin\PaymentDashboardController@funnel')->name('funnel');
Route::get('/logs', 'Admin\PaymentDashboardController@logs')->name('logs');
Route::post('/', 'Admin\PaymentDashboardController@store')->name('store');
Route::get('/{incident}', 'Admin\PaymentDashboardController@show')->name('show');
Route::post('/{incident}/activity', 'Admin\PaymentDashboardController@addActivity')->name('activity.store');
Route::patch('/{incident}/status', 'Admin\PaymentDashboardController@updateStatus')->name('status.update');
});
// business // business
Route::get('/admin/business/show', 'BusinessController@show')->name('admin_business_show'); Route::get('/admin/business/show', 'BusinessController@show')->name('admin_business_show');
Route::get('/admin/business/structure', 'BusinessController@structure')->name('admin_business_structure'); Route::get('/admin/business/structure', 'BusinessController@structure')->name('admin_business_structure');

View file

@ -1,6 +1,5 @@
<?php <?php
use App\Http\Controllers\User\IncentiveController;
use App\Models\Incentive; use App\Models\Incentive;
use App\Models\IncentiveParticipant; use App\Models\IncentiveParticipant;
use App\User; use App\User;
@ -113,7 +112,7 @@ it('sortiert bei Punktgleichstand Teilnehmer mit Klarnamen (bestaetigte Teilnahm
expect($orderedUserIds)->toBe([$confirmed->user_id, $anonymous->user_id]); expect($orderedUserIds)->toBe([$confirmed->user_id, $anonymous->user_id]);
}); });
it('begrenzt die User-Rangliste auf 30 Plaetze (Gewinner-Zone bleibt max_winners)', function () { it('zeigt alle Teilnehmer mit Aktivitaet in der User-Rangliste (kein Limit)', function () {
$incentive = Incentive::factory()->create(['max_winners' => 20]); $incentive = Incentive::factory()->create(['max_winners' => 20]);
$makeUser = fn () => User::forceCreate([ $makeUser = fn () => User::forceCreate([
@ -130,13 +129,21 @@ it('begrenzt die User-Rangliste auf 30 Plaetze (Gewinner-Zone bleibt max_winners
]); ]);
} }
// Teilnehmer ohne Punkte soll nicht erscheinen
IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeUser()->id,
'total_points' => 0,
'qualified_partners' => 0,
'qualified_abos' => 0,
]);
$ranking = IncentiveParticipant::where('incentive_id', $incentive->id) $ranking = IncentiveParticipant::where('incentive_id', $incentive->id)
->withRankingActivity() ->withRankingActivity()
->orderByIncentiveLeaderboard() ->orderByIncentiveLeaderboard()
->limit(IncentiveController::USER_RANKING_DISPLAY_LIMIT)
->get(); ->get();
expect($ranking)->toHaveCount(30); expect($ranking)->toHaveCount(35);
}); });
it('blendet in der User-Ranglogik Teilnehmer ohne Partner, Abo und Punkte aus', function () { it('blendet in der User-Ranglogik Teilnehmer ohne Partner, Abo und Punkte aus', function () {

View file

@ -0,0 +1,112 @@
<?php
use App\Mail\PaymentIncidentAlert;
use App\Models\IncidentActivity;
use App\Models\PaymentIncident;
use App\Models\ProviderUptimeLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
// ─── Uptime-Command: Erfolgsfall ──────────────────────────────────────────────
it('schreibt einen positiven ProviderUptimeLog wenn PAYONE erreichbar ist', function () {
Http::fake([
'api.pay1.de/*' => Http::response('', 200),
]);
$this->artisan('payment:check-uptime')->assertSuccessful();
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
expect($log)->not->toBeNull();
expect($log->is_up)->toBeTrue();
expect($log->error_message)->toBeNull();
});
it('schreibt einen negativen ProviderUptimeLog und legt Incident an wenn PAYONE nicht erreichbar ist', function () {
Http::fake([
'api.pay1.de/*' => Http::response('Server Error', 503),
]);
$this->artisan('payment:check-uptime')->assertSuccessful();
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
expect($log)->not->toBeNull();
expect($log->is_up)->toBeFalse();
$incident = PaymentIncident::where('provider', 'payone')->where('type', 'outage')->first();
expect($incident)->not->toBeNull();
expect($incident->severity)->toBe('critical');
expect($incident->status)->toBe('open');
});
it('erstellt keine doppelten Outage-Incidents bei mehrfachem Ausfall in derselben Stunde', function () {
Http::fake([
'api.pay1.de/*' => Http::response('', 503),
]);
$this->artisan('payment:check-uptime');
$this->artisan('payment:check-uptime');
expect(PaymentIncident::where('provider', 'payone')->where('type', 'outage')->count())->toBe(1);
});
it('löst offene Outage-Incidents automatisch auf wenn PAYONE wieder erreichbar ist', function () {
PaymentIncident::create([
'title' => 'Automatischer Ausfall',
'provider' => 'payone',
'type' => 'outage',
'severity' => 'critical',
'status' => 'open',
'detected_at' => now()->subMinutes(10),
]);
Http::fake([
'api.pay1.de/*' => Http::response('', 200),
]);
$this->artisan('payment:check-uptime')->assertSuccessful();
$incident = PaymentIncident::where('provider', 'payone')->where('type', 'outage')->first();
expect($incident->fresh()->status)->toBe('resolved');
expect($incident->fresh()->resolved_at)->not->toBeNull();
$activity = IncidentActivity::where('incident_id', $incident->id)
->where('type', 'status_change')
->first();
expect($activity)->not->toBeNull();
});
it('verarbeitet Verbindungsfehler als Ausfall', function () {
Http::fake([
'api.pay1.de/*' => fn () => throw new \Exception('Connection refused'),
]);
$this->artisan('payment:check-uptime')->assertSuccessful();
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
expect($log->is_up)->toBeFalse();
expect($log->error_message)->toContain('Connection refused');
});
// ─── Mailable ────────────────────────────────────────────────────────────────
it('sendet PaymentIncidentAlert-Mail wenn critical Incident angelegt wird', function () {
Mail::fake();
$incident = PaymentIncident::create([
'title' => 'Kritischer Test-Incident',
'provider' => 'payone',
'type' => 'outage',
'severity' => 'critical',
'detected_at' => now(),
]);
Mail::to(config('app.exception_mail'))->queue(new PaymentIncidentAlert($incident));
Mail::assertQueued(PaymentIncidentAlert::class, function (PaymentIncidentAlert $mail) use ($incident) {
return $mail->incident->id === $incident->id;
});
});

View file

@ -0,0 +1,101 @@
<?php
use App\Models\CheckoutFunnelEvent;
use App\Services\CheckoutFunnelTracker;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('speichert ein checkout_visited Ereignis', function () {
CheckoutFunnelTracker::visitedCheckout(consultantUserId: 42, metadata: ['is_from' => 'shopping']);
expect(CheckoutFunnelEvent::count())->toBe(1);
$event = CheckoutFunnelEvent::first();
expect($event->event)->toBe('checkout_visited');
expect($event->consultant_user_id)->toBe(42);
expect($event->metadata)->toMatchArray(['is_from' => 'shopping']);
});
it('speichert ein form_submitted Ereignis', function () {
CheckoutFunnelTracker::submittedForm(
shoppingUserId: 1,
shoppingOrderId: 10,
consultantUserId: 5,
paymentMethod: 'elv',
amountCents: 4990,
);
$event = CheckoutFunnelEvent::first();
expect($event->event)->toBe('form_submitted');
expect($event->shopping_user_id)->toBe(1);
expect($event->shopping_order_id)->toBe(10);
expect($event->payment_method)->toBe('elv');
expect($event->amount_cents)->toBe(4990);
});
it('speichert ein payment_initiated Ereignis', function () {
CheckoutFunnelTracker::initiatedPayment(
shoppingUserId: 1,
shoppingOrderId: 10,
shoppingPaymentId: 20,
consultantUserId: 5,
paymentMethod: 'cc',
amountCents: 9900,
);
$event = CheckoutFunnelEvent::first();
expect($event->event)->toBe('payment_initiated');
expect($event->shopping_payment_id)->toBe(20);
expect($event->amount_cents)->toBe(9900);
});
it('speichert ein payment_returned Ereignis mit return_status', function () {
CheckoutFunnelTracker::returnedFromPayment(
shoppingPaymentId: 20,
returnStatus: 'cancel',
);
$event = CheckoutFunnelEvent::first();
expect($event->event)->toBe('payment_returned');
expect($event->return_status)->toBe('cancel');
expect($event->shopping_payment_id)->toBe(20);
});
it('speichert ein payment_confirmed Ereignis', function () {
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: 20,
txaction: 'paid',
);
$event = CheckoutFunnelEvent::first();
expect($event->event)->toBe('payment_confirmed');
expect($event->metadata)->toMatchArray(['txaction' => 'paid']);
});
it('schluckt Exceptions und loggt nur eine Warnung', function () {
// Ungültiges event-Enum — soll keinen Fehler werfen
expect(fn () => CheckoutFunnelTracker::visitedCheckout())->not->toThrow(\Throwable::class);
});
it('Funnel-View gibt korrekten View zurück', function () {
$admin = \App\User::forceCreate([
'email' => 'admin-funnel-'.uniqid().'@test.com',
'password' => \Illuminate\Support\Facades\Hash::make('secret'),
'admin' => 2,
'lang' => 'de',
]);
$this->actingAs($admin);
CheckoutFunnelTracker::visitedCheckout();
CheckoutFunnelTracker::submittedForm(1, 1, null, 'elv', 3990);
$controller = new \App\Http\Controllers\Admin\PaymentDashboardController;
$response = $controller->funnel();
expect($response->getName())->toBe('admin.payment-dashboard.funnel');
$data = $response->getData();
expect($data)->toHaveKey('funnelSteps');
expect($data['funnelSteps'][0]['count'])->toBe(1);
expect($data['funnelSteps'][1]['count'])->toBe(1);
});

View file

@ -0,0 +1,183 @@
<?php
use App\Http\Controllers\Admin\PaymentDashboardController;
use App\Http\Middleware\Admin;
use App\Models\PaymentIncident;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
function makeAdminUser(): User
{
return User::forceCreate([
'email' => 'admin-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 2,
'lang' => 'de',
]);
}
function makeRegularUser(): User
{
return User::forceCreate([
'email' => 'user-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 0,
'lang' => 'de',
]);
}
function makeVipUser(): User
{
return User::forceCreate([
'email' => 'vip-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 1,
'lang' => 'de',
]);
}
// ─── Admin Middleware Tests ───────────────────────────────────────────────────
it('Admin-Middleware lässt Admins (admin >= 2) durch', function () {
$admin = makeAdminUser();
Auth::setUser($admin);
$request = Request::create('/admin/payment-dashboard');
$middleware = new Admin;
$passed = false;
$middleware->handle($request, function () use (&$passed) {
$passed = true;
});
expect($passed)->toBeTrue();
});
it('Admin-Middleware blockiert normale Benutzer (admin = 0)', function () {
$user = makeRegularUser();
$request = Request::create('/admin/payment-dashboard');
$request->setUserResolver(fn () => $user);
$middleware = new Admin;
$response = $middleware->handle($request, fn () => null);
expect($response)->not->toBeNull();
expect($response->getStatusCode())->toBe(302);
});
it('Admin-Middleware blockiert VIP-Benutzer (admin = 1)', function () {
$vip = makeVipUser();
$request = Request::create('/admin/payment-dashboard');
$request->setUserResolver(fn () => $vip);
$middleware = new Admin;
$response = $middleware->handle($request, fn () => null);
expect($response)->not->toBeNull();
expect($response->getStatusCode())->toBe(302);
});
// ─── Controller Auth Tests ────────────────────────────────────────────────────
it('Entwickler-Ansicht gibt View zurück für Admins', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
$response = $controller->index();
expect($response->getName())->toBe('admin.payment-dashboard.index');
});
it('GF-Ansicht gibt View zurück für Super-Admins (admin >= 3)', function () {
$superAdmin = User::forceCreate([
'email' => 'superadmin-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 3,
'lang' => 'de',
]);
$this->actingAs($superAdmin);
$controller = new PaymentDashboardController;
$response = $controller->management();
expect($response->getName())->toBe('admin.payment-dashboard.management');
});
it('GF-Ansicht liefert 403 für normale Admins (admin = 2)', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
expect(fn () => $controller->management())->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class);
});
it('Incident-Detail gibt korrekten View zurück', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$incident = PaymentIncident::create([
'title' => 'Test Incident Detail',
'provider' => 'payone',
'type' => 'payment_failure',
'severity' => 'high',
'detected_at' => now(),
]);
$controller = new PaymentDashboardController;
$response = $controller->show($incident);
expect($response->getName())->toBe('admin.payment-dashboard.show');
expect($response->getData()['incident']->id)->toBe($incident->id);
});
it('Log-Ansicht gibt View zurück', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
$response = $controller->logs();
expect($response->getName())->toBe('admin.payment-dashboard.logs');
});
it('Transaktions-Ansicht gibt View zurück', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
$response = $controller->transactions();
expect($response->getName())->toBe('admin.payment-dashboard.transactions');
});
it('Abbruch-Analyse gibt View zurück', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
$response = $controller->abandoned();
expect($response->getName())->toBe('admin.payment-dashboard.abandoned');
});
it('Abbruch-Analyse enthält die 3 erwarteten Datensätze', function () {
$admin = makeAdminUser();
$this->actingAs($admin);
$controller = new PaymentDashboardController;
$response = $controller->abandoned();
$data = $response->getData();
expect($data)->toHaveKey('ordersWithoutPayment');
expect($data)->toHaveKey('cancelledPayments');
expect($data)->toHaveKey('pendingPayments');
expect($data)->toHaveKey('abandonedStats');
expect($data['abandonedStats'])->toHaveKeys(['no_payment', 'cancelled', 'no_callback']);
});

View file

@ -0,0 +1,298 @@
<?php
use App\Http\Controllers\Admin\PaymentDashboardController;
use App\Http\Requests\PaymentIncident\AddIncidentActivityRequest;
use App\Http\Requests\PaymentIncident\StorePaymentIncidentRequest;
use App\Http\Requests\PaymentIncident\UpdateIncidentStatusRequest;
use App\Models\IncidentActivity;
use App\Models\PaymentIncident;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
uses(RefreshDatabase::class);
function makeAdmin(): User
{
return User::forceCreate([
'email' => 'admin-crud-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 2,
'lang' => 'de',
]);
}
// ─── Model / Scopes ───────────────────────────────────────────────────────────
it('scopeOpen gibt nur offene, laufende und wartende Incidents zurück', function () {
PaymentIncident::create(['title' => 'Offen', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'open', 'detected_at' => now()]);
PaymentIncident::create(['title' => 'In Bearbeitung', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'in_progress', 'detected_at' => now()]);
PaymentIncident::create(['title' => 'Wartet', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'waiting_provider', 'detected_at' => now()]);
PaymentIncident::create(['title' => 'Gelöst', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'resolved', 'detected_at' => now()]);
$open = PaymentIncident::open()->get();
expect($open)->toHaveCount(3);
expect($open->pluck('title')->toArray())->not->toContain('Gelöst');
});
it('scopePayone filtert nach PAYONE', function () {
PaymentIncident::create(['title' => 'PAYONE Incident', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
PaymentIncident::create(['title' => 'Stripe Incident', 'provider' => 'stripe', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
expect(PaymentIncident::payone()->count())->toBe(1);
expect(PaymentIncident::payone()->first()->title)->toBe('PAYONE Incident');
});
it('scopeLastDays filtert nach Zeitraum', function () {
PaymentIncident::create(['title' => 'Alt', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()->subDays(10)]);
PaymentIncident::create(['title' => 'Neu', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
expect(PaymentIncident::lastDays(7)->count())->toBe(1);
expect(PaymentIncident::lastDays(7)->first()->title)->toBe('Neu');
});
it('Accessor severity_color gibt Bootstrap-Klasse zurück', function () {
$incident = new PaymentIncident(['severity' => 'critical']);
expect($incident->severity_color)->toBe('danger');
$incident = new PaymentIncident(['severity' => 'high']);
expect($incident->severity_color)->toBe('warning');
$incident = new PaymentIncident(['severity' => 'low']);
expect($incident->severity_color)->toBe('success');
});
it('Accessor duration berechnet Dauer korrekt', function () {
$incident = new PaymentIncident([
'detected_at' => now()->subMinutes(90),
'resolved_at' => null,
]);
expect($incident->duration)->toContain('h');
});
// ─── Controller: Incident anlegen ─────────────────────────────────────────────
it('legt einen neuen Incident an und erstellt automatisch eine erste Aktivität', function () {
$admin = makeAdmin();
$this->actingAs($admin);
$request = StorePaymentIncidentRequest::create('/admin/payment-dashboard', 'POST', [
'title' => 'PAYONE IPN ausgefallen',
'provider' => 'payone',
'type' => 'ipn_error',
'severity' => 'high',
'detected_at' => now()->format('Y-m-d H:i:s'),
'affected_orders' => 5,
'affected_revenue' => '450.00',
]);
$request->setContainer(app());
$request->validateResolved();
try {
(new PaymentDashboardController)->store($request);
} catch (\Symfony\Component\Routing\Exception\RouteNotFoundException $e) {
// Domain-spezifische Routen sind im Test-Kontext nicht registriert erwartet.
}
$incident = PaymentIncident::where('title', 'PAYONE IPN ausgefallen')->first();
expect($incident)->not->toBeNull();
expect($incident->provider)->toBe('payone');
expect($incident->status)->toBe('open');
expect($incident->affected_orders)->toBe(5);
expect(IncidentActivity::where('incident_id', $incident->id)->count())->toBe(1);
expect(IncidentActivity::where('incident_id', $incident->id)->first()->type)->toBe('note');
});
// ─── FormRequest Validierung ──────────────────────────────────────────────────
it('validiert Pflichtfelder beim Anlegen eines Incidents', function () {
$rules = (new StorePaymentIncidentRequest)->rules();
$validator = Validator::make([], $rules);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('title'))->toBeTrue();
expect($validator->errors()->has('provider'))->toBeTrue();
expect($validator->errors()->has('type'))->toBeTrue();
expect($validator->errors()->has('severity'))->toBeTrue();
expect($validator->errors()->has('detected_at'))->toBeTrue();
});
it('validiert ungültige Enum-Werte in StorePaymentIncidentRequest', function () {
$rules = (new StorePaymentIncidentRequest)->rules();
$validator = Validator::make([
'title' => 'Test',
'provider' => 'bitcoin',
'type' => 'unknown',
'severity' => 'extreme',
'detected_at' => now()->toDateTimeString(),
], $rules);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('provider'))->toBeTrue();
expect($validator->errors()->has('type'))->toBeTrue();
expect($validator->errors()->has('severity'))->toBeTrue();
});
it('validiert Pflichtfelder in AddIncidentActivityRequest', function () {
$rules = (new AddIncidentActivityRequest)->rules();
$validator = Validator::make([], $rules);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('type'))->toBeTrue();
expect($validator->errors()->has('title'))->toBeTrue();
});
// ─── Controller: Status ändern ────────────────────────────────────────────────
it('ändert den Status eines Incidents und erstellt eine Aktivität', function () {
$admin = makeAdmin();
$this->actingAs($admin);
$incident = PaymentIncident::create([
'title' => 'Status-Test',
'provider' => 'payone',
'type' => 'outage',
'severity' => 'critical',
'detected_at' => now(),
]);
$request = UpdateIncidentStatusRequest::create('/admin/payment-dashboard/'.$incident->id.'/status', 'PATCH', [
'status' => 'resolved',
]);
$request->setContainer(app());
$request->validateResolved();
$controller = new PaymentDashboardController;
$controller->updateStatus($request, $incident);
$incident->refresh();
expect($incident->status)->toBe('resolved');
expect($incident->resolved_at)->not->toBeNull();
$statusActivity = IncidentActivity::where('incident_id', $incident->id)
->where('type', 'status_change')
->first();
expect($statusActivity)->not->toBeNull();
expect($statusActivity->title)->toContain('Gelöst');
});
it('setzt resolved_at nicht wenn Status in_progress ist', function () {
$admin = makeAdmin();
$this->actingAs($admin);
$incident = PaymentIncident::create([
'title' => 'Resolved At Test',
'provider' => 'payone',
'type' => 'other',
'severity' => 'low',
'detected_at' => now(),
]);
$request = UpdateIncidentStatusRequest::create('', 'PATCH', ['status' => 'in_progress']);
$request->setContainer(app());
$request->validateResolved();
(new PaymentDashboardController)->updateStatus($request, $incident);
expect($incident->fresh()->resolved_at)->toBeNull();
});
// ─── Controller: Aktivität hinzufügen ────────────────────────────────────────
it('fügt eine Aktivität zu einem Incident hinzu', function () {
$admin = makeAdmin();
$this->actingAs($admin);
$incident = PaymentIncident::create([
'title' => 'Aktivitäts-Test',
'provider' => 'payone',
'type' => 'payment_failure',
'severity' => 'medium',
'detected_at' => now(),
]);
$request = AddIncidentActivityRequest::create('', 'POST', [
'type' => 'email',
'title' => 'Mail an PAYONE gesendet',
'content' => 'Ticket eröffnet, Antwort abwarten.',
]);
$request->setContainer(app());
$request->validateResolved();
(new PaymentDashboardController)->addActivity($request, $incident);
$activity = IncidentActivity::where('incident_id', $incident->id)
->where('type', 'email')
->first();
expect($activity)->not->toBeNull();
expect($activity->title)->toBe('Mail an PAYONE gesendet');
expect($activity->author)->toBe($admin->name ?? 'System');
});
it('setzt Incident automatisch auf in_progress wenn Aktivität bei offenem Incident hinzugefügt wird', function () {
$admin = makeAdmin();
$this->actingAs($admin);
$incident = PaymentIncident::create([
'title' => 'Auto-Status-Test',
'provider' => 'payone',
'type' => 'other',
'severity' => 'low',
'status' => 'open',
'detected_at' => now(),
]);
$request = AddIncidentActivityRequest::create('', 'POST', ['type' => 'note', 'title' => 'Erste Notiz']);
$request->setContainer(app());
$request->validateResolved();
(new PaymentDashboardController)->addActivity($request, $incident);
expect($incident->fresh()->status)->toBe('in_progress');
});
it('validiert Pflichtfelder in UpdateIncidentStatusRequest', function () {
$rules = (new UpdateIncidentStatusRequest)->rules();
$validator = Validator::make([], $rules);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('status'))->toBeTrue();
});
it('validiert ungültigen Status in UpdateIncidentStatusRequest', function () {
$rules = (new UpdateIncidentStatusRequest)->rules();
$validator = Validator::make(['status' => 'deleted'], $rules);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('status'))->toBeTrue();
});
// ─── Cache Invalidierung ──────────────────────────────────────────────────────
it('invalidiert den Cache nach Incident-Anlage', function () {
$admin = makeAdmin();
$this->actingAs($admin);
Cache::put('open_incident_count', 99, 60);
$request = StorePaymentIncidentRequest::create('', 'POST', [
'title' => 'Cache-Test',
'provider' => 'payone',
'type' => 'other',
'severity' => 'low',
'detected_at' => now()->format('Y-m-d H:i:s'),
]);
$request->setContainer(app());
$request->validateResolved();
try {
(new PaymentDashboardController)->store($request);
} catch (\Symfony\Component\Routing\Exception\RouteNotFoundException $e) {
// Domain-spezifische Routen sind im Test-Kontext nicht registriert erwartet.
}
expect(Cache::has('open_incident_count'))->toBeFalse();
});

View file

@ -0,0 +1,169 @@
<?php
/**
* Wizard-Payment: Da Radio Buttons nur eine Produktauswahl erlauben,
* darf nach dem Absenden des Formulars immer nur exakt ein Produkt
* im Warenkorb liegen auch wenn der Nutzer per Browser-Zurück-Button
* mehrfach einreicht.
*/
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Schema::connection('sqlite')->table('products', function ($table) {
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
$table->string('slug')->nullable();
}
if (! Schema::connection('sqlite')->hasColumn('products', 'free_shipping_consultant')) {
$table->boolean('free_shipping_consultant')->default(false)->nullable();
}
});
});
function makeWizardProduct(string $name, float $price = 69.90, int $pos = 1): Product
{
return Product::forceCreate([
'name' => $name,
'title' => $name,
'price' => $price,
'tax' => 19.00,
'active' => true,
'is_membership_only' => false,
'pos' => $pos,
'show_at' => 3,
'show_on' => '["7","8"]',
'identifier' => 'show_order',
'action' => '["0","1"]',
'no_commission' => false,
'no_free_shipping' => false,
'free_shipping_consultant' => false,
'weight' => 0,
'points' => 0,
'slug' => \Illuminate\Support\Str::slug($name).'-'.uniqid(),
]);
}
it('entfernt vorhandene Produkte aus dem Wizard-Warenkorb wenn ein neues ausgewaehlt wird', function () {
$productA = makeWizardProduct('Starter-Paket A', 49.90, 1);
$productB = makeWizardProduct('Starter-Paket B', 99.90, 2);
$instance = 'shopping';
\Yard::instance($instance)->destroy();
// Produkt A ist bereits im Warenkorb (simuliert den Zustand nach erstem Absenden)
\Yard::instance($instance)->add(
$productA->id,
$productA->name,
1,
$productA->price,
false,
false,
[
'slug' => $productA->slug,
'weight' => 0,
'points' => 0,
'no_commission' => false,
'no_free_shipping' => false,
'free_shipping_consultant' => false,
'show_on' => $productA->show_on,
]
);
expect(\Yard::instance($instance)->count())->toBe(1);
// Nutzer geht zurück und wählt Produkt B — die neue Logik soll Produkt A entfernen
$cartItemB = \Yard::instance($instance)->add(
$productB->id,
$productB->name,
1,
$productB->price,
false,
false,
[
'slug' => $productB->slug,
'weight' => 0,
'points' => 0,
'no_commission' => false,
'no_free_shipping' => false,
'free_shipping_consultant' => false,
'show_on' => $productB->show_on,
]
);
// Schutzlogik aus WizardController::storePayment()
foreach (\Yard::instance($instance)->content() as $existingItem) {
if ($existingItem->rowId !== $cartItemB->rowId) {
\Yard::instance($instance)->remove($existingItem->rowId);
}
}
expect(\Yard::instance($instance)->count())->toBe(1);
expect(\Yard::instance($instance)->content()->first()->id)->toBe($productB->id);
\Yard::instance($instance)->destroy();
});
it('behaelt das Produkt bei qty=1 wenn dasselbe Produkt erneut abgesendet wird', function () {
$product = makeWizardProduct('Starter-Paket A', 49.90, 1);
$instance = 'shopping';
\Yard::instance($instance)->destroy();
\Yard::instance($instance)->add(
$product->id,
$product->name,
1,
$product->price,
false,
false,
[
'slug' => $product->slug,
'weight' => 0,
'points' => 0,
'no_commission' => false,
'no_free_shipping' => false,
'free_shipping_consultant' => false,
'show_on' => $product->show_on,
]
);
// Zweites Absenden desselben Produkts
$cartItemAgain = \Yard::instance($instance)->add(
$product->id,
$product->name,
1,
$product->price,
false,
false,
[
'slug' => $product->slug,
'weight' => 0,
'points' => 0,
'no_commission' => false,
'no_free_shipping' => false,
'free_shipping_consultant' => false,
'show_on' => $product->show_on,
]
);
if ($cartItemAgain->qty > 1) {
\Yard::instance($instance)->update($cartItemAgain->rowId, 1);
}
foreach (\Yard::instance($instance)->content() as $existingItem) {
if ($existingItem->rowId !== $cartItemAgain->rowId) {
\Yard::instance($instance)->remove($existingItem->rowId);
}
}
expect(\Yard::instance($instance)->count())->toBe(1);
expect(\Yard::instance($instance)->get($cartItemAgain->rowId)->qty)->toBe(1);
\Yard::instance($instance)->destroy();
});

View file

@ -3,3 +3,4 @@
uses(Tests\TestCase::class)->in('Feature/Incentive'); uses(Tests\TestCase::class)->in('Feature/Incentive');
uses(Tests\TestCase::class)->in('Feature/Sys'); uses(Tests\TestCase::class)->in('Feature/Sys');
uses(Tests\TestCase::class)->in('Unit/Incentive'); uses(Tests\TestCase::class)->in('Unit/Incentive');
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');