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 dd1c8f9a1e
commit ad19112750
13 changed files with 600 additions and 45 deletions

View file

@ -13,12 +13,16 @@ 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.';
protected $description = 'Prüft die Erreichbarkeit von Stripe und PAYONE und legt bei Ausfall automatisch einen Incident an.';
/**
* Ein HTTP-Status < 500 gilt als erreichbar — bei Stripe antwortet /v1/ ohne
* API-Key mit 401, was Erreichbarkeit belegt, ohne einen Key zu benötigen.
*
* @var array<string, string>
*/
private array $endpoints = [
'stripe' => 'https://api.stripe.com/v1/',
'payone' => 'https://api.pay1.de/post-gateway/',
];

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

View file

@ -77,6 +77,21 @@ class PaymentTransaction extends Model
return $this->belongsTo('App\Models\ShoppingPayment', 'shopping_payment_id');
}
/**
* Deep-Link ins Stripe-Dashboard für Stripe-Transaktionen
* (txid trägt dort die PaymentIntent-ID, siehe StripeTransactionRecorder).
*/
public function stripeDashboardUrl(): ?string
{
if ($this->provider !== 'stripe' || ! str_starts_with((string) $this->txid, 'pi_')) {
return null;
}
$base = $this->mode === 'test' ? 'https://dashboard.stripe.com/test' : 'https://dashboard.stripe.com';
return $base.'/payments/'.$this->txid;
}
/**
* Returns a human-readable description for known PAYONE error codes.
*

View file

@ -180,4 +180,19 @@ class ShoppingPayment extends Model
default => __('payment.stripe_online_short'),
};
}
/**
* Deep-Link ins Stripe-Dashboard, sobald ein PaymentIntent zugeordnet ist
* (external_reference; vor dem ersten Webhook steht dort noch die Session-ID).
*/
public function stripeDashboardUrl(): ?string
{
if ($this->payment_provider !== 'stripe' || ! str_starts_with((string) $this->external_reference, 'pi_')) {
return null;
}
$base = $this->mode === 'test' ? 'https://dashboard.stripe.com/test' : 'https://dashboard.stripe.com';
return $base.'/payments/'.$this->external_reference;
}
}

View file

@ -1,6 +1,6 @@
# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option)
> **Status:** In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ — **live in Produktion**; S4 ✓ **E2E komplett** (Abschluss, Cron, Decline→Recovery, authentication_required); S5 ✓ „Zahlungsart ändern" inkl. E2E über CRM + Kunden-Link (nur Portal-Pfad ohne Sichttest, gleicher Code-Pfad); S7.6 ✓ Zahlarten-Kommunikation — **vor Abo-Freigabe:** Deployment + 3 Migrationen, dann `abo-payments-frozen` aufheben, §16) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-09
> **Status:** In Arbeit (S0–S5 ✓, S7.4 ✓, S7.6 ✓ — **alles live seit 2026-07-10 inkl. Abos**, Freeze aufgehoben, Vorkasse wieder sichtbar, läuft fehlerfrei; offen: S7.1/S7.2/S7.3/S7.5, S6-Rest, S9 → §16) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-10
>
> **Lage-Update 2026-07-09 (Stakeholder):** Das PAYONE-Konto ist **wieder freigeschaltet**.
> Daraus ergeben sich folgende verbindliche Entscheidungen (Details §7.2):
@ -459,8 +459,9 @@ in `StripeCheckoutTest.php`; Karten-Update-Regressionssuite (17 Tests) unveränd
57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!).
8. [x] Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe.
9. [x] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
10. [ ] **Nach S4/S5 (JETZT offen, → §16):** Deployment + 3 neue Migrationen, dann
`abo-payments-frozen` aufheben und Vorkasse wieder einblenden.
10. [x] **Nach S4/S5:** Deployment + 3 neue Migrationen, `abo-payments-frozen`
aufgehoben, Vorkasse wieder eingeblendet — ✅ **live seit 2026-07-10, läuft
fehlerfrei** (→ §16).
---
@ -713,10 +714,38 @@ Payment-Dashboard kennt Stripe.
| S7.1 | **Schalter-UI** im Admin (Einstellungen): aktueller Provider, Umschalten mit Bestätigungsdialog + Hinweistext („wirkt nur auf neue Zahlungen"), `SysLog`-Eintrag. Nur berechtigte Admin-Rolle. |
| S7.2 | **Refunds:** `OrderWithdrawalController` (Widerruf) um automatische Erstattung erweitern — Provider der Ursprungszahlung entscheidet (Stripe: `Refund`-API; PAYONE: bestehender/manueller Weg). Teil-Erstattungen unterstützen. |
| S7.3 | Admin-Detailansichten (`ShoppingOrder`, `UserAbo`, Zahlungen): Provider-Badge, Stripe-IDs verlinkt zum Stripe-Dashboard (Deep-Link `https://dashboard.stripe.com/...`). |
| S7.4 | Payment-Dashboard (`/admin/payment-dashboard`) **für Stripe anpassen/optimieren** (Stakeholder 2026-07-09): Kacheln/Listen kennen bisher nur PAYONE — Stripe-Zahlungen korrekt ausweisen (Provider-Filter, Zahlart-Labels, Verlinkung ins Stripe-Dashboard), Umsatz/Fehlerraten je Provider, Übersicht „Abos je Provider" (Auslauf-Fortschritt ablesbar). |
| S7.4 | Payment-Dashboard (`/admin/payment-dashboard`) **für Stripe anpassen/optimieren** (Stakeholder 2026-07-09): Kacheln/Listen kennen bisher nur PAYONE — Stripe-Zahlungen korrekt ausweisen (Provider-Filter, Zahlart-Labels, Verlinkung ins Stripe-Dashboard), Umsatz/Fehlerraten je Provider, Übersicht „Abos je Provider" (Auslauf-Fortschritt ablesbar). **ERLEDIGT ✅ (2026-07-10), siehe Umsetzungsstatus S7.4 unten.** |
| S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. |
| S7.6 | **Zahlarten-Kommunikation** (Stakeholder 2026-07-09): Die über Stripe verfügbaren Zahlarten klar benennen, möglichst mit Logos — (a) im Checkout unter der „Online-Zahlung"-Option, (b) auf den Shop-Seiten, (c) im Footer bei den Zahlungsmöglichkeiten, (d) auf der Seite `/zahlungsarten`. Vorkasse als „Vorkasse (PAYONE)" ausweisen. **ERLEDIGT ✅ (2026-07-09), siehe Umsetzungsstatus unten.** |
### Umsetzungsstatus S7.4 (ERLEDIGT ✅, 2026-07-10)
`/admin/payment-dashboard` kennt jetzt beide Provider (Alt-Zeilen ohne Provider-Wert
zählen defensiv als PAYONE):
- **Provider-Status/Uptime:** Stripe in `getProviderStats()`/`getUptimeStats()` aktiviert;
`payment:check-uptime` prüft zusätzlich `https://api.stripe.com/v1/` (401 ohne Key
belegt Erreichbarkeit → kein API-Key nötig) inkl. Auto-Incident/Auto-Resolve wie PAYONE.
Incident-Kachel zeigt Stripe+PAYONE getrennt (`stripe_incidents_30d`).
- **Zahlungen** (`payments`): Anbieter-Filter (Stripe/PAYONE), Provider-Badge statt
rohem clearingtype, Umsatz-je-Provider-Kacheln (bezahlt/gesamt + Summe), Deep-Link
ins Stripe-Dashboard an Zahlung (external_reference `pi_…`, testmodus-bewusst) und
an jeder Stripe-Transaktion (txid). Helper: `ShoppingPayment::stripeDashboardUrl()`,
`PaymentTransaction::stripeDashboardUrl()`.
- **Rohe Transaktionen** (`transactions`): Anbieter-Filter + -Spalte, Karte
„Erfolgsraten je Anbieter". **Bugfix nebenbei:** `request()->string()` liefert ein
`Stringable` (immer truthy, `!== ''`) → der txaction-Filter griff IMMER mit leerem
Wert, die Liste war dauerhaft leer; jetzt `->toString()` wie in `payments()`.
- **Abos je Anbieter** (Index + GF-Ansicht): neue Kachel mit aktiven Abos je Provider
(`scopeActiveAbo`), Fehlerzähler (Status 3) und Fortschrittsbalken „Anteil Stripe" —
der Auslauf PAYONE → Stripe (§7.2) ist damit direkt ablesbar. GF-Ansicht zeigt
zusätzlich Zahlungsquote je Provider im Anbieter-Status.
- **Log-Viewer:** Kanal-Umschalter stripe/payone (Default stripe, Whitelist),
Tab heißt jetzt „Provider-Logs".
- **Tests:** `tests/Feature/PaymentDashboard/PaymentDashboardProviderTest.php` (9 Tests:
Filter, Stats je Provider, Abo-Auslauf, Deep-Links, Log-Kanäle, Uptime via Http::fake);
gesamte Dashboard-Suite (49 Tests) grün.
### Umsetzungsstatus S7.6 (ERLEDIGT ✅, 2026-07-09)
Kommuniziertes Zahlarten-Set (per API gegen die aktive Stripe `PaymentMethodConfiguration`
@ -885,14 +914,12 @@ Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
PayPal, PAYONE→Stripe-Umzug), Decline → Status 3, Recovery-Checkout,
authentication_required. Aufräumen: Test-Abos #440/#442 (Abo 442 steht nach dem
letzten Test auf Status 3 mit der `…3184`-Testkarte) und Test-Bestellungen bereinigen.
2. **Deployment + Migrationen** (3 neue: Provider-/Stripe-Abo-Spalten,
`widen_clearingtype`), dann im Admin: **`abo-payments-frozen` aufheben** (§7.2),
Vorkasse wieder einblenden (`payment-vorkasse-hidden` aus). Ersten 04:00-Cron-Lauf
beobachten (holt eingefrorene PAYONE-Abbuchungen nach → großer Lauf!):
`abo_order.log`, `stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
Der Mischlauf PAYONE+Stripe verifiziert sich dabei. Dazu im **Live**-Stripe-Dashboard
das Zahlarten-Set mit dem Testmodus abgleichen (S7.6 kommuniziert u. a. Link, EPS,
Bancontact — Test/Live sind getrennt konfiguriert!).
2. ~~**Deployment + Migrationen + Freeze aufheben**~~ ✅ **LIVE seit 2026-07-10**
(Stakeholder): S4/S5/S7.6 deployt, Abo-Freeze aufgehoben, Vorkasse wieder sichtbar —
läuft bisher fehlerfrei. **Weiter beobachten:** die ersten 04:00-Cron-Läufe
(`abo_order.log`, `stripe.log`, `stripe_webhook_events`, Payment-Dashboard) und im
**Live**-Stripe-Dashboard das Zahlarten-Set mit dem Testmodus abgleichen (S7.6
kommuniziert u. a. Link, EPS, Bancontact — Test/Live sind getrennt konfiguriert!).
3. ~~Phase **S5**~~ ✅ umgesetzt + E2E (2026-07-09, Scope erweitert auf „Zahlungsart
ändern"): CRM-Pfad und Berater-Kunden-Link im Browser verprobt (inkl. PayPal und
PAYONE→Stripe-Umzug, Abo 440). Nur der **Portal-Pfad** ist ohne Sichttest
@ -900,5 +927,10 @@ Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
4. ~~**S7.6 Zahlarten-Kommunikation**~~ ✅ umgesetzt (2026-07-09, siehe Umsetzungsstatus
in §11): Logos + Benennung in Checkout, Footer (alle Shop-Seiten) und `/zahlungsarten`.
**Offen:** kurzer Sichttest im Browser (Footer, `/zahlungsarten`, Checkout).
5. **S7.4 Payment-Dashboard** für Stripe anpassen/optimieren (Stakeholder 2026-07-09).
6. Nachgelagert: **S9 Rückbuchungen & Stornierungen** (§12.1).
5. ~~**S7.4 Payment-Dashboard**~~ ✅ umgesetzt (2026-07-10, siehe Umsetzungsstatus in
§11): Provider-Filter/-Badges, Stripe-Deep-Links, Umsatz-/Erfolgsraten je Provider,
Abos-je-Anbieter-Kachel (Auslauf-Fortschritt), Stripe-Uptime-Check, Log-Viewer für
stripe/payone. **Offen:** kurzer Sichttest im Browser.
6. Verbleibend aus S7: S7.1 (SysLog für Schalter), S7.2 (Refunds), S7.3
(Admin-Detailansichten mit Provider-Badge/Deep-Links), S7.5 (Betriebs-Runbook).
7. Nachgelagert: **S9 Rückbuchungen & Stornierungen** (§12.1).

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>

View file

@ -0,0 +1,220 @@
<?php
use App\Http\Controllers\Admin\PaymentDashboardController;
use App\Models\PaymentTransaction;
use App\Models\ProviderUptimeLog;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
/**
* Plan S7.4 – Payment-Dashboard für Stripe: Provider-Filter, Stats je Provider,
* Abo-Auslauf-Übersicht, Stripe-Deep-Links, Uptime-Check.
*/
function dashboardAdmin(): User
{
return User::forceCreate([
'email' => 'dash-admin-'.uniqid('', true).'@test.com',
'password' => Hash::make('secret'),
'admin' => 2,
'lang' => 'de',
]);
}
function dashboardPayment(array $overrides = []): ShoppingPayment
{
return ShoppingPayment::forceCreate(array_merge([
'shopping_order_id' => 1,
'clearingtype' => 'cc',
'reference' => 'dash-'.uniqid('', true),
'amount' => 5000,
'currency' => 'EUR',
'mode' => 'test',
], $overrides));
}
function dashboardAbo(array $overrides = []): UserAbo
{
$consultant = User::forceCreate([
'email' => 'dash-abo-'.uniqid('', true).'@test.com',
'password' => Hash::make('secret'),
'lang' => 'de',
]);
return UserAbo::create(array_merge([
'user_id' => $consultant->id,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => $consultant->email,
'payone_userid' => 0,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
], $overrides));
}
// ─── Provider-Filter Zahlungen ────────────────────────────────────────────────
it('filtert Zahlungen nach Provider', function () {
$this->actingAs(dashboardAdmin());
$stripe = dashboardPayment(['payment_provider' => 'stripe', 'txaction' => 'paid']);
$payone = dashboardPayment(['payment_provider' => 'payone']);
request()->merge(['provider' => 'stripe', 'days' => 7]);
$view = (new PaymentDashboardController)->payments();
$ids = $view->getData()['payments']->pluck('id');
expect($ids->all())->toBe([$stripe->id]);
request()->merge(['provider' => 'payone']);
$view = (new PaymentDashboardController)->payments();
$ids = $view->getData()['payments']->pluck('id');
expect($ids->all())->toBe([$payone->id]);
});
it('liefert Umsatz und Zahlungen je Provider in den Payment-Stats', function () {
$this->actingAs(dashboardAdmin());
dashboardPayment(['payment_provider' => 'stripe', 'txaction' => 'paid', 'amount' => 10000]);
dashboardPayment(['payment_provider' => 'stripe', 'txaction' => 'failed', 'amount' => 2000]);
dashboardPayment(['payment_provider' => 'payone', 'txaction' => 'paid', 'amount' => 5000]);
$view = (new PaymentDashboardController)->payments();
$providers = $view->getData()['paymentStats']['providers'];
expect($providers['stripe']['total'])->toBe(2)
->and($providers['stripe']['paid'])->toBe(1)
->and($providers['stripe']['paid_amount'])->toBe(100.0)
->and($providers['payone']['paid'])->toBe(1)
->and($providers['payone']['paid_amount'])->toBe(50.0);
});
// ─── Provider-Filter Transaktionen ────────────────────────────────────────────
it('filtert Transaktionen nach Provider und liefert Erfolgsraten je Provider', function () {
$this->actingAs(dashboardAdmin());
$payment = dashboardPayment(['payment_provider' => 'stripe']);
$stripeTx = PaymentTransaction::create([
'shopping_payment_id' => $payment->id, 'provider' => 'stripe',
'request' => 'paid', 'txaction' => 'paid', 'txid' => 'pi_123', 'userid' => 0,
]);
PaymentTransaction::create([
'shopping_payment_id' => $payment->id, 'provider' => 'payone',
'request' => 'failed', 'txaction' => 'failed', 'txid' => '99001', 'userid' => 0,
]);
request()->merge(['provider' => 'stripe', 'days' => 7]);
$view = (new PaymentDashboardController)->transactions();
$data = $view->getData();
expect($data['transactions']->pluck('id')->all())->toBe([$stripeTx->id])
->and($data['transactionStats']['providers']['stripe']['success_rate'])->toBe(100.0)
->and($data['transactionStats']['providers']['payone']['failed'])->toBe(1);
});
// ─── Abos je Provider (Auslauf-Fortschritt) ───────────────────────────────────
it('zeigt aktive Abos je Provider inkl. Stripe-Anteil und Fehlerzählern', function () {
$this->actingAs(dashboardAdmin());
dashboardAbo(['payment_provider' => 'stripe']);
dashboardAbo(['payment_provider' => 'payone']);
dashboardAbo(['payment_provider' => 'payone', 'status' => 3]);
dashboardAbo(['payment_provider' => 'stripe', 'status' => 5]); // beendet → zählt nicht
dashboardAbo(['payment_provider' => 'payone', 'active' => false]); // inaktiv → zählt nicht
$view = (new PaymentDashboardController)->index();
$stats = $view->getData()['aboProviderStats'];
expect($stats['total'])->toBe(3)
->and($stats['providers']['stripe']['total'])->toBe(1)
->and($stats['providers']['payone']['total'])->toBe(2)
->and($stats['providers']['payone']['failed'])->toBe(1)
->and($stats['stripe_share'])->toBe(33.3);
});
it('führt Stripe in den Provider-Status-Karten', function () {
$this->actingAs(dashboardAdmin());
$view = (new PaymentDashboardController)->index();
$data = $view->getData();
expect($data['providerStats'])->toHaveKey('stripe')
->and($data['uptimeStats'])->toHaveKey('stripe')
->and($data['stats'])->toHaveKey('stripe_incidents_30d');
});
// ─── Stripe-Deep-Links ────────────────────────────────────────────────────────
it('baut Stripe-Dashboard-Links nur für Stripe-Zahlungen mit PaymentIntent', function () {
$testMode = dashboardPayment(['payment_provider' => 'stripe', 'external_reference' => 'pi_abc', 'mode' => 'test']);
$liveMode = dashboardPayment(['payment_provider' => 'stripe', 'external_reference' => 'pi_def', 'mode' => 'live']);
$sessionOnly = dashboardPayment(['payment_provider' => 'stripe', 'external_reference' => 'cs_xyz']);
$payone = dashboardPayment(['payment_provider' => 'payone', 'external_reference' => 'pi_fake']);
expect($testMode->stripeDashboardUrl())->toBe('https://dashboard.stripe.com/test/payments/pi_abc')
->and($liveMode->stripeDashboardUrl())->toBe('https://dashboard.stripe.com/payments/pi_def')
->and($sessionOnly->stripeDashboardUrl())->toBeNull()
->and($payone->stripeDashboardUrl())->toBeNull();
});
it('baut Stripe-Dashboard-Links für Stripe-Transaktionen über die txid', function () {
$payment = dashboardPayment(['payment_provider' => 'stripe']);
$stripeTx = PaymentTransaction::create([
'shopping_payment_id' => $payment->id, 'provider' => 'stripe',
'request' => 'paid', 'txaction' => 'paid', 'txid' => 'pi_tx1', 'userid' => 0, 'mode' => 'live',
]);
$sessionTx = PaymentTransaction::create([
'shopping_payment_id' => $payment->id, 'provider' => 'stripe',
'request' => 'appointed', 'txaction' => 'appointed', 'txid' => 'cs_tx1', 'userid' => 0,
]);
$payoneTx = PaymentTransaction::create([
'shopping_payment_id' => $payment->id, 'provider' => 'payone',
'request' => 'paid', 'txaction' => 'paid', 'txid' => '123456', 'userid' => 0,
]);
expect($stripeTx->stripeDashboardUrl())->toBe('https://dashboard.stripe.com/payments/pi_tx1')
->and($sessionTx->stripeDashboardUrl())->toBeNull()
->and($payoneTx->stripeDashboardUrl())->toBeNull();
});
// ─── Log-Viewer ───────────────────────────────────────────────────────────────
it('unterstützt im Log-Viewer stripe- und payone-Kanäle mit stripe als Default', function () {
$this->actingAs(dashboardAdmin());
$view = (new PaymentDashboardController)->logs();
expect($view->getData()['selectedChannel'])->toBe('stripe')
->and($view->getData()['availableChannels'])->toBe(['stripe', 'payone']);
request()->merge(['channel' => 'payone']);
$view = (new PaymentDashboardController)->logs();
expect($view->getData()['selectedChannel'])->toBe('payone');
request()->merge(['channel' => '../etc/passwd']);
$view = (new PaymentDashboardController)->logs();
expect($view->getData()['selectedChannel'])->toBe('stripe');
});
// ─── Uptime-Check ─────────────────────────────────────────────────────────────
it('prüft die Erreichbarkeit von Stripe und PAYONE im Uptime-Check', function () {
Http::fake([
'api.stripe.com/*' => Http::response('', 401),
'api.pay1.de/*' => Http::response('', 200),
]);
$this->artisan('payment:check-uptime')->assertSuccessful();
expect(ProviderUptimeLog::where('provider', 'stripe')->where('is_up', true)->count())->toBe(1)
->and(ProviderUptimeLog::where('provider', 'payone')->where('is_up', true)->count())->toBe(1);
});