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

@ -0,0 +1,45 @@
{{-- Abos je Provider: macht den Auslauf-Fortschritt PAYONE → Stripe ablesbar (Plan S7.4). --}}
<div class="card mb-4">
<h6 class="card-header">
Abos je Anbieter
<small class="text-muted">&mdash; Auslaufmodell PAYONE &rarr; Stripe</small>
</h6>
<div class="card-body py-3">
@if($aboProviderStats['total'] === 0)
<div class="text-muted">Keine aktiven Abos vorhanden.</div>
@else
<div class="row">
@foreach($aboProviderStats['providers'] as $providerKey => $providerRow)
<div class="col-sm-6 col-md-3 mb-2">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ $providerRow['label'] }}</strong>
<span class="badge badge-{{ $providerKey === 'stripe' ? 'primary' : 'secondary' }}">
{{ $providerRow['total'] }} aktiv
</span>
</div>
<div class="text-muted small">
@if($providerRow['failed'] > 0)
<span class="text-danger">{{ $providerRow['failed'] }} mit fehlgeschlagener Zahlung</span>
@else
keine fehlgeschlagenen Zahlungen
@endif
</div>
</div>
@endforeach
</div>
@if($aboProviderStats['stripe_share'] !== null)
<div class="mt-2">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Anteil Stripe</span>
<span>{{ $aboProviderStats['stripe_share'] }}% von {{ $aboProviderStats['total'] }} aktiven Abos</span>
</div>
<div class="progress" style="height: 8px">
<div class="progress-bar bg-primary" role="progressbar"
style="width: {{ $aboProviderStats['stripe_share'] }}%"
aria-valuenow="{{ $aboProviderStats['stripe_share'] }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
@endif
@endif
</div>
</div>

View file

@ -22,9 +22,12 @@
<div class="col-sm-6 col-xl-2">
<div class="card">
<div class="card-body text-center">
<div class="text-muted small">PAYONE (30 Tage)</div>
<div class="text-muted small">Incidents (30 Tage)</div>
<div class="display-4 font-weight-bold text-info">
{{ $stats['payone_incidents_30d'] }}
{{ $stats['stripe_incidents_30d'] + $stats['payone_incidents_30d'] }}
</div>
<div class="text-muted" style="font-size:0.7rem">
Stripe {{ $stats['stripe_incidents_30d'] }} &middot; PAYONE {{ $stats['payone_incidents_30d'] }}
</div>
</div>
</div>

View file

@ -69,6 +69,9 @@
@endforeach
</div>
{{-- Abos je Provider (Auslauf-Fortschritt) --}}
@include('admin.payment-dashboard._partials.abo-provider-stats')
{{-- Tabs --}}
<ul class="nav nav-tabs" id="dashboardTabs">
<li class="nav-item">
@ -104,7 +107,7 @@
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.payment-dashboard.logs') }}">
<i class="ion ion-md-list"></i> PAYONE Logs
<i class="ion ion-md-list"></i> Provider-Logs
</a>
</li>
</ul>

View file

@ -7,10 +7,17 @@
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
<i class="ion ion-md-arrow-back"></i> Zurück
</a>
<strong>PAYONE Log-Viewer</strong>
<strong>Provider-Log-Viewer</strong>
</div>
@if(count($availableDates) > 1)
<form method="GET" action="{{ route('admin.payment-dashboard.logs') }}">
<form method="GET" action="{{ route('admin.payment-dashboard.logs') }}" class="form-inline">
<select name="channel" class="custom-select custom-select-sm mr-2" onchange="this.form.submit()">
@foreach($availableChannels as $channel)
<option value="{{ $channel }}" {{ $selectedChannel === $channel ? 'selected' : '' }}>
{{ strtoupper($channel) }}
</option>
@endforeach
</select>
@if(count($availableDates) > 1)
<select name="date" class="custom-select custom-select-sm" onchange="this.form.submit()">
@foreach($availableDates as $date)
<option value="{{ $date }}" {{ $selectedDate === $date ? 'selected' : '' }}>
@ -18,8 +25,8 @@
</option>
@endforeach
</select>
</form>
@endif
@endif
</form>
</div>
@if(count($entries) === 0)
@ -29,7 +36,7 @@
@if(count($availableDates) > 0)
Verfügbare Daten: {{ implode(', ', array_map(fn($d) => \Carbon\Carbon::parse($d)->format('d.m.Y'), $availableDates)) }}
@else
Der Log-Kanal <code>payone</code> hat noch keine Einträge geschrieben.
Der Log-Kanal <code>{{ $selectedChannel }}</code> hat noch keine Einträge geschrieben.
@endif
</div>
@else

View file

@ -76,12 +76,20 @@
</span>
@endif
<div class="text-muted small mt-1">{{ $provider['total_30d'] }} Incidents (30d)</div>
@if(isset($transactionStats['providers'][$key]))
<div class="text-muted small">
Zahlungsquote: <strong>{{ $transactionStats['providers'][$key]['success_rate'] }}%</strong>
</div>
@endif
</div>
@endforeach
</div>
</div>
</div>
{{-- Abos je Provider (Auslauf-Fortschritt) --}}
@include('admin.payment-dashboard._partials.abo-provider-stats')
{{-- Aktive Störungen --}}
@if($openIncidents->count() > 0)
<div class="card mb-4 border-danger">

View file

@ -69,6 +69,29 @@
</div>
</div>
{{-- Umsatz je Provider --}}
@if(count($paymentStats['providers']) > 0)
<div class="row mb-4">
@foreach($paymentStats['providers'] as $providerKey => $providerRow)
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ $providerRow['label'] }}</strong>
<span class="badge badge-{{ $providerKey === 'stripe' ? 'primary' : 'secondary' }}">
{{ $providerRow['paid'] }}/{{ $providerRow['total'] }} bezahlt
</span>
</div>
<div class="text-muted small mt-1">
Umsatz (bezahlt): <strong>{{ number_format($providerRow['paid_amount'], 2, ',', '.') }} €</strong>
</div>
</div>
</div>
</div>
@endforeach
</div>
@endif
{{-- Filter --}}
<div class="card mb-3">
<div class="card-body py-2">
@ -79,6 +102,13 @@
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Anbieter:</label>
<select name="provider" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
<option value="">Alle</option>
@foreach(['stripe' => 'Stripe', 'payone' => 'PAYONE'] as $val => $label)
<option value="{{ $val }}" {{ $providerFilter === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Status:</label>
<select name="txaction" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
<option value="">Alle</option>
@ -135,6 +165,11 @@
</td>
<td class="align-middle">
<code class="small">{{ $payment->reference }}</code>
@if($stripeUrl = $payment->stripeDashboardUrl())
<a href="{{ $stripeUrl }}" target="_blank" rel="noopener" class="ml-1" title="Im Stripe-Dashboard öffnen">
<i class="ion ion-md-open"></i>
</a>
@endif
</td>
<td class="align-middle">
@if($payment->shopping_order)
@ -181,8 +216,10 @@
</td>
<td class="align-middle small">
{{ $payment->getPaymentType() }}
@if($payment->clearingtype)
<span class="badge badge-secondary ml-1">{{ $payment->clearingtype }}</span>
@if($payment->payment_provider === 'stripe')
<span class="badge badge-primary ml-1">Stripe</span>
@else
<span class="badge badge-secondary ml-1" title="{{ $payment->clearingtype }}">PAYONE</span>
@endif
</td>
<td class="align-middle text-nowrap">
@ -253,7 +290,13 @@
<tbody>
@foreach($payment->payment_transactions as $tx)
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
<td class="pl-4 small">{{ $tx->txid ?? '—' }}</td>
<td class="pl-4 small">
@if($txStripeUrl = $tx->stripeDashboardUrl())
<a href="{{ $txStripeUrl }}" target="_blank" rel="noopener" title="Im Stripe-Dashboard öffnen">{{ $tx->txid }}</a>
@else
{{ $tx->txid ?? '—' }}
@endif
</td>
<td>
@php
$txaColor = match($tx->txaction) {

View file

@ -55,6 +55,30 @@
</div>
</div>
{{-- Erfolgsraten je Provider --}}
@if(count($transactionStats['providers']) > 0)
<div class="card mb-4">
<h6 class="card-header">Erfolgsraten je Anbieter (letzte {{ $transactionStats['days'] }} Tage)</h6>
<div class="card-body py-2">
<div class="row">
@foreach($transactionStats['providers'] as $providerKey => $providerRow)
<div class="col-sm-6 col-md-3 mb-2">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ $providerRow['label'] }}</strong>
<span class="badge badge-{{ $providerRow['success_rate'] >= 95 ? 'success' : ($providerRow['success_rate'] >= 80 ? 'warning' : 'danger') }}">
{{ $providerRow['success_rate'] }}%
</span>
</div>
<div class="text-muted small">
{{ $providerRow['paid'] }} bezahlt / {{ $providerRow['failed'] }} fehlgeschlagen / {{ $providerRow['total'] }} gesamt
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
@if($transactionStats['error_distribution']->count() > 0)
<div class="card mb-4">
<h6 class="card-header">Fehlercodes (letzte {{ $transactionStats['days'] }} Tage)</h6>
@ -79,6 +103,13 @@
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Anbieter:</label>
<select name="provider" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
<option value="">Alle</option>
@foreach(['stripe' => 'Stripe', 'payone' => 'PAYONE'] as $val => $label)
<option value="{{ $val }}" {{ $providerFilter === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<label class="mr-2 small">Aktion:</label>
<select name="txaction" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
<option value="">Alle</option>
@ -99,6 +130,7 @@
<tr>
<th>ID</th>
<th>Datum</th>
<th>Anbieter</th>
<th>Aktion</th>
<th>TX-ID</th>
<th>Referenz</th>
@ -113,6 +145,13 @@
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
<td class="text-muted small">{{ $tx->id }}</td>
<td class="text-nowrap small">{{ $tx->created_at->format('d.m.Y H:i') }}</td>
<td>
@if($tx->provider === 'stripe')
<span class="badge badge-primary">Stripe</span>
@else
<span class="badge badge-secondary">PAYONE</span>
@endif
</td>
<td>
@php
$actionColor = match($tx->txaction) {
@ -125,7 +164,13 @@
@endphp
<span class="badge badge-{{ $actionColor }}">{{ $tx->txaction ?? '—' }}</span>
</td>
<td class="small">{{ $tx->txid ?? '—' }}</td>
<td class="small">
@if($txStripeUrl = $tx->stripeDashboardUrl())
<a href="{{ $txStripeUrl }}" target="_blank" rel="noopener" title="Im Stripe-Dashboard öffnen">{{ $tx->txid }}</a>
@else
{{ $tx->txid ?? '—' }}
@endif
</td>
<td class="small">
@if($tx->shopping_payment)
<code>{{ $tx->shopping_payment->reference }}</code>
@ -160,14 +205,14 @@
</tr>
@if($tx->transmitted_data)
<tr class="collapse" id="tx-data-{{ $tx->id }}">
<td colspan="9" class="bg-light">
<td colspan="10" class="bg-light">
<pre class="mb-0 small" style="max-height:200px; overflow-y:auto">{{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</td>
</tr>
@endif
@empty
<tr>
<td colspan="9" class="text-center text-muted py-3">Keine Transaktionen gefunden.</td>
<td colspan="10" class="text-center text-muted py-3">Keine Transaktionen gefunden.</td>
</tr>
@endforelse
</tbody>