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,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\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;

View file

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

View file

@ -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();
}
}

View file

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

View file

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

View file

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