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:
parent
dd1c8f9a1e
commit
ad19112750
13 changed files with 600 additions and 45 deletions
|
|
@ -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/',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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">— Auslaufmodell PAYONE → 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>
|
||||
|
|
@ -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'] }} · PAYONE {{ $stats['payone_incidents_30d'] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
220
tests/Feature/PaymentDashboard/PaymentDashboardProviderTest.php
Normal file
220
tests/Feature/PaymentDashboard/PaymentDashboardProviderTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue