mivita/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md
Kevin 671022a28d Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung
- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan
  (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs)
- Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich
  begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail)
- Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail
- Admin: Abo löschen (destroy) inkl. Test
- Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID
- VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt
- Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:19 +00:00

97 KiB
Raw Blame History

Entwicklungsplan: Zahlungsanbieter & Abo-Subscription-Management

Status: In Arbeit (Phase R abgeschlossen) · Erstellt: 2026-06-09 · Zuletzt aktualisiert: 2026-06-25 · Briefing: docs/salescenter/Zahlungabieter.md

⚠️ Statushinweis (2026-06-25): PayPal-direkt zurückgestellt. Der PayPal-Merchant hat die Subscriptions-API bis heute nicht freigegeben. Die Phasen 4–8 (Provider-Abstraktion, PayPal direkt, Migration) sind damit blockiert und pausiert, bis die Freigabe vorliegt. Stattdessen wird die wichtigste unblockierte Anforderung – Kreditkarte aktualisieren (Phase 1) – isoliert und verschärft in einem eigenen Plan umgesetzt: dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md. Dort sind die Stakeholder-Festlegungen (nur Inhaber gibt Karte ein; CRM + Portal; Berater→Kunde-Link; Token-Refresh ohne Abbuchung) und die geklärte PAYONE-Frage K1 (kein updateuser, pseudocardpan genügt) dokumentiert. PAYONE-bezogene Phasen (0–3) bleiben hier gültig.

Dokumentations-Hinweis (verbindlich)

Dieser Plan wird phasenweise abgearbeitet. Für jede Phase gilt:

  • Der Umsetzungsstatus wird nach Abschluss auf ERLEDIGT gesetzt und sauber dokumentiert. (Datum, betroffene Dateien, kurze Begründung).
  • Abweichungen, Detail-Entscheidungen und Verworfenes werden dokumentiert.
  • Jede Änderung wird programmatisch getestet (Pest), bevor die Phase als erledigt gilt.
  • Nach Code-Änderungen läuft ./vendor/bin/pint.

1. Ziel & Briefing-Zusammenfassung

Das Briefing definiert vier zusammenhängende, aber technisch getrennte Bausteine:

# Anforderung Priorität (Briefing)
A Kreditkarte aktualisieren bei bestehenden Abos über PAYONE Als Erstes
B Subscription lösen & neu anlegen (Zahlungsmethode wechseln, Token erneuern) Hoch
C Nachträgliche Zahlung bei fehlgeschlagener Abbuchung (Zahlungslink wie Checkout) Hoch
D PayPal direkt integrieren (nicht mehr über PAYONE); PAYONE-PayPal-Abos parallel betreiben, später migrieren Mittel (nach A–C stabil)

Leitprinzip: PayPal und PAYONE müssen sauber getrennt werden. Kreditkarte bleibt bei PAYONE; PayPal-Abos laufen künftig über das direkte PayPal-Konto.


2. Ist-Zustand (Codebase-Analyse)

2.1 Zahlungsarchitektur heute

Checkout / Abo-Ausführung
        │
        ▼
PayoneController (app/Http/Controllers/Pay/PayoneController.php)
        │
        ├── setPrePayment()      → Erstzahlung Checkout (inkl. Abo-Init CC/PayPal-via-PAYONE)
        ├── setAboPayment()      → Wiederkehrende Abbuchung (userid + pseudocardpan)
        └── checkCreditCard()    → creditcardcheck → pseudocardpan (storecarddata=yes)
        │
        ▼
App\Services\Payone::sendRequest()  → https://api.pay1.de/post-gateway/
        │
        ▼
Api\PayoneController@paymentStatus   → IPN/TSOK-Callback (routes/api.php)
        │
        ▼
Payment::paymentStatusPaidAction()   → Bestellung bezahlt, Abo anlegen, E-Mails, Incentive

2.2 Abo-Datenmodell

Tabelle / Model Relevante Felder Rolle
user_abos / UserAbo payone_userid, clearingtype (cc/wlt), wallettype (PPE), carddata (JSON: pseudocardpan, cardexpiredate) Speichert Zahlungs-Token für wiederkehrende Abbuchungen
shopping_payments / ShoppingPayment clearingtype, carddata, reference, is_abo Jede Zahlungstransaktion
payment_transactions / PaymentTransaction txid, userid, txaction, transmitted_data PAYONE-Callback-Protokoll
user_abo_orders / UserAboOrder paid, status Ausführungs-Historie pro Abo

Abo-Anlage: AboHelper::createNewAbo() kopiert Zahlungsdaten aus der Erstzahlung (ShoppingPayment) in UserAbo.

Abo-Ausführung: Cron user:make_abo_order → UserMakeOrder::makePayment() → PayoneController::setAboPayment().

2.3 Bereits vorhandene Bausteine (wiederverwendbar)

Baustein Datei Nutzen für diesen Plan
Admin/User/Portal Retry AboRetryPaymentService, Routes in crm.php / portal.php Basis für nachträgliche Zahlung; heute nur erneuter Abbuchungsversuch mit bestehendem Token
Zahlungslink (Berater → Kunde) OrderPaymentService, ShoppingInstance (payment=6) Muster für Checkout-ähnlichen Zahlungslink; nicht für fehlgeschlagene Abo-Abbuchungen
Kreditkarten-Check PayoneController::checkCreditCard() Basis für CC-Aktualisierung
Payment-Dashboard Admin/PaymentDashboardController, payment:check-uptime Monitoring; Provider paypal bereits im Incident-Model
PayPal-Panne-Recovery abo:retry-failed-paypal Command Historischer Sonderfall; zeigt Bedarf an robuster Fehlerbehandlung
Race-Condition-Fix Api/PayoneController (Duplikat-Callback-Handling) Muster für idempotente Webhooks

2.4 Bekannte Lücken / Tech Debt

  • Kein payment_provider-Feld in user_abos / shopping_payments – Provider wird implizit über clearingtype/wallettype abgeleitet.
  • Keine User-Self-Service-UI zum Aktualisieren der Zahlungsmethode (weder CC noch PayPal).
  • Kein Abo-Zahlungslink bei Status abo_hold (3) – nur Admin-Retry-Button.
  • PayPal = PAYONE (clearingtype=wlt, wallettype=PPE) – keine direkte PayPal-Integration.
  • PAYONE-Key hardcoded in config/payone.php (Zeile 8) – sollte in .env / config/payone.php via env() ausgelagert werden (Sicherheit).
  • Subscription-Lifecycle (cancel + recreate) existiert nicht – Abo wird bei Checkout einmalig initialisiert und läuft dann mit gespeichertem Token.

2.5 Code-Review: Pay\PayoneController & Callback (Stand 2026-06-09)

Die Zahlungslogik funktioniert produktiv, weist aber strukturelle Schwächen auf, die neue Features (CC-Update, Recovery-Link, PayPal direkt) erschweren würden. Eine Refactoring-Phase vor allen Feature-Phasen ist empfohlen.

Betroffene Klassen

Klasse Zeilen Rolle heute Problem
App\Http\Controllers\Pay\PayoneController ~614 Stateful Payment-Service Falscher Namespace; kein HTTP-Controller
App\Http\Controllers\Api\PayoneController ~250 PAYONE-IPN-Webhook Monolith; echo/exit statt Response
App\Services\Payone ~214 HTTP-Client zum Gateway abort() statt Exceptions; loggt Requests mit PAN
App\Services\PaymentHelper ~172 ELV-Membership-Zahlung Dupliziert Response-Handling aus PayoneController

Aufrufer von Pay\PayoneController: CheckoutController, UserMakeOrder (Cron), PaymentHelper – jeweils via new PayoneController() ohne DI.

Festgestellte Probleme (priorisiert)

Prio Kategorie Befund Risiko
Kritisch Debug-Code var_dump($response); exit() bei unbekanntem APPROVED-Clearingtype (Z. 439–440) Produktions-Abbruch bei Randfällen (z. B. PayPal/Sofort)
Kritisch Fehlerbehandlung PENDING-Status endet mit barem exit() (Z. 454) White Screen, kein User-Feedback
Kritisch PCI/Logging Payone::sendRequest loggt $request komplett – bei creditcardcheck enthält das cardpan/cardcvc2 Compliance-Risiko
Hoch Architektur Klasse mischt Request-Bau, DB-Persistenz, HTTP, Redirects, Session, Tracking Schwer testbar, schwer erweiterbar
Hoch Duplikation PaymentTransaction::create 4× in ResponseData, erneut in PaymentHelper, UserMakeOrder Inkonsistente Protokollierung
Hoch Return-Typen ResponseData() liefert Redirect, Array oder abort()/exit() je nach Kontext Caller müssen implizites Verhalten kennen
Mittel Zustands-API 10+ mutable Properties; init() Pflicht vor jedem Aufruf Fehleranfällig bei parallelen Flows
Mittel setMethod() Mehrere unabhängige if-Blöcke statt match; kein Fehler bei unbekannter Methode Stille Fehler möglich
Mittel Naming/Typisierung Keine Return-Types; Tippfehler setDeliverylData; Konstanten ungenutzt Wartbarkeit
Niedrig Toter Code ~150 Zeilen auskommentierter Legacy-Code Lesbarkeit
Niedrig Api-Controller echo 'TSOK'; exit statt return response('TSOK') Nicht Laravel-idiomatisch

Was gut ist (beibehalten)

  • Trennung HTTP-Transport (App\Services\Payone) vs. Business-Parameter (PayoneController)
  • creditcardcheck mit storecarddata=yes – korrektes PCI-Muster
  • Abo-Init mit recurrence=recurring + unterschiedlichem customer_is_present (Checkout vs. Cron)
  • Api\PayoneController: Duplikat-Callback-Erkennung, txaction-Priorität, lockForUpdate bei paid
  • Unit-Tests für Payone::parseResponse und Netzwerkfehler (tests/Unit/Services/PayoneTest.php)

3. Soll-Architektur

3.1 Provider-Abstraktion (Schichtenmodell)

┌─────────────────────────────────────────────────────────────┐
│  UI: Portal / CRM / Checkout / Zahlungslink                 │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│  PaymentOrchestrator (neu)                                  │
│  - resolveProvider(UserAbo|ShoppingPayment)                 │
│  - initiatePayment / updatePaymentMethod / cancelSubscription│
│  - handleWebhook(provider, payload)                         │
└──────────┬─────────────────────────────┬────────────────────┘
           │                             │
┌──────────▼──────────┐       ┌──────────▼──────────┐
│  PayoneGateway      │       │  PayPalGateway      │
│  (CC, SEPA, …)      │       │  (Subscriptions)    │
│  bestehend, refact. │       │  neu                │
└─────────────────────┘       └─────────────────────┘

Empfehlung: Kein Big-Bang-Rewrite. Zuerst Phase R (Refactoring des Ist-Codes), danach schrittweise Extraktion in App\Services\Payment\PayoneGateway und paralleler PayPalGateway. Pay\PayoneController bleibt vorerst als Deprecated-Fassade, bis alle Aufrufer umgestellt sind.

3.2 Datenmodell-Erweiterungen (Vorschlag)

-- user_abos
payment_provider     ENUM('payone','paypal') DEFAULT 'payone'
payment_token_ref    VARCHAR(255) NULL      -- generischer Token (PayPal subscription_id / payone userid)
payment_method_meta  JSON NULL              -- provider-spezifische Metadaten (maskierte Karte, PayPal email)
subscription_status  ENUM('active','cancelled','migrating','legacy_payone_paypal') DEFAULT 'active'
migrated_from_abo_id INT UNSIGNED NULL      -- Verknüpfung bei CC↔PayPal-Wechsel

-- shopping_payments
payment_provider     ENUM('payone','paypal') DEFAULT 'payone'
external_reference   VARCHAR(255) NULL      -- PayPal capture_id / order_id

-- user_abo_payment_recovery_links (neu, optional)
id, user_abo_id, user_abo_order_id, token (unique), expires_at, used_at, shopping_order_id

Offene Frage O1: Sollen wir payone_userid beibehalten und payment_token_ref nur für PayPal nutzen, oder beide Provider über ein einheitliches Feld abbilden? #payone_userid Behalten, so können wir direkt in der Datenbank sehen, welchen Payment Provider wir ansprechen.

3.3 Provider-Matrix (Zielzustand)

Use Case Provider API
Checkout Einmalzahlung (CC, SEPA, Sofort, …) PAYONE Bestehend
Checkout Abo-Erstzahlung CC PAYONE authorization + recurrence=recurring
Checkout Abo-Erstzahlung PayPal PayPal direkt Subscriptions API v2 / Vault
Wiederkehrende Abo-Abbuchung CC PAYONE authorization + userid + pseudocardpan
Wiederkehrende Abo-Abbuchung PayPal PayPal direkt Subscription billing cycle
CC-Daten aktualisieren PAYONE creditcardcheck + updateuser/updatecard (PAYONE-Doku prüfen)
PayPal-Abo wechseln / neu anlegen PayPal direkt Cancel subscription + Create subscription
Legacy Abo (PayPal via PAYONE) PAYONE Unverändert bis Migration

4. Sicherheitskonzept

4.1 PCI-DSS & Kartendaten

Regel Umsetzung
Keine Speicherung von PAN/CVC Bereits eingehalten: nur pseudocardpan + cardexpiredate in carddata
Karteneingabe im Browser PAYONE Client-API / Hosted iFrame (wie Checkout heute) – keine Rohdaten an eigenen Server
CC-Aktualisierung Gleicher Flow wie Checkout: creditcardcheck mit storecarddata=yes, dann Update des bestehenden userid
Logging Niemals cardpan, cardcvc2 in Logs (payone.log, abo_order.log)

4.2 Webhook-/Callback-Sicherheit

Endpoint Validierung heute Soll
POST /api/payment/status (PAYONE) MD5-Key-Vergleich (config('payone.defaults.key')) Beibehalten; Key aus .env
PayPal Webhooks (neu) – PayPal-Signaturprüfung (verify-webhook-signature), Idempotenz-Store
Abo-Zahlungslink – Signed URL / UUID-Token, TTL, Einmalverwendung, Zuordnung zu user_abo_id

Bestehendes Muster (wiederverwenden): Duplikat-Callback-Erkennung in Api/PayoneController (Zeilen 110–120) – gleiche Logik für PayPal-Events.

4.3 Autorisierung & Missbrauchsschutz

  • CC-Aktualisierung / Zahlungsmethode wechseln: Nur authentifizierter Abo-Inhaber (Portal) oder berechtigter Berater (CRM) + Policy auf UserAbo.
  • Abo-Zahlungslink: Token per E-Mail, optional OTP für Endkunden (Portal hat bereits OTP-Infrastruktur).
  • Rate-Limiting auf Payment-Endpunkte (Laravel throttle).
  • Admin-Aktionen in SysLog protokollieren (bestehendes Muster).

4.4 Secrets & Konfiguration

// config/payone.php – Ziel
'defaults' => [
    'key' => env('PAYONE_KEY_HASH'), // MD5-Hash, nicht Klartext in Repo
    ...
]

// config/services.php – neu
'paypal' => [
    'client_id' => env('PAYPAL_CLIENT_ID'),
    'client_secret' => env('PAYPAL_CLIENT_SECRET'),
    'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
    'mode' => env('PAYPAL_MODE', 'sandbox'),
],

4.5 Test-/Live-Trennung

  • Bestehend: shopping_payments.mode / shopping_orders.mode (test/live).
  • PayPal: Sandbox vs. Live Credentials; Abo-Migration nur innerhalb desselben Modus.

5. Phasen-Übersicht

Phase Titel Aufwand (Schätzung) Priorität Abhängigkeiten
R Payment-Controller Refactoring 4–6 PT P0 – Vorab –
0 Analyse & PAYONE-API-Klärung 2–3 PT Blocker Phase R (R1–R2)
1 Kreditkarte aktualisieren (PAYONE) 5–8 PT P1 Phase 0, Phase R
2 Subscription cancel + recreate (PAYONE CC) 5–7 PT P1 Phase 1
3 Abo-Zahlungslink bei fehlgeschlagener Abbuchung 6–9 PT P1 Phase 0, Phase R
4 Provider-Abstraktion & Datenmodell 3–5 PT P2 Phase R (Teilüberlappung mit R3)
5 PayPal direkt – Checkout & Abo-Neuabschluss 10–15 PT P2 Phase 4
6 PayPal direkt – Wiederkehrende Abbuchung & Webhooks 8–12 PT P2 Phase 5
7 Zahlungsmethode wechseln CC ↔ PayPal 5–8 PT P2 Phase 2, 5, 6
8 Legacy-Migration PAYONE-PayPal → PayPal direkt 4–6 PT P3 Phase 6, 7
9 Monitoring, Tests, Rollout 3–5 PT P1–P3 Jeweils nach Phase

PT = Personentage (1 Entwickler)


5.1 Phase R – Payment-Controller Refactoring ⭐ Vorab-Schritt

Ziel

Bestehende PAYONE-Integration strukturell entflechten, kritische Bugs beseitigen und eine testbare Service-Schicht schaffen – ohne Verhalten im Checkout/Cron/IPN zu ändern (Strangler-Pattern).

Phase R ist Voraussetzung für Phase 1–3 und reduziert das Risiko, neue Features in den monolithischen Controller zu bauen.

Grundsätze

  1. Kein Verhaltens-Bruch: Bestehende Feature-Tests + manueller Checkout-Smoke-Test nach jedem Sub-Step.
  2. Strangler-Figur: Neue Services extrahieren; Pay\PayoneController delegiert vorerst nur weiter.
  3. DTOs statt Property-Soup: Request/Response als immutable Value Objects.
  4. Exceptions statt abort()/exit() in Service-Schicht; HTTP-Schicht entscheidet über Redirect/Fehlerseite.

Ziel-Struktur nach Phase R

app/Services/Payment/
├── Payone/
│   ├── PayoneClient.php              ← HTTP (aus App\Services\Payone)
│   ├── PayoneRequestBuilder.php      ← Parameter-Mapping (setMethod, personalData, …)
│   ├── PayonePaymentService.php      ← authorize, creditcardcheck, aboCharge, managemandate
│   ├── PayoneWebhookHandler.php      ← Logik aus Api\PayoneController
│   ├── PayoneTransactionRecorder.php ← PaymentTransaction::create (eine Stelle)
│   └── DTOs/
│       ├── PayonePaymentRequest.php
│       ├── PayonePaymentResult.php   ← status, txid, userid, redirectUrl, errors
│       └── PayoneMethodConfig.php    ← clearingtype, wallettype, …
├── PaymentMethodResolver.php         ← 'wlt#PPE' → PayoneMethodConfig (match)
└── Exceptions/
    ├── PayoneTransportException.php
    └── PayoneDeclinedException.php

app/Http/Controllers/Pay/PayoneController.php   ← @deprecated Fassade
app/Http/Controllers/Api/PayoneController.php   ← schlank: validate → Handler → response('TSOK')

Unterphasen

R1 – Hotfixes & Logging-Härtung ✅ ERLEDIGT (2026-06-09)

# Aufgabe Datei Status
R1.1 var_dump/exit bei APPROVED ersetzen: Log + abort(403) mit Fehlercode 1002 Pay\PayoneController Z. 435–441 ✅
R1.2 PENDING-Status: abort(503) mit User-Meldung statt barem exit() Pay\PayoneController Z. 444–451 ✅
R1.3 redactRequest() filtert cardpan/cardcvc2 aus allen Catch-Block-Logs App\Services\Payone Z. 182–188 ✅
R1.4 5× unreachable exit; nach return redirect() entfernt Pay\PayoneController ✅
R1.5 PAYONE_KEY aus .env statt Literal im Code config/payone.php Z. 8 ✅

Betroffene Dateien:

  • app/Http/Controllers/Pay/PayoneController.php
  • app/Services/Payone.php
  • config/payone.php

Abweichungen / Entscheidungen:

  • R1.1 entschieden gegen Redirect zu transaction_approved (erfordert $payt → würde DB-Create vor Fehlerbehandlung nötig machen); stattdessen strukturiertes Error-Log + abort(403).
  • R1.3 filtert nur cardpan + cardcvc2; cardexpiredate wurde bewusst nicht redaktiert (kein PCI-Scope, hilfreich für Debugging).
  • R1.5 war im Plan als Lücke notiert (Abschnitt 2.4) und wurde als Teil von R1 mit umgesetzt.

Tests: PayoneTest (5 Tests) weiterhin grün. Separater Test für Log-Redaction steht noch aus (als niedrig priorisiert eingestuft – Verhalten ist durch array_diff_key-Logik hinreichend einfach).

R2 – PayoneTransactionRecorder & Response-Normalisierung ✅ ERLEDIGT (2026-06-09)

Extrahiert die duplizierten PaymentTransaction::create-Blöcke in eine zentrale Klasse:

// app/Services/Payment/Payone/PayoneTransactionRecorder.php
PayoneTransactionRecorder::recordFromResponse(
    ShoppingPayment $payment,
    string $requestType,
    array $response,
    int $useridFallback = 0,
): PaymentTransaction

PayoneTransactionRecorder::recordInvoicePayment(
    ShoppingPayment $payment,
    string $requestType,
    array $requestPayload,
): PaymentTransaction

Betroffene Dateien:

  • app/Services/Payment/Payone/PayoneTransactionRecorder.php (neu)
  • app/Http/Controllers/Pay/PayoneController.php — 4× PaymentTransaction::create ersetzt
  • app/Services/PaymentHelper.php — 3× create zu 1× recordFromResponse + statusMap vereinfacht
  • app/Cron/UserMakeOrder.php — recordPaymentTransaction() delegiert an Recorder

Abweichungen / Entscheidungen:

  • Plan sah PayonePaymentResult-DTO in der Recorder-Signatur vor (Phase R4-Vorgriff). Umgesetzt mit array $response statt DTO, da DTOs erst in R4 eingeführt werden. Migration auf DTO ist dann ein reiner Signatur-Swap ohne Logikänderung.
  • recordInvoicePayment() als separate Methode für FNC/MIV-Sonderfall (kein API-Call, synthetischer Status FNCMIV). Plan hatte das nicht explizit vorgesehen.
  • transmitted_data wird nun auch bei ERROR und REDIRECT gespeichert (vorher nur bei APPROVED in Pay\PayoneController). Verbessert Debugbarkeit, kein Verhaltens-Bruch.
  • PaymentHelper::initELVPayment: 3 separate if-Blöcke zu 1 in_array-Check + statusMap-Array konsolidiert.

Tests: tests/Feature/PayoneTransactionRecorderTest.php (5 Tests, 44 Assertions) grün. PayoneTest (5 Tests) weiterhin grün.

R3 – PayoneRequestBuilder & PaymentMethodResolver ✅ ERLEDIGT (2026-06-09)

Betroffene Dateien:

  • app/Services/Payment/Payone/PayoneMethodConfig.php (neu) — Value Object für Method-Parameter
  • app/Services/Payment/Payone/PaymentMethodResolver.php (neu) — resolve(string, array): PayoneMethodConfig
  • app/Services/Payment/Payone/PayoneRequestBuilder.php (neu) — buildPersonalData, buildDeliveryData, buildRequest
  • app/Http/Controllers/Pay/PayoneController.php — setMethod(), setPersonalData(), setDeliverylData() delegieren; 3× array_merge → buildRequest()

Abweichungen / Entscheidungen:

  • Plan nannte buildAuthorizationRequest() als einheitliche Build-Methode; umgesetzt als buildRequest() mit allen Segmenten als optionale Parameter — flexibler, deckt onlyPaymentResponse() und ResponseData() und checkBankAccount() gleichermaßen ab.
  • $this->method bleibt intern ein array (kein Breaking Change); PayoneMethodConfig::toArray() liefert das Array an der Resolver-Grenze. Migration auf typisiertes Property in R4.
  • checkCreditCard() und checkBankAccount() nutzen weiterhin array_merge($this->default, $this->prepayment) direkt — keine Method-Config nötig, da kein setMethod()-Aufruf dort.
  • fnc#PYV (PAYONE Rechnungskauf) wirft InvalidArgumentException statt still ignoriert zu werden — bisheriger stiller Fehler behoben.

Tests: tests/Unit/Services/Payment/PaymentMethodResolverTest.php (11 Tests, 64 Assertions) grün. Alle cc, elv, vor, wlt#PPE, wlt#PDT, sb#PNT, sb#GPY, fnc#MIV + 3 Error-Cases geprüft.

R4 – PayonePaymentService ✅ ERLEDIGT (2026-06-09)

Betroffene Dateien:

  • app/Services/Payment/Payone/DTOs/PayonePaymentResult.php (neu) — immutables DTO mit status, txid, userid, redirectUrl, errorMessage, shoppingPayment
  • app/Services/Payment/Payone/Exceptions/PayoneTransportException.php (neu)
  • app/Services/Payment/Payone/Exceptions/PayoneDeclinedException.php (neu)
  • app/Services/Payment/Payone/PayonePaymentService.php (neu) — checkCreditCard, checkBankAccount, chargeAbo
  • app/Http/Controllers/Pay/PayoneController.php — setAboPayment, onlyPaymentResponse, checkCreditCard, checkBankAccount delegieren an Service
  • database/migrations/2026_06_09_182614_add_carddata_to_shopping_payments.php (neu) — fehlende Spalte aus Produktion nachträglich migriert

Implementierte Methoden (alle returnen PayonePaymentResult, ?Client $client für Test-Injection):

Methode Ersetzt
checkCreditCard(array $data, string $mode): PayonePaymentResult PayoneController::checkCreditCard()
checkBankAccount(array $data, int $amount, string $currency, ShoppingUser $shoppingUser, string $mode): PayonePaymentResult PayoneController::checkBankAccount()
chargeAbo(UserAbo, ShoppingOrder, ShoppingUser, int $amountCents, string $currency): PayonePaymentResult setAboPayment + onlyPaymentResponse

Abweichungen / Entscheidungen:

  • initiateCheckoutPayment (Plan) noch nicht extrahiert — Checkout-Pfad (setPrePayment + ResponseData) enthält HTTP-Concerns (Redirect, Session, CheckoutFunnelTracker); Extraktion erfolgt in Phase 1, wenn ein sauberer Aufrufer entsteht.
  • chargeAbo nimmt zusätzlich ShoppingUser $shoppingUser als Parameter (Plan hatte nur 3 Parameter) — nötig, weil buildPersonalData den User braucht.
  • Abo-Pfad im Controller: setAboPayment triggert chargeAbo sofort; onlyPaymentResponse gibt gecachtes lastPaymentResult zurück → UserMakeOrder-Aufrufer unverändert.
  • setPersonalData() bleibt im Controller als No-Op nach setAboPayment (Checkout-Pfad nutzt es weiterhin).
  • shopping_payments.carddata war in Produktion vorhanden aber nie migriert — fehlende Migration erstellt (idempotent mit Schema::hasColumn-Check).

Tests: tests/Feature/PayonePaymentServiceTest.php (7 Tests, 35 Assertions) — PayonePaymentResult-DTO, checkCreditCard VALID/ERROR, chargeAbo APPROVED mit DB-Check, chargeAbo ERROR mit ShoppingPayment-Nachweis. Alle 28 PAYONE-Tests (155 Assertions) grün.

R5 – Api-Webhook entflechten ✅ ERLEDIGT (2026-06-10)

Betroffene Dateien:

  • app/Services/Payment/Payone/PayoneWebhookHandler.php (neu) — handle(array $data): PayoneWebhookResult, gesamte Logik aus Api\PayoneController::paymentStatus (Validierung 2001–2006, Duplikat-Erkennung, txaction-Priorität, DB-Lock-Paid-Pfad, Mail)
  • app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php (neu) — Factory-States rejected(), duplicateIgnored(), processed(); response ist immer TSOK
  • app/Http/Controllers/Api/PayoneController.php — von ~250 auf 18 Zeilen reduziert; konstruktor-injizierter Handler, return response($result->response) statt echo 'TSOK'; exit

Abweichungen / Entscheidungen:

  • PayoneWebhookValidator (Plan-Option) nicht als eigene Klasse extrahiert — die 6 Validierungs-Guards sind kontextgebunden (Order/Payment-Lookups, Preisabgleich) und im Handler linear lesbarer als ein separater Validator.
  • Kein FormRequest — PAYONE sendet application/x-www-form-urlencoded ohne CSRF; die bestehende Param-Vollständigkeitsprüfung (2001) bleibt im Handler, da bei Fehlern weiterhin TSOK (nicht 422) an PAYONE zurückgehen muss.
  • throw $e im Paid-Pfad (DB-Transaktionsfehler) bleibt erhalten → PAYONE wiederholt den Callback; kein TSOK bei echtem Verarbeitungsfehler. Bewusst kein Verhaltens-Bruch.
  • txaction-Priorität als private const TXACTION_PRIORITY statt lokalem Array (vorher inline).
  • Verhalten 1:1 erhalten: alle MyLog::writeLog-Aufrufe, Util::utf8ize, CheckoutFunnelTracker, lockForUpdate-Doppelprüfung identisch übernommen.

Tests: tests/Feature/PayoneWebhookHandlerTest.php (11 Tests, 32 Assertions): Rejections 2001–2006, Duplikat-Ignorierung (paid + appointed), appointed-Verarbeitung, Prioritäts-Downgrade-Schutz, Paid-Happy-Path (Order wird paid). PaymentAboCallbackCreatesUserAboTest (gleicher paymentStatusPaidAction-Pfad) weiterhin grün.

  • Hinweis: PayoneRaceConditionTest schlägt bereits auf sauberem Stand fehl (DatabaseTransactions ohne migrierte Test-DB) — vorbestehend, von R5 unberührt.

R6 – PayoneClient: Exceptions statt abort ✅ ERLEDIGT (2026-06-10)

Betroffene Dateien:

  • app/Services/Payone.php — 5× abort(403, …) → throw new PayoneTransportException(…) (Codes 1002/1003/1004, Meldungstexte identisch)
  • app/Exceptions/Handler.php — PayoneTransportException in $dontReport; render() mappt sie auf HttpException(403) (Fallback = altes abort(403)-Verhalten)
  • app/Http/Controllers/Web/CheckoutController.php — processPaymentMethod() fängt PayoneTransportException → Redirect zu checkout.checkout_card mit Flash-Fehlermeldung
  • tests/Unit/Services/PayoneTest.php — Netzwerkfehler-Test auf PayoneTransportException umgestellt; 1004-Fall ergänzt

Abweichungen / Entscheidungen:

  • Zweistufige Fehlerbehandlung statt nur Checkout-Catch: Der globale Handler::render() bildet uncaught PayoneTransportException 1:1 auf eine 403-Seite ab. So bleiben alle HTTP-Aufrufer (insb. PaymentHelper::initELVPayment im Membership-Flow) ohne Anpassung verhaltensgleich; der Checkout fängt zusätzlich explizit ab für den UX-freundlicheren Redirect.
  • $dontReport-Eintrag nötig: abort(403)/HttpException wird von Laravel nicht gemeldet; eine eigene Exception hingegen schon → hätte doppelte Exception-Mails (zusätzlich zur bestehenden MyLog-Mail) ausgelöst. Eintrag in $dontReport verhindert das.
  • Cron unverändert: UserMakeOrder::makePayment fängt bereits \Exception; PayoneTransportException extends RuntimeException → wird geloggt + re-thrown wie zuvor (vorher HttpException, ebenfalls \Exception). Kein CLI-abort mehr.
  • Controller-interne Business-Aborts (PENDING 1000, unbekannter clearingtype 1002, kein Status 1001 in Pay\PayoneController::ResponseData) bleiben als abort() — sind keine Transportfehler und nicht Teil von R6.

Tests: PayoneTest (7 Tests inkl. 1002/1004-Transportfehler), PayoneTransportExceptionHandlingTest (2 Tests: 403-Render + shouldReport=false). Gesamte R-Phase-Suite: 44 grün.

R7 – Aufräumen & DI-Vorbereitung ✅ ERLEDIGT (2026-06-10)

Betroffene Dateien:

  • app/Http/Controllers/Pay/PayoneController.php — ~150 Zeilen auskommentierter Legacy-Code entfernt (getPDFFile, checkStatus, Shopware-Referenzblöcke, Karten-/clearingtype-Listen); @deprecated-Klassen-Docblock mit Migrationshinweis + clearingtype-Referenz; Konstruktor löst Service via app(PayonePaymentService::class) aus dem Container
  • app/Providers/AppServiceProvider.php — PayonePaymentService als Singleton registriert (config('payone.defaults'), realer Guzzle-Client zur Laufzeit)

Abweichungen / Entscheidungen:

  • Controller-Länge 614 → 328 Zeilen, nicht unter das 150-Zeilen-Ziel. Grund: setPrePayment() (Checkout-State-Aufbau, Abo-Init CC/PayPal) und ResponseData() (HTTP-Redirects/Session/Status-Mapping, ~110 Zeilen) bleiben bewusst im Controller — sie sind reine HTTP-/Checkout-Concerns. Deren Extraktion in PayonePaymentService::initiateCheckoutPayment() ist Phase-1-Arbeit (siehe R4-Abweichung), nicht Teil des reinen Refactorings ohne neuen Aufrufer.
  • DI live geschaltet: Controller nutzt jetzt den Container-Singleton statt new PayonePaymentService(...). Sicher, weil mode immer explizit pro Methodenaufruf übergeben wird — der geteilte Singleton braucht nur die Basis-Defaults. Pay\PayoneController selbst bleibt new-instanziiert (Aufrufer-Migration auf DI erst in Phase 1, gemäß OR2).
  • clearingtype-Dokumentation aus dem Datei-Header in den Klassen-Docblock überführt (statt gelöscht) — bleibt als Referenz erhalten, verweist auf PaymentMethodResolver.
  • Return-Types: an allen neuen Klassen (R3–R6) vorhanden; der Legacy-Controller behält seine bestehenden Signaturen (gemischte Rückgaben in ResponseData), um Aufrufer nicht zu brechen.

Tests: Keine neuen Tests nötig (reines Cleanup + DI-Registrierung). Container-Auflösung verifiziert; gesamte R-Phase-Suite (44 Tests) weiterhin grün.


Phase R abgeschlossen ✅ (2026-06-10)

Alle Unterphasen R1–R7 erledigt. Die PAYONE-Integration ist entflochten in:

  • PayonePaymentService (Checkout-/Abo-/CC-/SEPA-Requests) + PayonePaymentResult-DTO
  • PayoneRequestBuilder + PaymentMethodResolver + PayoneMethodConfig
  • PayoneTransactionRecorder (zentrale PaymentTransaction-Erstellung)
  • PayoneWebhookHandler + PayoneWebhookResult (IPN/TSOK-Callback)
  • PayoneTransportException + Handler-Mapping (keine abort() in der Service-Schicht)

Pay\PayoneController ist @deprecated-Fassade; verbleibender Checkout-HTTP-Flow wird in Phase 1 auf PayonePaymentService::initiateCheckoutPayment() gehoben.

Abgrenzung zu Phase 4

Phase R Phase 4
PAYONE-Code refactoren Provider-Feld in DB, PayPal-Gateway neu
Gleiche API, sauberer Code Neue Schnittstellen (PaymentGatewayInterface)
Kein PayPal PayPal direkt

Phase R liefert die PayonePaymentService-Klasse, die Phase 4 als PayoneGateway implements PaymentGatewayInterface formalisiert.

Akzeptanzkriterien Phase R

  • Kein var_dump, kein barem exit() in Payment-Flows (R1 – 2026-06-09)
  • PAN/CVC erscheinen nicht in storage/logs/payone.log (R1 – 2026-06-09)
  • PaymentTransaction-Erstellung an einer Stelle (PayoneTransactionRecorder) (R2 – 2026-06-09)
  • Unit-Tests für PaymentMethodResolver + PayoneRequestBuilder (R3 – 2026-06-09)
  • Feature-Tests Checkout-Callback und Abo-Cron unverändert grün (laufend geprüft)
  • Pay\PayoneController delegiert an Services (setAboPayment, checkCreditCard, checkBankAccount, onlyPaymentResponse) (R4 – 2026-06-09); Legacy-Code entfernt, 614 → 328 Zeilen (R7 – 2026-06-10). Das 150-Zeilen-Ziel bleibt offen, weil der Checkout-HTTP-Flow (setPrePayment/ResponseData) erst in Phase 1 extrahiert wird.

Risiken & Mitigation

Risiko Mitigation
Regression im Live-Checkout Sub-Steps einzeln deployen; R1 Hotfixes sofort, Rest hinter Feature-Flag optional
Refactoring zieht sich Zeitbox 6 PT; R4 bei Bedarf kürzen (nur Abo-Pfad + Checkout-Pfad)
Doppelarbeit mit Phase 4 R3/R4 API so designen, dass PayoneGateway sie 1:1 übernimmt

Offene Fragen (Phase R)

ID Frage
OR1 Soll Pay\PayoneController langfristig gelöscht oder dauerhaft als Fassade bleiben?
#Wenn er nicht mehr gebraucht wird, kann entfernt werden
OR2 DI sofort (PayonePaymentService inject) oder Übergang mit new + Fassade? Empfehlung: Fassade + Service-Container-Registrierung, Aufrufer migrieren in Phase 1.
#Wie Empfehlung
OR3 Soll PaymentHelper in Phase R mit refactored werden oder erst bei Membership-Refactoring? Empfehlung: mindestens R2 (TransactionRecorder) anwenden.
#Wie Empfehlung

5.2 Code-Review der bisherigen Umsetzung (2026-06-10)

Prüfstand

Geprüft wurden die Phase-R-Änderungen in:

  • app/Services/Payment/Payone/*
  • app/Http/Controllers/Pay/PayoneController.php
  • app/Http/Controllers/Api/PayoneController.php
  • app/Services/Payone.php
  • app/Services/PaymentHelper.php
  • app/Cron/UserMakeOrder.php
  • app/Http/Controllers/Web/CheckoutController.php
  • config/payone.php
  • zugehörige Tests unter tests/Feature/*Payone* und tests/Unit/Services/*Payone*

Ergebnis-Zusammenfassung

Die Umsetzung von Phase R ist in der Tendenz sauber und testbar: Service-Schicht, Request-Builder, Method-Resolver, DTOs, Webhook-Handler und Transport-Exception-Mapping sind vorhanden und die fokussierte PAYONE-Test-Suite ist grün. Es gibt aber einige Punkte, die vor Phase 1 korrigiert oder zumindest bewusst entschieden werden sollten.

Befunde

Prio Befund Datei / Stelle Risiko Empfehlung
Hoch PAYONE-Key-Konfiguration weicht vom Sicherheitsziel ab: Plan-Ziel war PAYONE_KEY_HASH (Hash in .env), umgesetzt ist hash('md5', env('PAYONE_KEY', '')) mit Klartext-Key in .env. Zusätzlich ergibt fehlender Env-Wert still den MD5 von leerem String. config/payone.php Z. 8, .env Secret bleibt als Klartext im Environment/Deployment; falscher oder leerer Key wird spät bemerkt; Webhook-Validierung und API-Calls brechen dann schwer diagnostizierbar. Entweder Plan an Klartext-Env anpassen oder besser Ziel umsetzen: PAYONE_KEY_HASH verwenden, keinen Runtime-Hash bilden, bei leerem Wert hart fehlschlagen/Config-Check ergänzen.
Hoch PaymentTransaction-Erstellung ist nicht vollständig zentralisiert: PayoneWebhookHandler erstellt Transaktionen weiterhin direkt per PaymentTransaction::create. PayoneWebhookHandler Z. 146–160 Akzeptanzkriterium "an einer Stelle" ist nur teilweise erfüllt; spätere Redaction/Normalisierung kann Webhook-Transaktionen vergessen. Webhook-Persistenz in PayoneTransactionRecorder::recordWebhookCallback(...) verschieben oder Akzeptanzkriterium auf "API-Response-Transaktionen zentralisiert" präzisieren.
Mittel Recorder filtert transmitted_data nicht. Der aktuelle recordFromResponse-Pfad speichert meist Responses, aber recordInvoicePayment speichert Request-Payloads unverändert und der Plan behauptet allgemein PCI-Filterung im Recorder. PayoneTransactionRecorder Z. 21–32, 46–54; Observability-Abschnitt 22.4 Wenn künftig versehentlich Request-Daten mit cardpan/cardcvc2 an den Recorder gehen, landen sie in der DB. Gemeinsame redactPayload()-Methode im Recorder ergänzen (mind. cardpan, cardcvc2, optional key, IBAN-Masking je nach Datenschutzentscheidung) und Tests dafür hinzufügen.
Mittel Webhook-Handler greift auf $data['txaction'] und $data['mode'] zu, obwohl die Vollständigkeitsprüfung diese Felder nicht erzwingt. Das entspricht vermutlich dem alten Verhalten, ist aber in der extrahierten Klasse eine vermeidbare Notice-/Undefined-Index-Kante. PayoneWebhookHandler Z. 38, 115, 156, 162–163, 183, 187, 193 Malformed Callback mit vorhandenen Pflichtfeldern, aber ohne txaction oder mode, erzeugt PHP-Warnings/Fehler statt sauberem TSOK mit Fehlercode. Pflichtprüfung um txaction und mode erweitern oder Defaults setzen (mode optional mit $shopping_payment->mode).
Mittel UserMakeOrder::makePayment() loggt den Response-Status als Objektproperty ($response->status), erhält aber aus onlyPaymentResponse() weiterhin ein Array. UserMakeOrder.php Z. 77–83 Irreführendes Log ("unbekannt") und je nach PHP-Konfiguration Warning "Attempt to read property on array". Auf ($response['status'] ?? 'unbekannt') ändern.
Niedrig PayoneTransportException verliert den ursprünglichen Guzzle-Fehler als $previous. Payone::sendRequest() Catch-Blöcke Debugging wird unnötig schwer, auch wenn MyLog Details enthält. throw new PayoneTransportException($userMessage, 0, $e) ermöglichen (Konstruktor nicht nötig, RuntimeException kann das bereits).
Niedrig PayonePaymentService::checkCreditCard() validiert Input nicht und greift direkt auf Array-Keys zu. PayonePaymentService Z. 24–37 In Phase 1 kann ein unvollständiger CC-Update-Request zu PHP-Fehlern statt User-Fehlermeldung führen. Für Phase 1 zwingend UpdateAboCreditCardRequest nutzen; Service optional mit klarer Domain-Exception absichern.

Positiv bestätigt

  • Payone::sendRequest() redaktiert cardpan und cardcvc2 in Transportfehler-Logs.
  • PayoneTransportException wird global wieder auf 403 gemappt und im Checkout benutzerfreundlich abgefangen.
  • PayoneWebhookHandler hält die bestehende TSOK-Semantik bei Validierungsfehlern und lässt echte Paid-Pfad-Exceptions weiterwerfen, damit PAYONE erneut zustellt.
  • PaymentMethodResolver wirft bei unbekannten Methoden explizite Exceptions statt stillem Fehler.
  • Abo-Abbuchung (chargeAbo) erstellt weiterhin ShoppingPayment vor dem API-Call und gibt die Response kompatibel an den alten Cron-Aufrufer zurück.

Verifikation

Ausgeführt am 2026-06-10:

./vendor/bin/pest tests/Unit/Services/PayoneTest.php \
  tests/Unit/Services/Payment/PaymentMethodResolverTest.php \
  tests/Feature/PayoneTransactionRecorderTest.php \
  tests/Feature/PayonePaymentServiceTest.php \
  tests/Feature/PayoneWebhookHandlerTest.php \
  tests/Feature/PayoneTransportExceptionHandlingTest.php

Ergebnis: 42 Tests bestanden, 1 optionaler Netzwerktest übersprungen, 190 Assertions.

./vendor/bin/pint --test app/Services/Payment/Payone app/Services/Payone.php \
  app/Http/Controllers/Pay/PayoneController.php app/Http/Controllers/Api/PayoneController.php \
  app/Services/PaymentHelper.php app/Cron/UserMakeOrder.php app/Exceptions/Handler.php \
  app/Http/Controllers/Web/CheckoutController.php app/Providers/AppServiceProvider.php \
  config/payone.php tests/Unit/Services/PayoneTest.php \
  tests/Unit/Services/Payment/PaymentMethodResolverTest.php \
  tests/Feature/PayoneTransactionRecorderTest.php tests/Feature/PayonePaymentServiceTest.php \
  tests/Feature/PayoneWebhookHandlerTest.php tests/Feature/PayoneTransportExceptionHandlingTest.php

Ergebnis: Pint --test grün (25 Dateien).

Zusätzlich: php -l für die zentralen PAYONE-Dateien ohne Syntaxfehler.

Review-To-dos vor Phase 1

  1. PAYONE-Key-Strategie finalisiert (2026-06-10) — PAYONE_KEY_HASH (MD5-Hash, kein Klartext). config/payone.php liest den Wert direkt (kein Runtime-hash()), fehlt er → harter Boot-RuntimeException. Klartext-PAYONE_KEY aus .env entfernt; phpunit.xml + .env.example ergänzt. Der Hard-Fail schließt zugleich die '' != ''-Webhook-Bypass-Lücke strukturell (Key kann nie leer sein).
    • Hash-Erzeugung: Der Klartext-Key wird zur Laufzeit nie gebraucht (API + Webhook nutzen nur den Hash). Für die einmalige Hash-Erzeugung dient der Artisan-Command payone:hash-key (verdeckte Eingabe via laravel/prompts, optional --write schreibt PAYONE_KEY_HASH direkt in die .env). So muss der Klartext-Key nicht dauerhaft in der .env liegen.
  2. Webhook-Persistenz zentralisiert (2026-06-10) — PayoneTransactionRecorder::recordWebhookCallback() neu; PayoneWebhookHandler ruft nur noch den Recorder. PaymentTransaction::create existiert jetzt ausschließlich im Recorder.
  3. Recorder-Redaction ergänzt (2026-06-10) — redactPayload() strippt cardpan/cardcvc2 aus transmitted_data in allen drei Record-Methoden; Test strips cardpan and cardcvc2 … hinzugefügt.
  4. Webhook-Pflichtfelder abgesichert (2026-06-10) — txaction ist jetzt Pflichtfeld (sonst 2001); mode fällt im Recorder auf $payment->mode zurück; restliche $data['mode']-Zugriffe sind null-sicher. Test rejects a callback missing txaction hinzugefügt.
  5. UserMakeOrder-Status-Log korrigiert (2026-06-10) — liest $response['status'] per Array-Zugriff statt $response->status.

Zusätzlich (Niedrig-Befund): Payone::sendRequest reicht die ursprüngliche Guzzle-Exception als $previous an PayoneTransportException weiter.

Offen bleibt nur der Niedrig-Befund zur Input-Validierung in checkCreditCard() — bewusst nach Phase 1 verschoben, da dort der UpdateAboCreditCardRequest (Form Request) eingeführt wird.

Verifikation der Fixes (2026-06-10): 47 Tests grün (216 Assertions); pint grün; Hard-Fail bei fehlendem PAYONE_KEY_HASH manuell bestätigt.


5.2.1 Nachprüfung Abo-Initialisierung / Doppelabo-Schutz (2026-06-10)

Ausgangsbefund: Neu gestartete Abos mit noch nicht bestätigter Erstzahlung konnten in den Abo-Übersichten fehlen, weil mehrere Queries nur status > 1 berücksichtigt haben. Dadurch war für den User nicht sichtbar, ob der Checkout schon ein Abo initiiert hat. Zusätzlich konnte bei Kunden-Abos ein reiner Zahlungslink (ShoppingInstance.payment=6) eine offene Abo-Initiierung repräsentieren, ohne in /user/abos/ot sichtbar zu sein.

Umgesetzt:

  • UserAbo-Übersichten für Eigen-, Kunden-, Team- und Portal-Abos berücksichtigen initiierte Abos ab status >= 1.
  • Pending-Erstzahlungen werden mit Statushinweis angezeigt: Zahlung noch offen, Zahlung in Verarbeitung oder Zahlung fehlgeschlagen.
  • Fehlgeschlagene, noch nicht aktive Erstzahlungs-Abos können gelöscht werden; solange noch kein Zahlungsfehler vorliegt bzw. die Zahlung beim Anbieter in Bearbeitung ist, ist Löschen gesperrt.
  • Der Checkout blockiert neue Abo-Bestellungen, sobald bereits ein Abo initiiert ist. Das gilt für eigene Abos und Kunden-Abos.
  • /user/abos/ot zeigt zusätzlich offene Abo-Zahlungslinks aus /user/order/payment/links, sofern shopping_data.is_for=abo-ot-customer und shopping_data.is_abo=true.
  • Abo-Zahlungslinks werden in /user/abos/ot ausgeblendet, sobald daraus bereits ein UserAbo entstanden ist. Dadurch erscheint ein bezahltes, aber vom Anbieter noch nicht final bestätigtes Abo nicht doppelt als Zahlungslink und als Abo.
  • Abo-Zahlungslinks sind löschbar bei Status 0/1 (Link gesendet/geöffnet) und wieder bei 5/6 (Fehler/abgebrochen). Bei Status 2/3/4 (Zahlung läuft bzw. Anbieter wartet) und 10 (bezahlt) ist Löschen gesperrt.
  • Die allgemeine Zahlungslink-Übersicht blendet Löschen ebenfalls aus, wenn der Status nicht löschbar ist, und zeigt Flash-Meldungen bei blockiertem Löschen.

Betroffene Kernstellen:

  • app/Models/UserAbo.php
  • app/Services/AboHelper.php
  • app/Services/OrderPaymentService.php
  • app/Http/Controllers/User/AboController.php
  • app/Http/Controllers/User/OrderController.php
  • app/Http/Controllers/User/OrderPaymentController.php
  • resources/views/user/abo/index.blade.php
  • resources/views/user/order/payment/index.blade.php
  • routes/domains/crm.php
  • tests/Feature/AboPendingInitialPaymentTest.php

Verifikation (2026-06-10):

./vendor/bin/pest tests/Feature/AboPendingInitialPaymentTest.php \
  tests/Unit/Services/PaymentAboCallbackCreatesUserAboTest.php \
  tests/Feature/AdminAboRetryPaymentTest.php

Ergebnis: 12 Tests bestanden, 38 Assertions.

./vendor/bin/pint --test app/Models/UserAbo.php app/Services/AboHelper.php \
  app/Services/OrderPaymentService.php app/Http/Controllers/User/AboController.php \
  app/Http/Controllers/User/OrderController.php app/Http/Controllers/User/OrderPaymentController.php \
  app/Http/Controllers/User/TeamController.php app/Http/Controllers/Portal/AboController.php \
  resources/views/user/abo/index.blade.php resources/views/user/order/payment/index.blade.php \
  resources/views/user/team/abos.blade.php resources/views/user/team/customer_abos.blade.php \
  resources/views/portal/abo/my_abo.blade.php resources/lang/de/abo.php resources/lang/en/abo.php \
  routes/domains/crm.php tests/Feature/AboPendingInitialPaymentTest.php

Ergebnis: Pint --test grün (17 Dateien).


5.3 KI-Code-Review & Architektur-Optimierungen (Gemini)

Da die Implementierung aus Phase R formal abgeschlossen wurde, ergeben sich für die folgenden Phasen (insb. Phase 1, 3 und 6) noch architektonische Optimierungspotenziale, um die Systemstabilität und Sicherheit zu maximieren.

1. Datenkonsistenz & Rollback bei externen API-Calls (Phase 1)

Befund: Bei der CC-Aktualisierung (PAYONE updateuser) muss nach erfolgreichem API-Call die lokale Datenbank (user_abos) aktualisiert werden. Optimierung: Da externe API-Calls nicht in eine DB-Transaktion gerollt werden können, muss das anschließende DB-Update mit einem strikten try-catch abgesichert werden. Schlägt das DB-Update fehl (z. B. durch einen Datenbank-Deadlock), sind PAYONE und MIVITA asynchron (PAYONE hat die neue Karte, MIVITA die alte). Es muss zwingend ein CRITICAL-Log-Alert ausgelöst und ein Auto-Incident im Payment-Dashboard generiert werden, der auf diese Inkonsistenz hinweist.

Anmerkung (Claude, 2026-06-10): Zustimmend, aber ein Alert allein heilt die Inkonsistenz nicht – er macht sie nur sichtbar. Ergänzend zwingend:

  • Detektierbare Spur statt nur Alert: Vor dem API-Call ein card_update_pending-Flag/Zeitstempel auf user_abos setzen. Ein Crash/Deadlock nach dem PAYONE-Call hinterlässt damit einen auffindbaren Zustand (sonst weiß niemand, dass etwas offen ist).
  • Idempotenter Reparaturpfad: Das DB-Update muss wiederholbar sein (erneutes Schreiben desselben pseudocardpan), damit ein Recovery-Job / manueller Retry die Lücke schließen kann. Optional: bei Bedarf den aktuellen Kartenstatus aktiv von PAYONE nachziehen.
  • Reihenfolge bleibt: PAYONE OK → DB-Update (nie umgekehrt); der neue pseudocardpan ist token-sicher loggbar (PAN/CVC fallen ohnehin durch die Recorder-Redaction aus R-Review).
  • Auto-Incident mit Bestandsmitteln umsetzbar (MyLog + Incident-Model des Dashboards, Abschnitt 22) – keine neue Infrastruktur nötig.

Befund: Der Plan sieht UUIDs für die Recovery-Links vor (O12: 72h TTL). Optimierung: Ein Klartext-Speichern der UUID in der Tabelle user_abo_payment_recovery_links ist bei Datenbank-Leaks ein hohes Risiko. Der Token sollte in der DB gehasht (analog zu Passwort-Reset-Tokens via Hash::make()) gespeichert werden. Zusätzlich ist ein striktes Rate-Limiting (z. B. über Laravels Throttle-Middleware) auf den Einlöse-Endpoint zu setzen, um Brute-Force-Angriffe zu verhindern.

Anmerkung (Claude, 2026-06-10) – Mechanismus korrigieren ⚠️: Token hashen: ja. Aber Hash::make() (bcrypt) ist hier das falsche Werkzeug. Bcrypt ist gesalzen → kein direkter Lookup per Hash möglich; man müsste jede Zeile einzeln durchhashen (O(n) pro Einlösung). Laravels Passwort-Reset funktioniert nur deshalb mit Hash::make, weil dort zuerst per E-Mail gesucht und dann Hash::check ausgeführt wird – diesen Schlüssel hat ein anonymer Recovery-Link nicht. Korrekt für einen Lookup-Token:

  • Indexierter SHA-256: hash('sha256', $token) in einer indexierten Spalte speichern, Lookup darüber (deterministisch, O(1) per Index). Empfohlen.
  • oder Selector/Verifier-Pattern: öffentlicher selector für den Lookup + gehashter verifier für die Prüfung mit konstantzeitigem Vergleich (hash_equals). Rate-Limiting (throttle), Single-Use (used_at), 72h-TTL und Bindung an user_abo_id bleiben unverändert korrekt. Der Klartext-Token wird ausschließlich im E-Mail-Link transportiert, nie persistiert.

3. Ausfallsicherheit durch PayPal Webhook Reconciliation (Phase 6)

Befund: Wenn PayPal das Billing vollständig übernimmt (Option A), wird der Cronjob user:make_abo_order für diese Abos übersprungen. Die Auslösung der Bestellung hängt zu 100 % am Webhook PAYMENT.SALE.COMPLETED. Optimierung: Webhooks können durch Netzwerkprobleme bei MIVITA, fehlerhafte Deployments oder Verzögerungen bei PayPal verloren gehen. Es wird dringend empfohlen, einen täglichen Reconciliation-Job (Pull-Methode) zu implementieren. Dieser prüft alle aktiven PayPal-Abos, deren next_date vergangen ist, und fragt den Status aktiv über die PayPal-API ab, falls kein Webhook eingetroffen ist.

Anmerkung (Claude, 2026-06-10): Zustimmend – Webhook-only ist fragil, der Pull-Job ist Pflicht und passt in den bestehenden Scheduler (Muster user:make_abo_order). Zwei Ergänzungen:

  • Eventname prüfen: PAYMENT.SALE.COMPLETED ist v1-Terminologie. Bei Subscriptions API v2 heißen die relevanten Events anders (BILLING.SUBSCRIPTION.*, PAYMENT.CAPTURE.COMPLETED). Vor der Umsetzung in Phase 0 verbindlich klären (hängt an O3 – aktuell läuft PayPal nur über Checkout).
  • Gemeinsame Idempotenz: Der Reconciliation-Job muss dieselbe Idempotenz-/Dedup-Logik nutzen wie der Webhook (siehe Befund 5), sonst entsteht eine Doppelbestellung, wenn Pull und Webhook beide feuern.

4. Tiefe Absicherung der Service-Schicht (Phase 1)

Befund: Der Niedrig-Prio-Befund aus Abschnitt 5.2 bemängelt fehlende Input-Validierung in checkCreditCard(). Als Lösung wurde notiert, den UpdateAboCreditCardRequest im Controller zu nutzen. Optimierung: Eine reine Validierung im HTTP-Layer (Controller) schützt die Service-Klasse nicht, wenn diese künftig (z. B. durch ein Admin-CLI-Command) direkt aufgerufen wird. Statt reiner Arrays sollten im PayonePaymentService strikt typisierte DTOs (Data Transfer Objects) als Parameter erzwungen werden.

Anmerkung (Claude, 2026-06-10): Zustimmend in der Richtung, aber Framing schärfen: Form Request und DTO sind komplementär, kein Entweder-oder. Der Form Request validiert und erzeugt das DTO (User-Meldungen, CSRF, Auth); das DTO garantiert dem Service strukturell saubere Eingaben (CLI-fest, kein $data['key']-Zugriff auf ungeprüfte Arrays). PayonePaymentResult ist bereits der Präzedenzfall auf der Output-Seite – ein CreditCardCheckData-DTO auf der Input-Seite ist die logische Ergänzung. Scope-Hinweis: Zunächst nur den CC-Pfad in Phase 1 auf DTO heben, nicht den ganzen PayonePaymentService auf einen Schlag – sonst inkonsistente Teilmigration. Die übrigen Methoden (checkBankAccount, chargeAbo) folgen schrittweise, wenn ihr jeweiliger Feature-Pfad angefasst wird.

5. Idempotenz & Race-Conditions bei PayPal (Phase 6)

Befund: PayPal sendet Webhooks unter Umständen mehrfach oder parallel (Retries). Optimierung: Wie in Phase R5 für PAYONE sehr gut umgesetzt, muss auch der PayPalWebhookController eine strikte Idempotenzprüfung anhand der event_id durchführen. Dies muss zwingend von einer DB::transaction() umschlossen sein, die den Abo-Datensatz mit lockForUpdate() sperrt, um Race Conditions bei Millisekunden-genauen, parallelen Aufrufen exakt identischer Webhooks auszuschließen.

Anmerkung (Claude, 2026-06-10): Zustimmend – das R5-Muster (lockForUpdate + Double-Check, Prioritäts-Logik) lässt sich 1:1 übertragen. Zwei sicherheitskritische Ergänzungen:

  • DB-Unique-Constraint auf event_id: Harte Idempotenz auf Datenbankebene (ein Duplikat-Insert schlägt fehl), stärker als reine App-Logik – fängt auch parallele Inserts ab, bevor die Geschäftslogik läuft.
  • Fehlt im Befund – PayPal-Webhook-Signaturprüfung: PAYONE nutzte den MD5-Key zur Authentifizierung; PayPal braucht echte Signatur-Verifikation (verify-webhook-signature-API bzw. Cert-basiert mit webhook_id). Ohne diese ist der Endpoint fälschbar. Sicherheitskritisch – darf nicht untergehen (vgl. Abschnitt 4.2).

Konsolidierte Bewertung (Claude, 2026-06-10)

# Befund Phase Urteil Wichtigste Verfeinerung
1 Konsistenz CC-Update 1 ✅ übernehmen Reparaturpfad + card_update_pending-Spur, nicht nur Alert
2 Recovery-Token hashen 3 ✅ mit Korrektur ⚠️ Indexierter SHA-256 / Selector-Verifier statt bcrypt Hash::make
3 PayPal Reconciliation 6 ✅ übernehmen Eventnamen in Phase 0 klären (v1 vs. v2); Idempotenz teilen
4 DTOs im Service 1 ✅ übernehmen Komplementär zum Form Request; nur CC-Pfad zuerst
5 PayPal Idempotenz 6 ✅ übernehmen DB-Unique auf event_id + Signaturprüfung ergänzen

Gesamteinschätzung: Die Gemini-Befunde sind solide und ohne Ausreißer/Over-Engineering. Unmittelbar relevant für den nächsten Schritt sind nur ① und ④ (beide Phase 1); ②/③/⑤ sind korrekt für Phase 3/6 geparkt. Einziger Punkt, der vor der Umsetzung zwingend korrigiert gehört: ② (bcrypt → indexierter SHA-256).


5.4 Live-Verifikation Phase R (End-to-End, 2026-06-10)

Nach Abschluss von R1–R7 + Review-Fixes wurden im Test-System echte Checkout-Bestellungen durchgeführt und die PAYONE-Callbacks (IPN) ausgelöst. Anschließend wurde der DB-Zustand geprüft (read-only).

Geprüfte Testbestellungen (mode=test)

Order Methode Auth-Tx (API-Response) Webhook-Tx (IPN) Order-Status Abo
55459 PayPal (wlt/PPE) REDIRECT appointed → paid paid ✓ –
55460 CC APPROVED paid paid ✓ –
55461 CC APPROVED appointed → paid paid ✓ Abo 385: status 2, user_abo_order paid
55463 PayPal (wlt/PPE) REDIRECT paid paid ✓ –
55458 PayPal (wlt/PPE) REDIRECT nur appointed paid=0 (Zwischenstand) –
55462 CC APPROVED – (kein IPN, bewusst) paid=0 / prev Abo 386: status 1 (pending)

55458 und 55462 wurden bewusst ohne paid-Callback belassen. Die Orders bleiben korrekt unbezahlt – kein vorzeitiges/falsches Paid. Das belegt: der Zustand kippt erst beim paid-IPN.

Bestätigte Eigenschaften der Phase-R-Implementierung

Aspekt Befund in den Live-Daten
Recorder-Trennung (R2/R5) request='authorization' = API-Response (key=NULL); request='transaction' = Webhook (key gesetzt). Beide Pfade über PayoneTransactionRecorder.
Webhook-Handler (R5) status PAYONE, txaction appointed/paid, key + transmitted_data via recordWebhookCallback persistiert.
txaction-Priorität (R5) 55459 & 55461 zeigen appointed → paid; Order landet final auf paid, kein Downgrade.
PCI-Redaction (R-Review #3) 0 Treffer für cardpan/cardcvc2 über alle 13 geprüften transmitted_data-Felder.
Key-Config (R-Review #1) PAYONE-Callbacks wurden akzeptiert/gespeichert → PAYONE_KEY_HASH stimmt mit den von PAYONE gesendeten Keys überein; Hard-Fail-Umstellung bricht den Live-Flow nicht.
Abo-Anlage bezahltes Abo (385) status 2 + user_abo_order paid=1; unbezahltes (386) status 1 + paid=0 (pending, vom Cron nicht abgebucht). Karten-Token + payone_userid korrekt gespeichert.

Ergebnis: Phase R ist nicht nur formal abgeschlossen, sondern end-to-end im Test-System bestätigt. Keine PCI-Leckage, korrekte Statusübergänge, robustes Verhalten bei ausbleibendem Callback. Solide Basis für Phase 0/1.


6. Phase 0 – Analyse & API-Klärung

Ziele

  • PAYONE-Dokumentation für Kartenaktualisierung am bestehenden userid klären.
  • PayPal-Produktentscheidung: Subscriptions API v2 vs. Vault + Reference Transactions.
  • Anzahl betroffener Live-Abos erfassen (CC vs. PayPal-via-PAYONE).

Aufgaben

  1. PAYONE: Mit BS PAYONE klären, welcher Request für CC-Update gilt:
    • Option A: request=updateuser mit neuem pseudocardpan
    • Option B: Neuer creditcardcheck + Zuordnung zum bestehenden userid
    • Testbench-Aufruf dokumentieren (Sandbox-userid aus bestehendem Test-Abo)
  2. PayPal: Merchant-Konto prüfen – Subscriptions freigeschaltet? Welche Länder/Währungen?
  3. Datenbestand: SQL-Auswertung:
    SELECT clearingtype, wallettype, status, COUNT(*) 
    FROM user_abos WHERE deleted_at IS NULL 
    GROUP BY clearingtype, wallettype, status;
    
  4. Abhängigkeiten: Checkout-Flow, UserMakeAboOrder, AboRetryPaymentService als Sequenzdiagramm finalisieren.

Deliverables

  • PAYONE API-Spec für CC-Update (internes Doc oder Link)
  • PayPal Integrationsentscheid dokumentiert
  • Bestandsanalyse (Anzahl Abos pro Typ/Status)

Offene Fragen

ID Frage
O2 Gibt es PAYONE-Support-Freigabe für updateuser/updatecard im Live-Portal?
#keine Information also vermutlich nicht, Der Prozess Die alte Subscription lösen oder löschen Und eine neue Subscription anlegen
O3 Welches PayPal-Produkt ist beim Merchant-Account aktiv (Subscriptions vs. nur Checkout)?
#alles nur über den Checkout. Die Subscription werden beim ersten Checkout angelegt.
O4 Sollen CC-Aktualisierung und Abo-Wechsel auch im Admin möglich sein (Impersonation/Support)?
#nein, Kreditkarteninformationen werden ausschließlich vom User selbst eingegeben.

7. Phase 1 – Kreditkarte aktualisieren (PAYONE) ⭐ Priorität 1

📌 Ausgelagert & aktualisiert (2026-06-25): Diese Phase wird eigenständig umgesetzt in dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md (mit Stakeholder-Festlegungen, geklärter K1-Frage und Berater→Kunde-Link). Der folgende Abschnitt ist die ursprüngliche Skizze; bei Abweichungen gilt der ausgelagerte Plan.

Ziel

Authentifizierter User kann bei einem aktiven CC-Abo (clearingtype=cc) neue Kreditkartendaten hinterlegen. Die nächste Abbuchung nutzt den aktualisierten pseudocardpan.

User Stories

  • US-1.1: Als Endkunde (Portal) möchte ich meine Kreditkarte für mein Abo aktualisieren, damit künftige Abbuchungen funktionieren.
  • US-1.2: Als Berater (CRM) möchte ich die Karte meines Kunden-Abos aktualisieren können (Support-Fall).
  • US-1.3: Als System speichere ich nur Token (pseudocardpan), nie Rohdaten.

Technische Umsetzung

Backend

Komponente Beschreibung
AboPaymentMethodService (neu) Orchestriert CC-Update: Validierung → PAYONE → UserAbo aktualisieren
PayoneGateway::updateCreditCard(UserAbo, cardData) (neu) PAYONE-Request mit bestehendem payone_userid
UpdateAboCreditCardRequest (neu) Form Request; keine PAN im Server-Log
Controller-Routes Portal + CRM (+ optional Admin read-only Anzeige)

Ablauf

sequenceDiagram
    participant U as User (Portal/CRM)
    participant C as AboController
    participant S as AboPaymentMethodService
    participant P as PayoneGateway
    participant DB as user_abos

    U->>C: POST update-payment-method
    C->>S: updateCreditCard(userAbo, ccForm)
    S->>S: Policy: User darf Abo bearbeiten
    S->>P: creditcardcheck (storecarddata=yes)
    P-->>S: pseudocardpan, status VALID
    S->>P: updateuser (userid, neuer pseudocardpan) 
    P-->>S: APPROVED
    S->>DB: carddata aktualisieren
    S->>DB: SysLog + optional PaymentTransaction
    S-->>U: Erfolg / Fehlermeldung

Frontend

  • Button „Zahlungsmethode aktualisieren“ in admin/abo/_detail.blade.php (nur bei clearingtype=cc).
  • Modal mit gleichem CC-Formular wie Checkout (checkout.blade.php – PAYONE Client-API Felder).
  • Sichtbar in Portal (portal/abo/my_abo.blade.php) und CRM (user/abo/detail.blade.php).

Datenänderungen

  • Keine Migration zwingend nötig (carddata JSON existiert).
  • Optional: payment_method_meta mit card_mask, cardexpiredate für Anzeige („Visa •••• 4242, gültig 12/27“).

Sicherheit

  • Policy UserAboPolicy@updatePaymentMethod
  • CSRF, Auth-Middleware (bestehend)
  • Fehlgeschlagener PAYONE-Call: kein Partial-Update in DB
  • Transaktion: PAYONE OK → DB Update (nicht umgekehrt)

Tests (Pest)

Test Szenario
AboUpdateCreditCardTest Happy Path: Mock Payone → carddata aktualisiert
Fehler: Ungültige Karte → kein DB-Update
Auth: Fremdes Abo → 403
Nur CC-Abos: PayPal-Abo → 422

Akzeptanzkriterien

  • User kann CC-Daten im Portal und CRM aktualisieren
  • Nächster Cron-Lauf (user:make_abo_order) nutzt neuen pseudocardpan
  • Admin sieht maskierte Karteninfo in Abo-Detail
  • Keine PAN/CVC in Logs oder DB

8. Phase 2 – Subscription cancel + recreate (PAYONE CC)

Ziel

Technische Fähigkeit, eine PAYONE-Subscription (Token) zu lösen und mit neuer Zahlungsmethode neu anzulegen, ohne das Abo (UserAbo) inhaltlich zu verlieren (Produkte, Historie, next_date).

Anwendungsfälle

  1. CC-Aktualisierung schlägt fehl → kompletter Neuaufbau des PAYONE-userid
  2. Vorbereitung für CC → PayPal-Wechsel (Phase 7)
  3. Support: „Karte komplett neu hinterlegen“

Ablauf (Vorschlag)

  1. Cancel: PAYONE updateuser mit recurrence=stop oder Entfernen des Zahlungsmittels (PAYONE-Doku Phase 0)
  2. Neu: Wie Checkout-Abo-Init – neuer creditcardcheck → neuer userid via authorization + recurrence=recurring
  3. DB: Alten payone_userid in user_abo_payment_migrations archivieren; UserAbo mit neuem Token updaten
  4. Kein neues UserAbo-Record – gleiche ID, Historie bleibt

UI

  • Wizard „Zahlungsmethode erneuern“ (2 Schritte: Bestätigung → CC-Formular)
  • Warnhinweis: „Ihre bisherige Karte wird entfernt. Bitte neue Daten eingeben.“

Offene Fragen

ID Frage
O5 Muss bei PAYONE explizit recurrence=stop gesendet werden, oder reicht neuer userid?
#Hier gibt es keine klaren Anweisung seitens PAYONE. Entweder erstellen wir einfach eine neue, denn soweit ich sehe, ist recurrence=stop Für das komplette beenden, wo keine Abbuchung mehr folgen. Wir erstellen einen neuen.
O6 Was passiert mit ausstehenden abo_hold-Bestellungen beim Recreate? Automatisch retry?
Abos, die angehalten sind mit Abo Hold Gelangen direkt zum Checkout mit dem Warenkorb. Hier wird direkt die Initialisierung mit dem gesamten Warenkorb ausgeführt. D.h. das ist ein automatischer Retry, der aber direkt im Checkout ausgeführt wird, Geht die Buchung durch, haben wir sofort einen neuen Token.

Ziel

Wenn ein Abo auf status=3 (Hold) wechselt, erhält der User einen Zahlungslink, über den er die offene Abbuchung wie im Checkout begleichen kann. Nach erfolgreicher Zahlung: Abo auf status=2, Hintergrundprozesse (Versand, Rechnung, Incentive, Einmal-Artikel-Purge).

Ist vs. Soll

Heute Soll
Fehlgeschlagene Abbuchung E-Mail ohne Link (send_link=false in UserMakeAboOrder) E-Mail mit signiertem Zahlungslink
User Self-Service Nur Berater-Retry (Admin) / Portal-Retry (erneute Abbuchung mit altem Token) Checkout-ähnliche Zahlung mit frei wählbarer Methode
Bestehende Infrastruktur ShoppingInstance + OrderPaymentService (Berater→Kunde) Neuer Flow AboPaymentRecoveryService

Technische Umsetzung

Neuer Service: AboPaymentRecoveryService

createRecoveryLink(UserAbo, UserAboOrder) → token, url, expires_at
resolveRecoveryLink(token) → UserAbo + offene ShoppingOrder (oder neu erzeugen)
completeRecovery(ShoppingPayment) → AboHelper::markPaid, Incentive, AboOneTimeService::purgeAfterExecution

Ablauf

  1. UserMakeAboOrder / AboRetryPaymentService bei Fehler → createRecoveryLink()
  2. E-Mail (MailCheckout erweitern oder neues Mailable AboPaymentRecoveryMail) mit Link
  3. User öffnet Link → reduzierter Checkout (Betrag fix, Adresse vorausgefüllt, Zahlungsmethoden wie Shop)
  4. Zahlung erfolgreich → gleiche Pipeline wie AboRetryPaymentService::markAboSuccess()

Datenmodell

Tabelle user_abo_payment_recovery_links (siehe 3.2) oder Wiederverwendung ShoppingInstance mit neuem payment-Typ (z. B. 7 = abo_recovery).

Offene Frage O7: Eigene Tabelle vs. ShoppingInstance.payment=7 – Was passt besser zum bestehenden Checkout-Flow?

Sicherheit

  • Token: Str::uuid(), 72h TTL (konfigurierbar via Setting)
  • Einmalverwendung (used_at)
  • Betrag serverseitig aus UserAboOrder / ShoppingOrder, nicht manipulierbar
  • Kein Login nötig (Link per E-Mail), aber E-Mail-Adresse muss zu user_abo.email passen (optional Verifikation)

Zahlungsmethoden am Recovery-Checkout

Methode Provider Hinweis
Kreditkarte PAYONE Einmalzahlung + optional neues Token für Abo speichern
PayPal PAYONE (interim) / PayPal direkt (später) Briefing: langfristig nur PayPal direkt
Vorauskasse PAYONE Optional – Briefing nicht explizit

Offene Frage O8: Soll der Recovery-Checkout gleichzeitig die Zahlungsmethode fürs Abo aktualisieren (neuer Token), oder nur die einmalige offene Rate begleichen?

Tests

  • Recovery-Link erzeugen und einlösen (Mock Payment)
  • Abgelaufener Token → 410 Gone
  • Bereits bezahltes Abo → Redirect mit Hinweis
  • Erfolg → UserAbo.status=2, UserAboOrder.paid=true

10. Phase 4 – Provider-Abstraktion & Datenmodell

Ziel

Technische Grundlage für parallelen Betrieb PAYONE + PayPal direkt.

Aufgaben

  1. Migration: payment_provider, payment_token_ref, subscription_status (siehe 3.2)
  2. Backfill: Bestehende Abos → payment_provider=payone; PayPal-via-PAYONE → subscription_status=legacy_payone_paypal
  3. Interface PaymentGatewayInterface:
    interface PaymentGatewayInterface {
        public function charge(ChargeRequest $request): ChargeResponse;
        public function updatePaymentMethod(UpdateMethodRequest $request): UpdateMethodResponse;
        public function cancelSubscription(CancelRequest $request): CancelResponse;
        public function handleWebhook(array $payload): WebhookResult;
    }
    
  4. PaymentOrchestrator resolved Provider anhand payment_provider
  5. Refactor schrittweise: UserMakeOrder::makePayment() ruft Orchestrator statt direkt PayoneController

Tests

  • Provider-Resolution Unit Tests
  • Backfill-Migration Test (bestehende Abos unverändert funktionsfähig)

11. Phase 5 – PayPal direkt: Checkout & Abo-Neuabschluss

Ziel

Neue Abos mit PayPal laufen nicht mehr über PAYONE. Checkout und Erstzahlung nutzen PayPal Subscriptions API.

API-Integration (PayPal REST v2)

Schritt PayPal Endpoint Anmerkung
Plan erstellen (Admin/Cron) POST /v1/billing/plans Pro Abo-Intervall/Produkt oder dynamischer Plan
Subscription anlegen POST /v1/billing/subscriptions Beim Checkout nach PayPal-Approval
Approval JS SDK / Redirect User genehmigt im PayPal-Login
Webhook BILLING.SUBSCRIPTION.ACTIVATED, PAYMENT.SALE.COMPLETED Separater Controller

Code-Struktur

app/Services/Payment/
├── PayPalGateway.php
├── PayPalWebhookController.php   → routes/api.php
├── PayPalPlanService.php         → Plan-Sync
└── DTOs/...

Checkout-Änderungen

  • CheckoutController::processPaymentMethod() – Branch für payment_method=paypal_direct
  • PaymentMethod-Tabelle: neuer Eintrag oder short=PPD (PayPal Direct) vs. wlt#PPE (Legacy)
  • AboHelper::createNewAbo() – bei PayPal: payment_provider=paypal, payment_token_ref=subscription_id

Sicherheit

  • OAuth2 Client Credentials für Server-zu-Server
  • Webhook-Signaturprüfung Pflicht
  • PayPal-subscription_id niemals im Frontend loggen

Sandbox-Tests

  • PayPal Sandbox Buyer Account
  • Feature-Flag: config('services.paypal.enabled') – erst Sandbox, dann Live

Offene Fragen

ID Frage
O9 Feste PayPal-Pläne pro Abo-Intervall (5/10/20/25) oder ein dynamischer Plan pro Checkout-Betrag?
O10 PayPal JS SDK im Checkout (Blade) – welche SDK-Version / Hosted Fields Policy?
O11 Sollen Einmalzahlungen (nicht Abo) mit PayPal direkt schon in Phase 5, oder erst Abo?

12. Phase 6 – PayPal direkt: Wiederkehrende Abbuchung & Webhooks

Ziel

Monatliche Abo-Ausführung für PayPal-direkt-Abos ohne UserMakeOrder→PAYONE, sondern über PayPal-Subscription-Billing.

Architektur-Entscheidung

Option A – PayPal führt Billing aus (empfohlen):

  • PayPal zieht automatisch ein; Webhook PAYMENT.SALE.COMPLETED → System erzeugt ShoppingOrder + markiert bezahlt
  • user:make_abo_order überspringt PayPal-direkt-Abos (nur noch Versand/Order-Erstellung nach Webhook)

Option B – Merchant-initiated:

  • Cron triggert PayPal Capture – komplexer, nicht empfohlen für Subscriptions

Webhook-Controller

POST /api/paypal/webhook
→ verify signature
→ idempotency check (event_id)
→ switch(event_type):
    PAYMENT.SALE.COMPLETED → create order from subscription
    BILLING.SUBSCRIPTION.CANCELLED → UserAbo.status=4
    BILLING.SUBSCRIPTION.SUSPENDED → UserAbo.status=3

Monitoring

  • payment:check-uptime um PayPal-Endpoint erweitern
  • Payment-Dashboard: PayPal-Incidents (Provider paypal existiert bereits)

13. Phase 7 – Zahlungsmethode wechseln (CC ↔ PayPal)

Ziel

User kann Zahlungsmethode wechseln: CC (PAYONE) ↔ PayPal (direkt).

Ablauf

  1. Alte Subscription canceln (PAYONE recurrence=stop oder PayPal cancel subscription)
  2. UserAbo.subscription_status = migrating
  3. Neue Subscription mit gewählter Methode anlegen (Phase 1/5 Flows)
  4. UserAbo updaten: neuer Provider, Token, migrated_from_abo_id = self
  5. subscription_status = active

UI

  • „Zahlungsmethode wechseln“ in Abo-Einstellungen
  • Klare Hinweise: „PayPal nur über direktes PayPal-Konto“ (Briefing)

Einschränkungen

  • Während abo_hold: Wechsel erlaubt? (Empfehlung: ja, als Alternative zum Recovery-Link)
  • Mindestlaufzeit beachten (abo-min-duration Setting)

14. Phase 8 – Legacy-Migration PAYONE-PayPal → PayPal direkt

Ziel

Bestehende Abos mit clearingtype=wlt, wallettype=PPE (PayPal via PAYONE) bleiben funktionsfähig, bis User aktiv migriert.

Migrations-Kampagne (Briefing)

  1. Phase Stabilisierung: PayPal direkt min. X Wochen Live-Betrieb ohne kritische Incidents
  2. User-Kommunikation: E-Mail + Portal-Banner „Bitte Zahlungsmethode einmalig aktualisieren“
  3. Technisch: Deep-Link in Phase-7-Wizard; alter PAYONE-PayPal-Token wird bei Migration invalidated
  4. Reporting: Admin-Dashboard: % migriert, offene Legacy-Abos

Kein Big-Bang

  • Legacy-Abos weiter über UserMakeOrder→PAYONE
  • RetryFailedPaypalAbos Command bleibt für historische Fälle, wird nicht für neue Abos benötigt

15. Phase 9 – Monitoring, Tests, Rollout

Teststrategie

Ebene Umfang
Unit Gateway-Mocks, Provider-Resolution, Token-Generierung
Feature CC-Update, Recovery-Link, Webhook-Handling
Integration PAYONE Sandbox, PayPal Sandbox (markiert, optional CI)
Regression Bestehende Checkout-Tests, AboUserRetryTest, PayoneCallbackTestbenchPrepareCronTest

Rollout-Plan

  1. Staging: Alle Phasen gegen Sandbox
  2. Feature-Flags: ABO_CC_UPDATE_ENABLED, ABO_RECOVERY_LINK_ENABLED, PAYPAL_DIRECT_ENABLED
  3. Canary: CC-Update nur Portal → dann CRM → dann Recovery-Link
  4. PayPal direkt: Nur Neukunden-Abo-Checkout → nach 2 Wochen Bestands-Migrations-Einladung
  5. Rollback: Feature-Flags off → Legacy-Flow unverändert

Observability

  • Log-Kanäle: payone, payment, abo_order (+ neu: paypal) — Details in Abschnitt 22
  • Metriken: Recovery-Link-Conversion, CC-Update-Erfolgsrate, Webhook-Latenz — Details in Abschnitt 22
  • Payment-Dashboard: Auto-Incident-Trigger für alle neuen Flows — Details in Abschnitt 22

16. Betroffene Dateien (Übersicht)

Bestehend (Anpassung)

Datei Änderung
app/Http/Controllers/Pay/PayoneController.php Extraktion in Gateway; CC-Update-Methode
app/Cron/UserMakeOrder.php Provider-Routing
app/Services/AboHelper.php createNewAbo() mit payment_provider
app/Console/Commands/UserMakeAboOrder.php Recovery-Link bei Fehler
app/Services/AboRetryPaymentService.php Recovery-Link-Option
app/Http/Controllers/Api/PayoneController.php Unverändert (PAYONE only)
app/Http/Controllers/Web/CheckoutController.php PayPal-direct Branch
app/Http/Controllers/Portal/AboController.php CC-Update, Wechsel-Wizard
app/Http/Controllers/User/AboController.php CC-Update (CRM)
resources/views/admin/abo/_detail.blade.php Payment-Method-UI
resources/views/portal/abo/my_abo.blade.php Payment-Method-UI
resources/views/web/templates/checkout.blade.php PayPal SDK (Phase 5)
config/payone.php / config/services.php Secrets aus .env
routes/domains/portal.php, crm.php, api.php Neue Routes

Neu (Vorschlag)

Datei Zweck
app/Services/Payment/PaymentOrchestrator.php Provider-Routing
app/Services/Payment/PayoneGateway.php PAYONE-Operationen
app/Services/Payment/PayPalGateway.php PayPal-Operationen
app/Services/AboPaymentMethodService.php CC-Update, Recreate
app/Services/AboPaymentRecoveryService.php Recovery-Links
app/Http/Controllers/Api/PayPalWebhookController.php PayPal Webhooks
app/Http/Controllers/Web/AboPaymentRecoveryController.php Recovery-Checkout
app/Policies/UserAboPolicy.php Authorization
database/migrations/xxxx_add_payment_provider_to_user_abos.php Schema
tests/Feature/AboUpdateCreditCardTest.php Phase 1
tests/Feature/AboPaymentRecoveryTest.php Phase 3

17. Abhängigkeiten zu anderen Projekten

Projekt Bezug
dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md Recovery/Erfolg muss AboOneTimeService::purgeAfterExecution() aufrufen
dev/payment-dashboard/ENTWICKLUNGSPLAN.md PayPal-Uptime, Auto-Incidents
docs/salescenter/ABO - Bestellungen.md Einmal-Produkte + Zahlungsfehler-Verhalten

18. Risiken & Mitigationen

Risiko Impact Mitigation
PAYONE CC-Update-API anders als erwartet Phase 1 verzögert Phase 0 Klärung mit PAYONE-Support
PayPal Subscriptions Limitierungen (EU/DE) Phase 5 blockiert Früh Sandbox testen; Fallback Vault
Doppel-Abbuchung bei Webhook + Cron Hoch Idempotenz; PayPal-Abos aus Cron-Payment ausschließen
Legacy-Abos brechen bei Migration Hoch Kein Zwang; paralleler Betrieb; Rollback-Flag
PCI-Verstoß durch Logging Kritisch Code-Review-Checkliste; keine PAN in Requests loggen
Race Condition Callback/Redirect Mittel Bestehendes Muster aus Api/PayoneController

19. Offene Fragen (Gesamtliste)

Datenmodell

ID Thema Entscheidung Empfehlung
O1 Einheitliches payment_token_ref vs. payone_userid behalten Tech Lead payone_userid behalten; payment_token_ref nur für PayPal neu hinzufügen. Eine Umbenennung würde alle bestehenden Abo-Flows (UserMakeOrder, AboRetryPaymentService, AboHelper) anfassen – für null funktionalen Gewinn. PayPal-IDs (subscription_id) sind ohnehin ein anderes Format. Dual-Felder sind klarer als ein generisches Einheitsfeld.

PAYONE API

ID Thema Entscheidung Empfehlung
O2 PAYONE CC-Update API: updateuser oder neue authorization? PAYONE Support + Dev Als Fallback Variante B planen (neuer creditcardcheck + neue authorization mit recurrence=recurring → neuer userid). Diese Variante nutzt ausschließlich bereits getestete Code-Pfade. Variante A (updateuser mit neuem pseudocardpan) umsetzen, wenn PAYONE-Support dies für den Live-Portal-Account explizit bestätigt. Phase 0 muss diese Frage auflösen, bevor Phase 1 beginnt.
O5 PAYONE recurrence=stop bei Recreate nötig? PAYONE Support Nein – kein expliziter Stop-Call. Der PAYONE userid ist nur eine gespeicherte Zahlungsreferenz, kein aktives Subscription-Objekt auf PAYONE-Seite. Ein nicht mehr verwendeter userid verfällt passiv. Bei Recreate einfach neuen userid via creditcardcheck + authorization anlegen und in user_abos.payone_userid überschreiben. Mit PAYONE-Support bestätigen, aber mit dieser Annahme planen.

Feature-Scope & UX

ID Thema Entscheidung Empfehlung
O4 CC-Update durch Admin / Support-Impersonation? Product Owner Ja, mit Pflicht-Logging. Support-Fälle im CRM sind unvermeidlich. Bestehende SysLog-Infrastruktur bereits vorhanden. Einschränkung: nur Nutzer mit admin-Rolle; jede Admin-initiierte Änderung wird mit Admin-User-ID und optionalem Kommentarfeld protokolliert. Policy UserAboPolicy@updatePaymentMethod steuert die Berechtigung.
O6 Verhalten bei abo_hold + Recreate: offene Orders auto-retry? Product Owner Nein – kein automatischer Retry. Bei status=3 (hold) können bereits unbezahlte user_abo_orders vorliegen. Ein Auto-Retry nach Token-Recreate riskiert Doppelabbuchung und ist für den User überraschend. Stattdessen: Recreate aktualisiert nur den Token; die offene Rate wird über den Recovery-Link (Phase 3) separat beglichen. Nächstes geplantes Datum läuft normal weiter.
O11 PayPal direkt auch für Einmal-Checkout (nicht Abo) in Phase 5? Product Owner Nein – Phase 5 ausschließlich Abo-Neuabschluss. Einmalzahlungen mit PayPal direkt erfordern eigene Checkout-Änderungen und verdoppeln den Scope. Nach stabiler Phase 6 als separate Initiative einplanen.
O12 TTL für Recovery-Links: 72h? Product Owner 72h – empfohlen als konfigurierbarer Default. Lang genug für Nutzer, die E-Mails nicht täglich lesen; kurz genug um Missbrauch zu begrenzen. Via bestehendem Setting-Model konfigurierbar machen (Schlüssel abo_recovery_link_ttl_hours, Default 72).
O13 Welche Zahlungsmethoden am Recovery-Checkout? Product Owner Kreditkarte (PAYONE) + PayPal (interim PAYONE, später direkt) – kein Vorauskasse, kein SEPA. Vorauskasse liefert keine sofortige Zahlungsbestätigung und kann abo_hold nicht automatisch aufheben. SEPA erfordert Mandatsverwaltung und ist für einen Self-Service-Recovery zu komplex. Einfach halten: CC + PayPal.
O14 Mindestlaufzeit bei Zahlungsmethode-Wechsel zurücksetzen? Legal / Product Mindestlaufzeit nicht zurücksetzen. Der Zähler beginnt mit dem Abo-Start, nicht mit dem Zahlungsmethode-Wechsel. Technisch: user_abos.created_at als Referenz beibehalten; AboPolicy@canCancel prüft Mindestlaufzeit unabhängig vom Zahlungsträger. Legal-Bestätigung einholen, aber diese Auslegung ist die nutzerfreundlichere.
O15 Zeitpunkt Legacy-Migrations-Kampagne (PAYONE-PayPal → PayPal direkt) Product Owner Frühestens 4 Wochen nach Go-Live Phase 6 ohne kritische Incidents. Kampagne: 2 E-Mail-Erinnerungen + Portal-Banner; Sunset-Datum 3–6 Monate nach Kampagnenstart kommunizieren. Alten PAYONE-PayPal-Flow erst abschalten, wenn <5 % der Abos noch darauf laufen.

Recovery-Technologie

ID Thema Entscheidung Empfehlung
O7 Recovery: eigene Tabelle vs. ShoppingInstance.payment=7 Tech Lead Eigene Tabelle user_abo_payment_recovery_links. ShoppingInstance ist für Berater→Kunde-Zahlungslinks konzipiert; sie zu überladen koppelt zwei konzeptionell verschiedene Flows. Eine dedizierte Tabelle erlaubt klares TTL-Management, Einmalverwendungs-Flag (used_at) und einen sauberen Audit-Trail ohne Side Effects auf bestehende Berater-Links.
O8 Recovery: nur Einmalzahlung oder auch Token fürs Abo aktualisieren? Product Owner Token aktualisieren, wenn neue Zahlungsdaten eingegeben wurden; sonst nicht. Gibt der User am Recovery-Checkout eine neue CC ein, soll diese automatisch als zukünftiger Abo-Token gespeichert werden – er würde sonst sofort wieder fehlschlagen. Wählt er PayPal, gilt dasselbe. Wählt er dieselbe Methode ohne neue Daten (reiner Retry), kein Token-Update. Dies entspricht O8: bedingtes Token-Update.

PayPal-Integration

ID Thema Entscheidung Empfehlung
O3 PayPal Merchant: Subscriptions API oder Vault + Reference Transactions? Finance / Admin PayPal Subscriptions API v2 anstreben; Vault als Fallback. Subscriptions API ist der Standard für wiederkehrende Abbuchungen in DE/EU und deckt den gesamten Billing-Lifecycle ab. Vault + Reference Transactions ist komplexer und erfordert explizite Händler-Freigabe. Phase 0 klärt, welches Produkt im Merchant-Account aktiv ist. Falls nur Vault verfügbar: Phase 6-Architektur auf Merchant-initiated Billing umstellen.
O9 PayPal Plan-Strategie: fixe Pläne pro Intervall/Betrag oder dynamisch? Product + Finance Fixe Pläne. MIVITA hat definierte Abo-Beträge (5/10/20/25 EUR) und Intervalle. Dynamische Pläne per Checkout-Betrag würden den PayPal-Account mit Hunderten Einmal-Plans fluten und Audits erschweren. Eine überschaubare Matrix (Beträge × Intervalle) ist wartbar, auditierbar und für den PayPal-Support leichter nachvollziehbar.
O10 PayPal JS SDK Variante im Blade-Checkout Frontend/Dev PayPal JS SDK v2 mit vault=true&intent=subscription. Das SDK rendert den Approval-Button nativ und öffnet das PayPal-Popup ohne eigene Redirect-Logik. Keine Hosted Fields für PayPal (das ist ein CC-Only-Feature). Integration: <script src="https://www.paypal.com/sdk/js?client-id=...&vault=true&intent=subscription"> + paypal.Buttons({createSubscription, onApprove}).render(...).

Refactoring

ID Thema Entscheidung Empfehlung
OR1 Pay\PayoneController langfristig löschen oder als Fassade behalten? Tech Lead Löschen nach Phase 4. Sobald alle Aufrufer (CheckoutController, UserMakeOrder, PaymentHelper) direkt auf PayonePaymentService zeigen, ist die Fassade totes Gewicht. Deprekations-Deadline: Abschluss Phase 4. Bis dahin @deprecated-PHPDoc + Migrations-Hinweis, danach vollständig entfernen.
OR2 DI sofort in alle Aufrufer oder erst Fassaden-Übergang? Tech Lead Fassade + Service-Container jetzt; Aufrufer schrittweise in Phase 1 migrieren. PayonePaymentService als Singleton im Service-Provider registrieren; Pay\PayoneController delegiert in Phase R noch direkt. CheckoutController und UserMakeOrder injizieren den Service in Phase 1. PaymentHelper folgt in R3. Kein Big-Bang – jeder Aufrufer als eigener, testbarer Schritt.
OR3 PaymentHelper in Phase R vollständig refactoren? Tech Lead Nur R2 (TransactionRecorder) jetzt; Rest später. Die drei PaymentTransaction::create-Blöcke in PaymentHelper.php (Zeilen 43, 55, 67) auf PayoneTransactionRecorder umstellen – das ist der höchste ROI und direkte Phase-R2-Aufgabe. Die übrige ELV-Logik in PaymentHelper gehört in ein späteres Membership/ELV-Refactoring. Vollständiges Rewrite in Phase R würde den Scope sprengen.

20. Empfohlene Umsetzungsreihenfolge

Phase R (Payment-Controller Refactoring)  ← VORAB
    ↓
Phase 0 (Klärung) ─────────────────────────────────────┐
    ↓                                                  │
Phase 1 (CC aktualisieren) ────────────────────────────┤
    ↓                                                  │
Phase 2 (Cancel + Recreate CC)                         │
    ↓                                                  Phase 4 (Abstraktion, parallel ab R4)
Phase 3 (Recovery-Zahlungslink)                        │
    ↓                                                  ↓
Phase 5 (PayPal Checkout) → Phase 6 (PayPal Webhooks) → Phase 7 (Wechsel)
                                                              ↓
                                                         Phase 8 (Migration)
                                                              ↓
                                                         Phase 9 (Rollout)

Sofort starten: Phase R (Hotfixes R1 sofort deploybar, dann R2–R4 schrittweise).

Danach: Phase 0 + Phase 1 (Briefing-Priorität „Kreditkarte als Erstes“) – baut auf PayonePaymentService aus R4 auf.

Parallel möglich: Phase 0 (API-Klärung) während R2–R3; Phase 3 nach R4.

PayPal direkt: Erst nach stabiler Phase R + Phase 1–3.


21. Nächste Schritte

  1. Phase R1: Hotfixes (var_dump, PENDING, Log-Redaction, PAYONE_KEY aus .env) (2026-06-09)
  2. Phase R2: PayoneTransactionRecorder extrahieren (2026-06-09)
  3. Phase R3: PaymentMethodResolver + PayoneRequestBuilder extrahieren (2026-06-09)
  4. Phase R4: PayonePaymentService + PayonePaymentResult-DTO (2026-06-09)
  5. Phase R5: PayoneWebhookHandler + PayoneWebhookResult; Api\PayoneController schlank (2026-06-10)
  6. Phase R6: Payone::sendRequest → PayoneTransportException; Handler-Fallback + Checkout-Catch (2026-06-10)
  7. Phase R7: Toten Code entfernt, @deprecated + Container-DI, Service-Provider-Singleton (2026-06-10) — Phase R komplett
  8. Offene Fragen O1–O4 + OR1–OR3 mit Stakeholdern klären
  9. PAYONE-Support: CC-Update-Request dokumentieren (Phase 0)
  10. SQL-Bestandsanalyse Live-Abos ausführen
  11. Review-To-dos aus Abschnitt 5.2 erledigen, dann Phase 1: AboPaymentMethodService auf PayonePaymentService aufsetzen

22. Dashboard & Observability – Erweiterungsplan

Das Payment-Dashboard (app/Http/Controllers/Admin/PaymentDashboardController.php) ist bereits gut ausgebaut. Dieser Abschnitt dokumentiert, was vorhanden ist, wo die Lücken für die neuen Flows liegen und was pro Phase konkret erweitert wird.

22.1 Was bereits vorhanden ist

View / Feature Beschreibung
index() Incident-Übersicht, Transaction-Stats (7d), Provider-Status, Uptime-Ampel
management() GF-Ansicht (read-only, 30d, Super-Admin)
transactions() payment_transactions Tabelle mit Fehler-Verteilung + PAYONE Error-Code-Mapping
payments() shopping_payments mit vollständiger Transaktionskette (Bestellung → User)
abandoned() Orders ohne Zahlung / abgebrochene / Callbacks ausgeblieben
funnel() 5-Stufen Checkout-Funnel mit Conversion-Rate und Source-Breakdown
logs() PAYONE Log-Viewer (liest payone-*.log, täglich wechselnd)
show() Incident-Detail mit Activity-Timeline
PaymentIncident Manuelles Incident-Management; provider = payone/paypal bereits vorbereitet
ProviderUptimeLog Uptime-Tracking für payone und paypal bereits vorbereitet
CheckoutFunnelEvent Tracking: checkout_visited, form_submitted, payment_initiated, payment_returned, payment_confirmed

Log-Kanäle (konfiguriert in config/logging.php):

Kanal Datei Retention
payone payone-{date}.log 14 Tage (täglich)
abo_order abo_order-{date}.log 14 Tage (täglich)
payment payment-{date}.log 30 Tage (täglich)
paypal — fehlt noch

22.2 Lücken im Ist-Stand (für neue Flows relevant)

Lücke Auswirkung Behoben in
payment_transactions hat kein provider-Feld Sobald PayPal direkt läuft, sind PayPal- und PAYONE-Transaktionen nicht filterbar Phase 4 Migration
payment_transactions hat kein context_type Abo-Transaktionen vs. Checkout vs. Recovery nur über ShoppingPayment-Join unterscheidbar Phase 4 Migration
logs() liest nur payone-*.log abo_order und payment Logs unsichtbar im Dashboard Phase R1 (sofort möglich)
Kein Recovery-Link-Tracking Phase-3-Conversion (generiert vs. eingelöst vs. abgelaufen) nicht messbar Phase 3
Kein Auto-Incident aus Code Alle Incidents heute manuell; wiederholte Webhook-Fehler werden nicht eskaliert Phase R5 + Phase 6
paypal Log-Kanal nicht konfiguriert PayPal-Webhook-Events haben keinen dedizierten Log-Kanal Phase 6 (vor erstem Webhook-Call)

22.3 DB-Schema: Erweiterungen für Traceability

Diese Felder werden in Phase 4 (Migrations-Block) hinzugefügt und von allen neuen Services befüllt:

-- payment_transactions (Phase 4)
provider      ENUM('payone','paypal') NOT NULL DEFAULT 'payone'
context_type  ENUM('checkout','abo','recovery') NULL
context_id    INT UNSIGNED NULL   -- user_abo_order_id oder user_abo_payment_recovery_links.id

Backfill: Bestehende Einträge bekommen provider=payone; context_type via JOIN auf shopping_payments.is_abo (1 → abo, 0 → checkout, NULL → NULL).

Vorteil: transactions() und payments() im Dashboard können sofort per Dropdown nach Provider und Kontext filtern — ohne Code-Änderung an den Queries, nur WHERE-Ergänzung.


22.4 Dashboard-Erweiterungen pro Phase

Phase R1 (sofort, 0,5 PT)

Multi-Log-Viewer: logs()-Methode um weitere Kanäle erweitern:

// Auswahl per ?channel=payone|abo_order|payment|paypal
private function getAvailableLogDates(string $channel = 'payone'): array
{
    $files = glob(storage_path('logs/'.$channel.'-*.log'));
    ...
}
  • Dropdown im Log-Viewer: PAYONE | Abo-Orders | Payment | PayPal
  • abo_order-Logs sind besonders wertvoll zur Fehlerdiagnose bei fehlgeschlagenen Abo-Abbuchungen

Vorteil: Sofort deploybar, kein DB-Schema, keine neue View.

Phase 1 – CC-Aktualisierung (CC-Update-Metrik)

AboPaymentMethodService::updateCreditCard() loggt jeden Versuch in einem neuen payment-Log-Eintrag mit Kontext:

\Log::channel('payment')->info('CC-Update', [
    'abo_id' => $userAbo->id,
    'success' => true/false,
    'payone_response' => $result->status,
]);

Dashboard-Erweiterung in getProviderStats() oder separater Metrik-Block im index():

Metrik Query-Quelle
CC-Update-Erfolgsrate (30d) payment_transactions WHERE context_type=abo AND request=creditcardcheck
Letztes CC-Update (Zeitpunkt) payment_transactions WHERE request=creditcardcheck ORDER BY created_at DESC

Neue Methode PaymentDashboardController::aboRecovery(): View:

Route: GET /admin/payment-dashboard/abo-recovery
View:  resources/views/admin/payment-dashboard/abo-recovery.blade.php

Metriken:

Metrik Berechnung
Generierte Links (30d) user_abo_payment_recovery_links WHERE created_at >= now()-30d COUNT
Eingelöste Links WHERE used_at IS NOT NULL COUNT
Abgelaufene Links (ungenutzt) WHERE expires_at < now() AND used_at IS NULL COUNT
Conversion-Rate used_at IS NOT NULL / generiert × 100
Durchschnittliche Zeit bis Einlösung AVG(used_at - created_at)
Offene Recovery-Links (aktiv, ungenutzt) WHERE expires_at > now() AND used_at IS NULL

Listenansicht: Paginated Tabelle mit Abo-ID, User, Betrag, generiert am, eingelöst am, Status (aktiv/eingelöst/abgelaufen).

Phase 6 – PayPal Webhooks (Log-Kanal + Auto-Incident)

1. paypal Log-Kanal hinzufügen in config/logging.php:

'paypal' => [
    'driver' => 'daily',
    'path' => storage_path('logs/paypal.log'),
    'level' => env('LOG_LEVEL', 'info'),
    'days' => 30,
],

PayPalWebhookController logt jeden Event in diesen Kanal — sichtbar im Dashboard nach Phase-R1-Erweiterung des Log-Viewers sofort.

2. Auto-Incident-Trigger: PayPalWebhookController und PayoneWebhookHandler (Phase R5) zählen aufeinanderfolgende Fehler via Cache und öffnen automatisch einen Incident:

// Muster für Auto-Incident (wiederverwendbar für PAYONE + PayPal)
private function maybeCreateAutoIncident(string $provider, string $type, string $reason): void
{
    $key = "payment_webhook_errors_{$provider}";
    $count = Cache::increment($key);
    Cache::expire($key, 3600); // Reset nach 1h ohne Fehler

    if ($count >= 5) { // 5 aufeinanderfolgende Fehler → Incident
        Cache::forget($key);
        PaymentIncident::firstOrCreate(
            ['provider' => $provider, 'type' => $type, 'status' => 'open'],
            [
                'title' => "Auto-Incident: Wiederholte Webhook-Fehler ({$provider})",
                'description' => $reason,
                'severity' => 'high',
                'detected_at' => now(),
            ]
        );
    }
}

PaymentIncident unterstützt provider=paypal und alle Status-Werte bereits — kein Schema-Aufwand.

Phase 9 – Abo-Zahlungshistorie (vollständige Übersicht)

Neue Methode PaymentDashboardController::aboPayments(): View:

Route: GET /admin/payment-dashboard/abo-payments
View:  resources/views/admin/payment-dashboard/abo-payments.blade.php

Tab 1 – Fehlgeschlagene Abo-Abbuchungen (Hold): UserAbo WHERE status=3 mit letztem UserAboOrder und zugehörigem ShoppingPayment + Fehlercode.

Tab 2 – Recovery-Status: Join auf user_abo_payment_recovery_links: Link generiert? Eingelöst? Abgelaufen?

Tab 3 – Erfolgreiche Retries: UserAboOrder WHERE paid=true AND created_at >= now()-30d — Überblick, wie viele Hold-Abos erfolgreich reaktiviert wurden.

Tab 4 – Provider-Breakdown: Nach Phase 4: Abo-Zahlungen gruppiert nach payment_transactions.provider (PAYONE vs. PayPal direkt).


22.5 Logging-Konventionen für alle neuen Services

Alle neuen Services verwenden strukturiertes Logging im gleichen Format wie bestehende Services (MyLog::writeLog für Fehler, \Log::channel() für Info/Debug):

Service Log-Kanal Pflicht-Felder im Kontext
AboPaymentMethodService payment abo_id, user_id, action (cc_update/recreate), provider, success
AboPaymentRecoveryService payment abo_id, recovery_link_id, action (create/resolve/expire), amount
PayoneWebhookHandler payone txid, txaction, reference, abo_context (bool)
PayPalWebhookController paypal event_id, event_type, subscription_id, abo_id
PayonePaymentService payone request_type, provider, context_type, abo_id (wenn vorhanden)

PCI-Regel (aus R1.3): Niemals cardpan, cardcvc2 in Log-Kontext. transmitted_data in payment_transactions darf diese Felder ebenfalls nicht enthalten. Review-Hinweis 2026-06-10: Der aktuelle PayoneTransactionRecorder filtert gespeicherte Payloads noch nicht zentral; Redaction ist als Review-To-do in Abschnitt 5.2 aufgenommen.


22.6 Zusammenfassung: Dashboard-Erweiterungen nach Phase

Phase Dashboard-Erweiterung Aufwand
R1 Multi-Log-Viewer (abo_order, payment Kanäle) 1–2h
4 provider + context_type Filter in transactions() und payments() 2–3h
1 CC-Update-Erfolgsrate-Metrik in index() oder payments() 1–2h
3 Neues Tab aboRecovery() + abo-recovery.blade.php 3–4h
6 paypal Log-Kanal + Log-Viewer + Auto-Incident-Trigger 2–3h
9 Neues Tab aboPayments() mit 4 Sub-Tabs 4–6h

Summe: ~13–20h — verteilt auf die jeweiligen Phasen, kein separates Dashboard-Projekt nötig.


Letzte Aktualisierung: 2026-06-09 (Phase R ergänzt; Offene Fragen mit Empfehlungen vervollständigt; Abschnitt 22 Dashboard & Observability hinzugefügt)