14-04-2026
This commit is contained in:
parent
f58c709945
commit
0f82fea88a
72 changed files with 7414 additions and 148 deletions
125
app/Console/Commands/CheckPaymentUptime.php
Normal file
125
app/Console/Commands/CheckPaymentUptime.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Console;
|
|||
use App\Console\Commands\BusinessStore;
|
||||
use App\Console\Commands\BusinessStoreOptimized;
|
||||
use App\Console\Commands\CheckPaymentsAccount;
|
||||
use App\Console\Commands\CheckPaymentUptime;
|
||||
use App\Console\Commands\DhlUpdateTracking;
|
||||
use App\Console\Commands\UserCleanup;
|
||||
use App\Console\Commands\UserMakeAboOrder;
|
||||
|
|
@ -21,6 +22,7 @@ class Kernel extends ConsoleKernel
|
|||
protected $commands = [
|
||||
BusinessStore::class,
|
||||
BusinessStoreOptimized::class,
|
||||
CheckPaymentUptime::class,
|
||||
CheckPaymentsAccount::class,
|
||||
UserMakeAboOrder::class,
|
||||
UserCleanup::class,
|
||||
|
|
@ -34,6 +36,12 @@ class Kernel extends ConsoleKernel
|
|||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// Uptime-Check: PAYONE-Erreichbarkeit alle 5 Minuten prüfen
|
||||
$schedule->command('payment:check-uptime')
|
||||
->everyFiveMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Job 1: Überprüft täglich um 02:00 Uhr die Zahlungskonten.
|
||||
$schedule->command('payments:check-accounts')->dailyAt('02:00');
|
||||
// Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript.
|
||||
|
|
|
|||
568
app/Http/Controllers/Admin/PaymentDashboardController.php
Normal file
568
app/Http/Controllers/Admin/PaymentDashboardController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment;
|
||||
use App\Services\ShoppingUserService;
|
||||
|
|
@ -152,6 +153,9 @@ class PayoneController extends Controller
|
|||
'txaction' => $data['txaction'],
|
||||
'transmitted_data' => Util::utf8ize($data),
|
||||
'mode' => $data['mode'],
|
||||
'errorcode' => $data['errorcode'] ?? null,
|
||||
'errormessage' => $data['errormessage'] ?? null,
|
||||
'customermessage' => $data['customermessage'] ?? null,
|
||||
]);
|
||||
|
||||
// Define txaction priority (higher number = higher priority)
|
||||
|
|
@ -219,6 +223,20 @@ class PayoneController extends Controller
|
|||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'paid',
|
||||
metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
if ($data['txaction'] === 'appointed') {
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'appointed',
|
||||
metadata: ['mode' => $data['mode'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
$data['send_link'] = $send_link;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ namespace App\Http\Controllers\Pay;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payone;
|
||||
use Util;
|
||||
|
|
@ -189,6 +190,15 @@ class PayoneController extends Controller
|
|||
'mode' => $this->shopping_order->mode,
|
||||
]);
|
||||
|
||||
CheckoutFunnelTracker::initiatedPayment(
|
||||
shoppingUserId: $this->shopping_user->id,
|
||||
shoppingOrderId: $this->shopping_order->id,
|
||||
shoppingPaymentId: $this->shopping_payment->id,
|
||||
consultantUserId: $this->shopping_user->auth_user_id ?? null,
|
||||
paymentMethod: $payment_method,
|
||||
amountCents: $amount,
|
||||
);
|
||||
|
||||
$this->default['mode'] = $this->shopping_order->mode;
|
||||
|
||||
return $this->reference;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Auth;
|
||||
use Request;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Models\UserCredit;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Exports\UserTeamExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserCredit;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Services\HTMLHelper;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Request;
|
||||
|
||||
class RevenueReportController extends Controller
|
||||
{
|
||||
|
|
@ -22,42 +20,42 @@ class RevenueReportController extends Controller
|
|||
public function index()
|
||||
{
|
||||
$this->setFilterVars();
|
||||
|
||||
|
||||
$data = [
|
||||
'filter_months' => HTMLHelper::getTransMonths(),
|
||||
'filter_years' => HTMLHelper::getYearRange(2022),
|
||||
'revenue_summary' => $this->getRevenueSummary(),
|
||||
'credit_summary' => $this->getCreditSummary()
|
||||
'credit_summary' => $this->getCreditSummary(),
|
||||
];
|
||||
|
||||
|
||||
return view('admin.revenue.index', $data);
|
||||
}
|
||||
|
||||
public function export()
|
||||
{
|
||||
$this->setFilterVars();
|
||||
|
||||
|
||||
$filter_year = session('revenue_filter_year');
|
||||
|
||||
|
||||
// Get data like in the HTML view
|
||||
$revenue_summary = $this->getRevenueSummary();
|
||||
$credit_summary = $this->getCreditSummary();
|
||||
|
||||
|
||||
$filename = "umsatz-gutschrift-bericht-{$filter_year}";
|
||||
|
||||
|
||||
$columns = [];
|
||||
|
||||
|
||||
// Umsätze Section Header
|
||||
$columns[] = ['Typ' => 'UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
|
||||
// Yearly Revenue Summary
|
||||
if(isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) {
|
||||
foreach($revenue_summary['yearly'] as $item) {
|
||||
if (isset($revenue_summary['yearly']) && $revenue_summary['yearly']->count() > 0) {
|
||||
foreach ($revenue_summary['yearly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->period_label,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
@ -65,22 +63,22 @@ class RevenueReportController extends Controller
|
|||
'Typ' => "Jahr {$filter_year}",
|
||||
'Netto' => '0,00',
|
||||
'Steuer' => '0,00',
|
||||
'Brutto' => '0,00'
|
||||
'Brutto' => '0,00',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Empty row
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
|
||||
// Monthly Revenue Breakdown
|
||||
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if(isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) {
|
||||
foreach($revenue_summary['monthly'] as $item) {
|
||||
if (isset($revenue_summary['monthly']) && $revenue_summary['monthly']->count() > 0) {
|
||||
foreach ($revenue_summary['monthly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->period_label,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
@ -88,25 +86,25 @@ class RevenueReportController extends Controller
|
|||
'Typ' => 'Keine monatlichen Umsätze gefunden',
|
||||
'Netto' => '',
|
||||
'Steuer' => '',
|
||||
'Brutto' => ''
|
||||
'Brutto' => '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Two empty rows for separation
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
|
||||
// Gutschriften Section Header
|
||||
$columns[] = ['Typ' => 'GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
|
||||
// Yearly Credit Summary
|
||||
if(isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) {
|
||||
foreach($credit_summary['yearly'] as $item) {
|
||||
if (isset($credit_summary['yearly']) && $credit_summary['yearly']->count() > 0) {
|
||||
foreach ($credit_summary['yearly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->period_label,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
@ -114,22 +112,22 @@ class RevenueReportController extends Controller
|
|||
'Typ' => "Jahr {$filter_year}",
|
||||
'Netto' => '0,00',
|
||||
'Steuer' => '0,00',
|
||||
'Brutto' => '0,00'
|
||||
'Brutto' => '0,00',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Empty row
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
|
||||
// Monthly Credit Breakdown
|
||||
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if(isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) {
|
||||
foreach($credit_summary['monthly'] as $item) {
|
||||
if (isset($credit_summary['monthly']) && $credit_summary['monthly']->count() > 0) {
|
||||
foreach ($credit_summary['monthly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->period_label,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
@ -137,24 +135,106 @@ class RevenueReportController extends Controller
|
|||
'Typ' => 'Keine monatlichen Gutschriften gefunden',
|
||||
'Netto' => '',
|
||||
'Steuer' => '',
|
||||
'Brutto' => ''
|
||||
'Brutto' => '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Two empty rows for separation
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
// Umsätze nach Ländern - Jährlich
|
||||
$columns[] = ['Typ' => 'UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if (isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0) {
|
||||
foreach ($revenue_summary['country_yearly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->country_name,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$columns[] = ['Typ' => 'Keine Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
}
|
||||
|
||||
// Empty row
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
// Umsätze nach Ländern - Monatlich
|
||||
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if (isset($revenue_summary['country_monthly']) && $revenue_summary['country_monthly']->count() > 0) {
|
||||
$revenueByMonth = $revenue_summary['country_monthly']->groupBy('month');
|
||||
foreach ($revenueByMonth as $countries) {
|
||||
$columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
foreach ($countries as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => ' '.$item->country_name,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$columns[] = ['Typ' => 'Keine monatlichen Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
}
|
||||
|
||||
// Two empty rows for separation
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
// Gutschriften nach Ländern - Jährlich
|
||||
$columns[] = ['Typ' => 'GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if (isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0) {
|
||||
foreach ($credit_summary['country_yearly'] as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => $item->country_name,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$columns[] = ['Typ' => 'Keine Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
}
|
||||
|
||||
// Empty row
|
||||
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
|
||||
// Gutschriften nach Ländern - Monatlich
|
||||
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
if (isset($credit_summary['country_monthly']) && $credit_summary['country_monthly']->count() > 0) {
|
||||
$creditByMonth = $credit_summary['country_monthly']->groupBy('month');
|
||||
foreach ($creditByMonth as $countries) {
|
||||
$columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
foreach ($countries as $item) {
|
||||
$columns[] = [
|
||||
'Typ' => ' '.$item->country_name,
|
||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$columns[] = ['Typ' => 'Keine monatlichen Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||
}
|
||||
|
||||
$headers = ['Zeitraum', 'Netto (€)', 'Steuer (€)', 'Brutto (€)'];
|
||||
|
||||
return Excel::download(new UserTeamExport($columns, $headers), $filename . '.xlsx');
|
||||
|
||||
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xlsx');
|
||||
}
|
||||
|
||||
private function setFilterVars()
|
||||
{
|
||||
if (!session('revenue_filter_month')) {
|
||||
if (! session('revenue_filter_month')) {
|
||||
session(['revenue_filter_month' => intval(date('m'))]);
|
||||
}
|
||||
if (!session('revenue_filter_year')) {
|
||||
if (! session('revenue_filter_year')) {
|
||||
session(['revenue_filter_year' => intval(date('Y'))]);
|
||||
}
|
||||
if(!session('revenue_filter_type')){
|
||||
if (! session('revenue_filter_type')) {
|
||||
session(['revenue_filter_type' => 'year']);
|
||||
}
|
||||
if (Request::get('revenue_filter_month')) {
|
||||
|
|
@ -171,20 +251,24 @@ class RevenueReportController extends Controller
|
|||
private function getRevenueSummary()
|
||||
{
|
||||
$year = session('revenue_filter_year');
|
||||
|
||||
|
||||
return [
|
||||
'yearly' => $this->getRevenueByYear($year),
|
||||
'monthly' => $this->getRevenueByMonthsInYear($year)
|
||||
'monthly' => $this->getRevenueByMonthsInYear($year),
|
||||
'country_yearly' => $this->getRevenueByCountryYear($year),
|
||||
'country_monthly' => $this->getRevenueByCountryMonthly($year),
|
||||
];
|
||||
}
|
||||
|
||||
private function getCreditSummary()
|
||||
{
|
||||
$year = session('revenue_filter_year');
|
||||
|
||||
|
||||
return [
|
||||
'yearly' => $this->getCreditByYear($year),
|
||||
'monthly' => $this->getCreditByMonthsInYear($year)
|
||||
'monthly' => $this->getCreditByMonthsInYear($year),
|
||||
'country_yearly' => $this->getCreditByCountryYear($year),
|
||||
'country_monthly' => $this->getCreditByCountryMonthly($year),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -340,4 +424,116 @@ class RevenueReportController extends Controller
|
|||
->orderBy('month')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,7 @@ class IncentiveController extends Controller
|
|||
->withRankingActivity()
|
||||
->with('user', 'user.account')
|
||||
->orderByIncentiveLeaderboard()
|
||||
->limit(self::USER_RANKING_DISPLAY_LIMIT)
|
||||
->get();
|
||||
->paginate(100);
|
||||
|
||||
$participateHasTrackableAbos = false;
|
||||
if (! $participant?->accepted_terms_at) {
|
||||
|
|
@ -74,8 +73,8 @@ class IncentiveController extends Controller
|
|||
'participant' => $participant,
|
||||
'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null,
|
||||
'ranking' => $ranking,
|
||||
'rankingDisplayLimit' => self::USER_RANKING_DISPLAY_LIMIT,
|
||||
'participateHasTrackableAbos' => $participateHasTrackableAbos,
|
||||
'isVipView' => $user->isVIP(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +159,12 @@ class IncentiveController extends Controller
|
|||
return [];
|
||||
}
|
||||
|
||||
$files = glob($dir . '/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
|
||||
$files = glob($dir.'/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
|
||||
|
||||
$images = [];
|
||||
foreach ($files as $file) {
|
||||
$basename = basename($file);
|
||||
$images[] = 'img/incentive/' . $basename;
|
||||
$images[] = 'img/incentive/'.$basename;
|
||||
}
|
||||
sort($images);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Models\ShoppingPayment;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CheckoutRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Shop;
|
||||
|
|
@ -98,6 +99,11 @@ class CheckoutController extends Controller
|
|||
'yard_instance' => $this->instance,
|
||||
];
|
||||
|
||||
CheckoutFunnelTracker::visitedCheckout(
|
||||
consultantUserId: Util::getUserShop()?->id ?? null,
|
||||
metadata: ['is_from' => $is_from, 'is_for' => $is_for, 'is_abo' => $is_abo],
|
||||
);
|
||||
|
||||
return view('web.templates.checkout', $data);
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +174,14 @@ class CheckoutController extends Controller
|
|||
|
||||
Util::setUserHistoryValue(['status' => 2, 'shopping_order_id' => $shopping_order->id]);
|
||||
|
||||
CheckoutFunnelTracker::submittedForm(
|
||||
shoppingUserId: $shopping_user->id,
|
||||
shoppingOrderId: $shopping_order->id,
|
||||
consultantUserId: $shopping_user->auth_user_id ?? null,
|
||||
paymentMethod: Request::get('payment_method'),
|
||||
amountCents: (int) (Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100),
|
||||
);
|
||||
|
||||
// Zahlungsmethode verarbeiten
|
||||
if (Request::get('payment_method')) {
|
||||
return $this->processPaymentMethod($data, $shopping_user, $shopping_order);
|
||||
|
|
@ -400,6 +414,11 @@ class CheckoutController extends Controller
|
|||
$ShoppingPayment->status = $status;
|
||||
$ShoppingPayment->save();
|
||||
|
||||
CheckoutFunnelTracker::returnedFromPayment(
|
||||
shoppingPaymentId: $ShoppingPayment->id,
|
||||
returnStatus: $status,
|
||||
);
|
||||
|
||||
if ($status === 'success') {
|
||||
return $this->handleSuccessfulTransaction($ShoppingPayment, $reference);
|
||||
}
|
||||
|
|
@ -410,7 +429,9 @@ class CheckoutController extends Controller
|
|||
|
||||
return $this->showTransactionError(
|
||||
__('payment.payment_canceled'),
|
||||
__('payment.payment_canceled_description')
|
||||
__('payment.payment_canceled_description'),
|
||||
'cancel',
|
||||
['checkout_url' => route('checkout.checkout_card')],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -418,9 +439,29 @@ class CheckoutController extends Controller
|
|||
Util::setUserHistoryValue(['status' => 23]);
|
||||
Util::setInstanceStatusByPayment($ShoppingPayment, 5); // link_failed
|
||||
|
||||
$latestTransaction = $ShoppingPayment->payment_transactions()
|
||||
->whereNotNull('errorcode')
|
||||
->orWhere(fn ($q) => $q->whereNotNull('transmitted_data'))
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$errorcode = null;
|
||||
$errorDescription = null;
|
||||
if ($latestTransaction) {
|
||||
$errorcode = $latestTransaction->errorcode
|
||||
?? ($latestTransaction->transmitted_data['errorcode'] ?? null);
|
||||
$errorDescription = $latestTransaction->error_description;
|
||||
}
|
||||
|
||||
return $this->showTransactionError(
|
||||
__('payment.payment_error'),
|
||||
__('payment.payment_error_description')
|
||||
__('payment.payment_error_description'),
|
||||
'error',
|
||||
[
|
||||
'errorcode' => $errorcode,
|
||||
'error_description' => $errorDescription,
|
||||
'checkout_url' => route('checkout.checkout_card'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -434,19 +475,21 @@ class CheckoutController extends Controller
|
|||
/**
|
||||
* Zeigt eine Transaktionsfehlerseite an
|
||||
*
|
||||
* @param string $title
|
||||
* @param string $message
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
private function showTransactionError($title, $message)
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function showTransactionError(string $title, string $message, string $type = 'error', array $extra = [])
|
||||
{
|
||||
$data = [
|
||||
$data = array_merge([
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'is_checkout' => true,
|
||||
'yard_instance' => $this->instance,
|
||||
'error_title' => $title,
|
||||
'error_message' => $message,
|
||||
];
|
||||
'error_type' => $type,
|
||||
], $extra);
|
||||
|
||||
return view('web.templates.checkout-error', $data);
|
||||
}
|
||||
|
|
@ -494,6 +537,12 @@ class CheckoutController extends Controller
|
|||
if ($payt->shopping_payment->reference != $reference) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $payt->shopping_payment_id,
|
||||
txaction: $payt->txaction ?? $payt->status ?? 'approved',
|
||||
);
|
||||
|
||||
Yard::instance($this->instance)->destroy();
|
||||
$this->checkoutRepo->sessionDestroy(true);
|
||||
Util::setInstanceStatus(3, true); // link_pending
|
||||
|
|
|
|||
|
|
@ -608,6 +608,11 @@ class WizardController extends Controller
|
|||
if ($cartItem->qty > 1) {
|
||||
Yard::instance('shopping')->update($cartItem->rowId, 1);
|
||||
}
|
||||
foreach (Yard::instance('shopping')->content() as $existingItem) {
|
||||
if ($existingItem->rowId !== $cartItem->rowId) {
|
||||
Yard::instance('shopping')->remove($existingItem->rowId);
|
||||
}
|
||||
}
|
||||
if (\App\Services\UserService::getTaxFree()) {
|
||||
Yard::setTax($cartItem->rowId, 0);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Mail/PaymentIncidentAlert.php
Normal file
31
app/Mail/PaymentIncidentAlert.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
app/Models/CheckoutFunnelEvent.php
Normal file
123
app/Models/CheckoutFunnelEvent.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Models/IncidentActivity.php
Normal file
46
app/Models/IncidentActivity.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
156
app/Models/PaymentIncident.php
Normal file
156
app/Models/PaymentIncident.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ShoppingPayment $shopping_payment
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction query()
|
||||
|
|
@ -39,18 +40,20 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereTxid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value)
|
||||
*
|
||||
* @property string|null $mode
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class PaymentTransaction extends Model
|
||||
{
|
||||
protected $table = 'payment_transactions';
|
||||
|
||||
|
||||
protected $casts = [
|
||||
'transmitted_data' => 'array'
|
||||
];
|
||||
'transmitted_data' => 'array',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'shopping_payment_id',
|
||||
|
|
@ -67,9 +70,52 @@ class PaymentTransaction extends Model
|
|||
'mode',
|
||||
];
|
||||
|
||||
|
||||
public function shopping_payment()
|
||||
{
|
||||
return $this->belongsTo('App\Models\ShoppingPayment','shopping_payment_id');
|
||||
return $this->belongsTo('App\Models\ShoppingPayment', 'shopping_payment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable description for known PAYONE error codes.
|
||||
*
|
||||
* @return array<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
app/Models/ProviderUptimeLog.php
Normal file
24
app/Models/ProviderUptimeLog.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -16,6 +17,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
public function boot()
|
||||
{
|
||||
Schema::defaultStringLength(191);
|
||||
Paginator::defaultView('pagination::bootstrap-4');
|
||||
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');
|
||||
|
||||
if ($this->app->environment('production')) {
|
||||
URL::forceScheme('https');
|
||||
|
|
@ -33,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
|
|||
// $userShop = $context->userShop ?? \App\Services\Util::getUserShop();
|
||||
// $view->with('user_shop', $userShop);
|
||||
// }
|
||||
|
||||
|
||||
// Temporär: Verwende immer das normale Verhalten
|
||||
$view->with('user_shop', \App\Services\Util::getUserShop());
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
128
app/Services/CheckoutFunnelTracker.php
Normal file
128
app/Services/CheckoutFunnelTracker.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue