Stripe-Integration (S7.4): Payment-Dashboard für Stripe

- Provider-Status/Uptime: Stripe-Kachel aktiviert; payment:check-uptime prüft
  zusätzlich api.stripe.com/v1/ (401 ohne Key = erreichbar) inkl. Auto-Incident
- Zahlungen: Anbieter-Filter, Provider-Badges, Umsatz je Provider,
  Stripe-Dashboard-Deep-Links (ShoppingPayment/PaymentTransaction::
  stripeDashboardUrl, pi_-Guard, testmodus-bewusst)
- Rohe Transaktionen: Anbieter-Filter/-Spalte, Erfolgsraten je Anbieter;
  Bugfix: Stringable-Vergleich ließ den txaction-Filter immer mit leerem Wert
  greifen — Liste war dauerhaft leer
- Neue Kachel „Abos je Anbieter" (Index + GF-Ansicht) mit Fehlerzählern und
  Stripe-Anteil-Fortschrittsbalken (Auslaufmodell PAYONE → Stripe ablesbar)
- Incident-Kachel weist Stripe/PAYONE getrennt aus; Log-Viewer mit
  Kanal-Umschalter stripe/payone
- Plan-Doku: S7.4-Umsetzungsstatus, Abo-Go-Live 2026-07-10 vermerkt

Tests: PaymentDashboardProviderTest (9 neu), Dashboard-Suite 49 grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-10 16:57:50 +00:00
parent ce4750fd1a
commit 8e09aaefe8
13 changed files with 600 additions and 45 deletions

View file

@ -14,7 +14,9 @@ use App\Models\PaymentTransaction;
use App\Models\ProviderUptimeLog;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Services\MyLog;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@ -39,6 +41,7 @@ class PaymentDashboardController extends Controller
$allIncidents = PaymentIncident::with('activities')->orderBy('detected_at', 'desc')->paginate(20);
$providerStats = $this->getProviderStats();
$uptimeStats = $this->getUptimeStats();
$aboProviderStats = $this->getAboProviderStats();
return view('admin.payment-dashboard.index', compact(
'stats',
@ -47,6 +50,7 @@ class PaymentDashboardController extends Controller
'allIncidents',
'providerStats',
'uptimeStats',
'aboProviderStats',
));
}
@ -61,6 +65,7 @@ class PaymentDashboardController extends Controller
$openIncidents = PaymentIncident::open()->orderBy('detected_at', 'desc')->get();
$recentIncidents = PaymentIncident::orderBy('detected_at', 'desc')->take(10)->get();
$providerStats = $this->getProviderStats();
$aboProviderStats = $this->getAboProviderStats();
return view('admin.payment-dashboard.management', compact(
'stats',
@ -68,6 +73,7 @@ class PaymentDashboardController extends Controller
'openIncidents',
'recentIncidents',
'providerStats',
'aboProviderStats',
));
}
@ -165,7 +171,8 @@ class PaymentDashboardController extends Controller
public function transactions(): View
{
$days = request()->integer('days', 7);
$txactionFilter = request()->string('txaction', '');
$txactionFilter = request()->string('txaction', '')->toString();
$providerFilter = request()->string('provider', '')->toString();
$query = PaymentTransaction::with('shopping_payment')
->orderBy('created_at', 'desc');
@ -174,14 +181,16 @@ class PaymentDashboardController extends Controller
$query->where('created_at', '>=', now()->subDays($days));
}
if ($txactionFilter && $txactionFilter !== '') {
if ($txactionFilter !== '') {
$query->where('txaction', $txactionFilter);
}
$this->applyProviderFilter($query, 'provider', $providerFilter);
$transactions = $query->paginate(30)->withQueryString();
$transactionStats = $this->getTransactionStats($days > 0 ? $days : 30);
return view('admin.payment-dashboard.transactions', compact('transactions', 'transactionStats', 'days', 'txactionFilter'));
return view('admin.payment-dashboard.transactions', compact('transactions', 'transactionStats', 'days', 'txactionFilter', 'providerFilter'));
}
/**
@ -361,6 +370,7 @@ class PaymentDashboardController extends Controller
$days = request()->integer('days', 7);
$statusFilter = request()->string('txaction', '')->toString();
$modeFilter = request()->string('mode', '')->toString();
$providerFilter = request()->string('provider', '')->toString();
$query = ShoppingPayment::with([
'payment_transactions' => fn ($q) => $q->orderBy('created_at'),
@ -380,26 +390,34 @@ class PaymentDashboardController extends Controller
$query->where('mode', $modeFilter);
}
$this->applyProviderFilter($query, 'payment_provider', $providerFilter);
$payments = $query->paginate(25)->withQueryString();
$paymentStats = $this->getPaymentStats($days > 0 ? $days : 30);
return view('admin.payment-dashboard.payments', compact('payments', 'paymentStats', 'days', 'statusFilter', 'modeFilter'));
return view('admin.payment-dashboard.payments', compact('payments', 'paymentStats', 'days', 'statusFilter', 'modeFilter', 'providerFilter'));
}
/**
* PAYONE Log-Viewer.
* Log-Viewer für die Provider-Kanäle (payone-/stripe-Tageslogs).
*/
public function logs(): View
{
$availableDates = $this->getAvailableLogDates();
$availableChannels = ['stripe', 'payone'];
$selectedChannel = request()->string('channel', 'stripe')->toString();
if (! in_array($selectedChannel, $availableChannels, true)) {
$selectedChannel = 'stripe';
}
$availableDates = $this->getAvailableLogDates($selectedChannel);
$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');
$logFile = storage_path('logs/'.$selectedChannel.'-'.$selectedDate.'.log');
$entries = [];
if (file_exists($logFile)) {
@ -415,7 +433,7 @@ class PaymentDashboardController extends Controller
}
}
return view('admin.payment-dashboard.logs', compact('entries', 'availableDates', 'selectedDate'));
return view('admin.payment-dashboard.logs', compact('entries', 'availableDates', 'selectedDate', 'availableChannels', 'selectedChannel'));
}
// ─── Private Helpers ──────────────────────────────────────────────────────
@ -429,6 +447,7 @@ class PaymentDashboardController extends Controller
->whereMonth('resolved_at', now()->month)->count(),
'total_affected_revenue' => PaymentIncident::open()->sum('affected_revenue'),
'payone_incidents_30d' => PaymentIncident::payone()->lastDays(30)->count(),
'stripe_incidents_30d' => PaymentIncident::where('provider', 'stripe')->lastDays(30)->count(),
'critical_open' => PaymentIncident::open()->where('severity', 'critical')->count(),
];
}
@ -451,6 +470,27 @@ class PaymentDashboardController extends Controller
$lastFailed = PaymentTransaction::where('txaction', 'failed')->latest()->first();
// Erfolgs-/Fehlerraten je Provider (Alt-PAYONE-Zeilen ohne Provider zählen als payone)
$providerRows = PaymentTransaction::where('created_at', '>=', $since)
->selectRaw("COALESCE(provider, 'payone') as provider_key")
->selectRaw('COUNT(*) as total')
->selectRaw("SUM(CASE WHEN txaction = 'paid' THEN 1 ELSE 0 END) as paid")
->selectRaw("SUM(CASE WHEN txaction = 'failed' THEN 1 ELSE 0 END) as failed")
->groupBy('provider_key')
->get();
$providers = [];
foreach ($providerRows as $row) {
$providers[$row->provider_key] = [
'label' => strtoupper($row->provider_key),
'total' => (int) $row->total,
'paid' => (int) $row->paid,
'failed' => (int) $row->failed,
'success_rate' => $row->total > 0 ? round(((int) $row->paid / $row->total) * 100, 1) : 0,
];
}
ksort($providers);
return [
'total' => $total,
'paid' => $paid,
@ -458,6 +498,7 @@ class PaymentDashboardController extends Controller
'success_rate' => $successRate,
'error_distribution' => $errorDistribution,
'last_failed' => $lastFailed,
'providers' => $providers,
'days' => $days,
];
}
@ -465,10 +506,9 @@ class PaymentDashboardController extends Controller
private function getProviderStats(): array
{
$providers = [
'stripe',
'payone',
// 'stripe', // aktuell nicht aktiv
'paypal',
// 'mollie', // aktuell nicht aktiv
'other',
];
$stats = [];
@ -496,6 +536,26 @@ class PaymentDashboardController extends Controller
$base = ShoppingPayment::where('created_at', '>=', $since);
// Umsatz (bezahlt) und Volumen je Provider (Alt-Zeilen ohne Provider = payone)
$providerRows = (clone $base)
->selectRaw("COALESCE(payment_provider, 'payone') as provider_key")
->selectRaw('COUNT(*) as total')
->selectRaw("SUM(CASE WHEN txaction = 'paid' THEN 1 ELSE 0 END) as paid")
->selectRaw("SUM(CASE WHEN txaction = 'paid' THEN amount ELSE 0 END) as paid_amount")
->groupBy('provider_key')
->get();
$providers = [];
foreach ($providerRows as $row) {
$providers[$row->provider_key] = [
'label' => strtoupper($row->provider_key),
'total' => (int) $row->total,
'paid' => (int) $row->paid,
'paid_amount' => round((int) $row->paid_amount / 100, 2),
];
}
ksort($providers);
return [
'total' => (clone $base)->count(),
'paid' => (clone $base)->where('txaction', 'paid')->count(),
@ -506,18 +566,74 @@ class PaymentDashboardController extends Controller
(clone $base)->whereHas('payment_transactions', fn ($q) => $q->where('txaction', 'failed'))->sum('amount') / 100,
2
),
'providers' => $providers,
'days' => $days,
];
}
private function getAvailableLogDates(): array
/**
* Provider-Filter für Listen: Alt-PAYONE-Zeilen haben teils NULL in der
* Provider-Spalte und müssen beim payone-Filter mitkommen.
*
* @param Builder<ShoppingPayment>|Builder<PaymentTransaction> $query
*/
private function applyProviderFilter($query, string $column, string $provider): void
{
if ($provider === '') {
return;
}
if ($provider === 'payone') {
$query->where(fn ($q) => $q->where($column, 'payone')->orWhereNull($column));
} else {
$query->where($column, $provider);
}
}
/**
* Aktive Abos je Provider — macht den Auslauf-Fortschritt PAYONE → Stripe ablesbar.
*
* @return array{providers: array<string, array{label: string, total: int, failed: int}>, total: int, stripe_share: float|null}
*/
private function getAboProviderStats(): array
{
$rows = UserAbo::query()
->activeAbo()
->selectRaw("COALESCE(payment_provider, 'payone') as provider_key")
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as failed')
->groupBy('provider_key')
->get();
$providers = [];
$total = 0;
foreach ($rows as $row) {
$providers[$row->provider_key] = [
'label' => strtoupper($row->provider_key),
'total' => (int) $row->total,
'failed' => (int) $row->failed,
];
$total += (int) $row->total;
}
ksort($providers);
$stripeCount = $providers['stripe']['total'] ?? 0;
return [
'providers' => $providers,
'total' => $total,
'stripe_share' => $total > 0 ? round($stripeCount / $total * 100, 1) : null,
];
}
private function getAvailableLogDates(string $channel): array
{
$logPath = storage_path('logs');
$files = glob($logPath.'/payone-*.log');
$files = glob($logPath.'/'.$channel.'-*.log');
$dates = [];
foreach ($files as $file) {
if (preg_match('/payone-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) {
if (preg_match('/'.$channel.'-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) {
$dates[] = $m[1];
}
}
@ -533,10 +649,9 @@ class PaymentDashboardController extends Controller
private function getUptimeStats(): array
{
$providers = [
'stripe',
'payone',
// 'stripe', // aktuell nicht aktiv
'paypal',
// 'mollie', // aktuell nicht aktiv
];
$stats = [];
$since = now()->subHours(24);