- 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>
97 KiB
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 (keinupdateuser,pseudocardpangenü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
ERLEDIGTgesetzt 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 inuser_abos/shopping_payments– Provider wird implizit überclearingtype/wallettypeabgeleitet. - 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.phpviaenv()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) creditcardcheckmitstorecarddata=yes– korrektes PCI-Muster- Abo-Init mit
recurrence=recurring+ unterschiedlichemcustomer_is_present(Checkout vs. Cron) Api\PayoneController: Duplikat-Callback-Erkennung, txaction-Priorität,lockForUpdatebeipaid- Unit-Tests für
Payone::parseResponseund 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_useridbeibehalten undpayment_token_refnur 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
SysLogprotokollieren (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
- Kein Verhaltens-Bruch: Bestehende Feature-Tests + manueller Checkout-Smoke-Test nach jedem Sub-Step.
- Strangler-Figur: Neue Services extrahieren;
Pay\PayoneControllerdelegiert vorerst nur weiter. - DTOs statt Property-Soup: Request/Response als immutable Value Objects.
- 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.phpapp/Services/Payone.phpconfig/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;cardexpiredatewurde 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::createersetztapp/Services/PaymentHelper.php— 3×createzu 1×recordFromResponse+statusMapvereinfachtapp/Cron/UserMakeOrder.php—recordPaymentTransaction()delegiert an Recorder
Abweichungen / Entscheidungen:
- Plan sah
PayonePaymentResult-DTO in der Recorder-Signatur vor (Phase R4-Vorgriff). Umgesetzt mitarray $responsestatt 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 StatusFNCMIV). Plan hatte das nicht explizit vorgesehen.transmitted_datawird nun auch bei ERROR und REDIRECT gespeichert (vorher nur bei APPROVED inPay\PayoneController). Verbessert Debugbarkeit, kein Verhaltens-Bruch.PaymentHelper::initELVPayment: 3 separate if-Blöcke zu 1in_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-Parameterapp/Services/Payment/Payone/PaymentMethodResolver.php(neu) —resolve(string, array): PayoneMethodConfigapp/Services/Payment/Payone/PayoneRequestBuilder.php(neu) —buildPersonalData,buildDeliveryData,buildRequestapp/Http/Controllers/Pay/PayoneController.php—setMethod(),setPersonalData(),setDeliverylData()delegieren; 3×array_merge→buildRequest()
Abweichungen / Entscheidungen:
- Plan nannte
buildAuthorizationRequest()als einheitliche Build-Methode; umgesetzt alsbuildRequest()mit allen Segmenten als optionale Parameter — flexibler, decktonlyPaymentResponse()undResponseData()undcheckBankAccount()gleichermaßen ab. $this->methodbleibt intern einarray(kein Breaking Change);PayoneMethodConfig::toArray()liefert das Array an der Resolver-Grenze. Migration auf typisiertes Property in R4.checkCreditCard()undcheckBankAccount()nutzen weiterhinarray_merge($this->default, $this->prepayment)direkt — keine Method-Config nötig, da keinsetMethod()-Aufruf dort.fnc#PYV(PAYONE Rechnungskauf) wirftInvalidArgumentExceptionstatt 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 mitstatus,txid,userid,redirectUrl,errorMessage,shoppingPaymentapp/Services/Payment/Payone/Exceptions/PayoneTransportException.php(neu)app/Services/Payment/Payone/Exceptions/PayoneDeclinedException.php(neu)app/Services/Payment/Payone/PayonePaymentService.php(neu) —checkCreditCard,checkBankAccount,chargeAboapp/Http/Controllers/Pay/PayoneController.php—setAboPayment,onlyPaymentResponse,checkCreditCard,checkBankAccountdelegieren an Servicedatabase/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.chargeAbonimmt zusätzlichShoppingUser $shoppingUserals Parameter (Plan hatte nur 3 Parameter) — nötig, weilbuildPersonalDataden User braucht.- Abo-Pfad im Controller:
setAboPaymenttriggertchargeAbosofort;onlyPaymentResponsegibt gecachteslastPaymentResultzurück →UserMakeOrder-Aufrufer unverändert. setPersonalData()bleibt im Controller als No-Op nachsetAboPayment(Checkout-Pfad nutzt es weiterhin).shopping_payments.carddatawar in Produktion vorhanden aber nie migriert — fehlende Migration erstellt (idempotent mitSchema::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 ausApi\PayoneController::paymentStatus(Validierung 2001–2006, Duplikat-Erkennung, txaction-Priorität, DB-Lock-Paid-Pfad, Mail)app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php(neu) — Factory-Statesrejected(),duplicateIgnored(),processed();responseist immerTSOKapp/Http/Controllers/Api/PayoneController.php— von ~250 auf 18 Zeilen reduziert; konstruktor-injizierter Handler,return response($result->response)stattecho '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 sendetapplication/x-www-form-urlencodedohne CSRF; die bestehende Param-Vollständigkeitsprüfung (2001) bleibt im Handler, da bei Fehlern weiterhinTSOK(nicht422) an PAYONE zurückgehen muss. throw $eim Paid-Pfad (DB-Transaktionsfehler) bleibt erhalten → PAYONE wiederholt den Callback; keinTSOKbei echtem Verarbeitungsfehler. Bewusst kein Verhaltens-Bruch.- txaction-Priorität als
private const TXACTION_PRIORITYstatt 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:
PayoneRaceConditionTestschlägt bereits auf sauberem Stand fehl (DatabaseTransactionsohne 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—PayoneTransportExceptionin$dontReport;render()mappt sie aufHttpException(403)(Fallback = altesabort(403)-Verhalten)app/Http/Controllers/Web/CheckoutController.php—processPaymentMethod()fängtPayoneTransportException→ Redirect zucheckout.checkout_cardmit Flash-Fehlermeldungtests/Unit/Services/PayoneTest.php— Netzwerkfehler-Test aufPayoneTransportExceptionumgestellt; 1004-Fall ergänzt
Abweichungen / Entscheidungen:
- Zweistufige Fehlerbehandlung statt nur Checkout-Catch: Der globale
Handler::render()bildet uncaughtPayoneTransportException1:1 auf eine 403-Seite ab. So bleiben alle HTTP-Aufrufer (insb.PaymentHelper::initELVPaymentim 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)/HttpExceptionwird von Laravel nicht gemeldet; eine eigene Exception hingegen schon → hätte doppelte Exception-Mails (zusätzlich zur bestehendenMyLog-Mail) ausgelöst. Eintrag in$dontReportverhindert das.- Cron unverändert:
UserMakeOrder::makePaymentfängt bereits\Exception;PayoneTransportException extends RuntimeException→ wird geloggt + re-thrown wie zuvor (vorherHttpException, ebenfalls\Exception). Kein CLI-abortmehr. - Controller-interne Business-Aborts (
PENDING1000, unbekannter clearingtype 1002, kein Status 1001 inPay\PayoneController::ResponseData) bleiben alsabort()— 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 viaapp(PayonePaymentService::class)aus dem Containerapp/Providers/AppServiceProvider.php—PayonePaymentServiceals 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) undResponseData()(HTTP-Redirects/Session/Status-Mapping, ~110 Zeilen) bleiben bewusst im Controller — sie sind reine HTTP-/Checkout-Concerns. Deren Extraktion inPayonePaymentService::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, weilmodeimmer explizit pro Methodenaufruf übergeben wird — der geteilte Singleton braucht nur die Basis-Defaults.Pay\PayoneControllerselbst bleibtnew-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-DTOPayoneRequestBuilder+PaymentMethodResolver+PayoneMethodConfigPayoneTransactionRecorder(zentralePaymentTransaction-Erstellung)PayoneWebhookHandler+PayoneWebhookResult(IPN/TSOK-Callback)PayoneTransportException+ Handler-Mapping (keineabort()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 baremexit()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\PayoneControllerdelegiert 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.phpapp/Http/Controllers/Api/PayoneController.phpapp/Services/Payone.phpapp/Services/PaymentHelper.phpapp/Cron/UserMakeOrder.phpapp/Http/Controllers/Web/CheckoutController.phpconfig/payone.php- zugehörige Tests unter
tests/Feature/*Payone*undtests/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()redaktiertcardpanundcardcvc2in Transportfehler-Logs.PayoneTransportExceptionwird global wieder auf 403 gemappt und im Checkout benutzerfreundlich abgefangen.PayoneWebhookHandlerhält die bestehende TSOK-Semantik bei Validierungsfehlern und lässt echte Paid-Pfad-Exceptions weiterwerfen, damit PAYONE erneut zustellt.PaymentMethodResolverwirft bei unbekannten Methoden explizite Exceptions statt stillem Fehler.- Abo-Abbuchung (
chargeAbo) erstellt weiterhinShoppingPaymentvor 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
- PAYONE-Key-Strategie finalisiert (2026-06-10) —
PAYONE_KEY_HASH(MD5-Hash, kein Klartext).config/payone.phpliest den Wert direkt (kein Runtime-hash()), fehlt er → harter Boot-RuntimeException. Klartext-PAYONE_KEYaus.enventfernt;phpunit.xml+.env.exampleergä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 vialaravel/prompts, optional--writeschreibtPAYONE_KEY_HASHdirekt in die.env). So muss der Klartext-Key nicht dauerhaft in der.envliegen.
- 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
- Webhook-Persistenz zentralisiert (2026-06-10) —
PayoneTransactionRecorder::recordWebhookCallback()neu;PayoneWebhookHandlerruft nur noch den Recorder.PaymentTransaction::createexistiert jetzt ausschließlich im Recorder. - Recorder-Redaction ergänzt (2026-06-10) —
redactPayload()stripptcardpan/cardcvc2austransmitted_datain allen drei Record-Methoden; Teststrips cardpan and cardcvc2 …hinzugefügt. - Webhook-Pflichtfelder abgesichert (2026-06-10) —
txactionist jetzt Pflichtfeld (sonst2001);modefällt im Recorder auf$payment->modezurück; restliche$data['mode']-Zugriffe sind null-sicher. Testrejects a callback missing txactionhinzugefügt. 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 derUpdateAboCreditCardRequest(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 abstatus >= 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/otzeigt zusätzlich offene Abo-Zahlungslinks aus/user/order/payment/links, sofernshopping_data.is_for=abo-ot-customerundshopping_data.is_abo=true.- Abo-Zahlungslinks werden in
/user/abos/otausgeblendet, sobald daraus bereits einUserAboentstanden 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 bei5/6(Fehler/abgebrochen). Bei Status2/3/4(Zahlung läuft bzw. Anbieter wartet) und10(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.phpapp/Services/AboHelper.phpapp/Services/OrderPaymentService.phpapp/Http/Controllers/User/AboController.phpapp/Http/Controllers/User/OrderController.phpapp/Http/Controllers/User/OrderPaymentController.phpresources/views/user/abo/index.blade.phpresources/views/user/order/payment/index.blade.phproutes/domains/crm.phptests/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 aufuser_abossetzen. 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
pseudocardpanist 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.
2. Absicherung der Recovery-Links (Phase 3)
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 mitHash::make, weil dort zuerst per E-Mail gesucht und dannHash::checkausgefü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
selectorfür den Lookup + gehashterverifierfür die Prüfung mit konstantzeitigem Vergleich (hash_equals). Rate-Limiting (throttle), Single-Use (used_at), 72h-TTL und Bindung anuser_abo_idbleiben 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.COMPLETEDist 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).PayonePaymentResultist bereits der Präzedenzfall auf der Output-Seite – einCreditCardCheckData-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 ganzenPayonePaymentServiceauf 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 mitwebhook_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 beimpaid-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
useridklären. - PayPal-Produktentscheidung: Subscriptions API v2 vs. Vault + Reference Transactions.
- Anzahl betroffener Live-Abos erfassen (CC vs. PayPal-via-PAYONE).
Aufgaben
- PAYONE: Mit BS PAYONE klären, welcher Request für CC-Update gilt:
- Option A:
request=updateusermit neuempseudocardpan - Option B: Neuer
creditcardcheck+ Zuordnung zum bestehendenuserid - Testbench-Aufruf dokumentieren (Sandbox-
useridaus bestehendem Test-Abo)
- Option A:
- PayPal: Merchant-Konto prüfen – Subscriptions freigeschaltet? Welche Länder/Währungen?
- Datenbestand: SQL-Auswertung:
SELECT clearingtype, wallettype, status, COUNT(*) FROM user_abos WHERE deleted_at IS NULL GROUP BY clearingtype, wallettype, status; - Abhängigkeiten: Checkout-Flow,
UserMakeAboOrder,AboRetryPaymentServiceals 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 beiclearingtype=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 (
carddataJSON existiert). - Optional:
payment_method_metamitcard_mask,cardexpiredatefü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 neuenpseudocardpan - 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
- CC-Aktualisierung schlägt fehl → kompletter Neuaufbau des PAYONE-
userid - Vorbereitung für CC → PayPal-Wechsel (Phase 7)
- Support: „Karte komplett neu hinterlegen“
Ablauf (Vorschlag)
- Cancel: PAYONE
updateusermitrecurrence=stopoder Entfernen des Zahlungsmittels (PAYONE-Doku Phase 0) - Neu: Wie Checkout-Abo-Init – neuer
creditcardcheck→ neueruseridviaauthorization+recurrence=recurring - DB: Alten
payone_useridinuser_abo_payment_migrationsarchivieren;UserAbomit neuem Token updaten - 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. |
9. Phase 3 – Abo-Zahlungslink bei fehlgeschlagener Abbuchung
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
UserMakeAboOrder/AboRetryPaymentServicebei Fehler →createRecoveryLink()- E-Mail (
MailCheckouterweitern oder neues MailableAboPaymentRecoveryMail) mit Link - User öffnet Link → reduzierter Checkout (Betrag fix, Adresse vorausgefüllt, Zahlungsmethoden wie Shop)
- 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.emailpassen (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
- Migration:
payment_provider,payment_token_ref,subscription_status(siehe 3.2) - Backfill: Bestehende Abos →
payment_provider=payone; PayPal-via-PAYONE →subscription_status=legacy_payone_paypal - 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; } PaymentOrchestratorresolved Provider anhandpayment_provider- Refactor schrittweise:
UserMakeOrder::makePayment()ruft Orchestrator statt direktPayoneController
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ürpayment_method=paypal_directPaymentMethod-Tabelle: neuer Eintrag odershort=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_idniemals 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 erzeugtShoppingOrder+ 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-uptimeum PayPal-Endpoint erweitern- Payment-Dashboard: PayPal-Incidents (Provider
paypalexistiert bereits)
13. Phase 7 – Zahlungsmethode wechseln (CC ↔ PayPal)
Ziel
User kann Zahlungsmethode wechseln: CC (PAYONE) ↔ PayPal (direkt).
Ablauf
- Alte Subscription canceln (PAYONE
recurrence=stopoder PayPalcancel subscription) UserAbo.subscription_status = migrating- Neue Subscription mit gewählter Methode anlegen (Phase 1/5 Flows)
UserAboupdaten: neuer Provider, Token,migrated_from_abo_id= selfsubscription_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-durationSetting)
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)
- Phase Stabilisierung: PayPal direkt min. X Wochen Live-Betrieb ohne kritische Incidents
- User-Kommunikation: E-Mail + Portal-Banner „Bitte Zahlungsmethode einmalig aktualisieren“
- Technisch: Deep-Link in Phase-7-Wizard; alter PAYONE-PayPal-Token wird bei Migration invalidated
- Reporting: Admin-Dashboard: % migriert, offene Legacy-Abos
Kein Big-Bang
- Legacy-Abos weiter über
UserMakeOrder→PAYONE RetryFailedPaypalAbosCommand 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
- Staging: Alle Phasen gegen Sandbox
- Feature-Flags:
ABO_CC_UPDATE_ENABLED,ABO_RECOVERY_LINK_ENABLED,PAYPAL_DIRECT_ENABLED - Canary: CC-Update nur Portal → dann CRM → dann Recovery-Link
- PayPal direkt: Nur Neukunden-Abo-Checkout → nach 2 Wochen Bestands-Migrations-Einladung
- 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
- Phase R1: Hotfixes (
var_dump,PENDING, Log-Redaction, PAYONE_KEY aus.env) (2026-06-09) - Phase R2:
PayoneTransactionRecorderextrahieren (2026-06-09) - Phase R3:
PaymentMethodResolver+PayoneRequestBuilderextrahieren (2026-06-09) - Phase R4:
PayonePaymentService+PayonePaymentResult-DTO (2026-06-09) - Phase R5:
PayoneWebhookHandler+PayoneWebhookResult;Api\PayoneControllerschlank (2026-06-10) - Phase R6:
Payone::sendRequest→PayoneTransportException; Handler-Fallback + Checkout-Catch (2026-06-10) - Phase R7: Toten Code entfernt,
@deprecated+ Container-DI, Service-Provider-Singleton (2026-06-10) — Phase R komplett - Offene Fragen O1–O4 + OR1–OR3 mit Stakeholdern klären
- PAYONE-Support: CC-Update-Request dokumentieren (Phase 0)
- SQL-Bestandsanalyse Live-Abos ausführen
- Review-To-dos aus Abschnitt 5.2 erledigen, dann Phase 1:
AboPaymentMethodServiceaufPayonePaymentServiceaufsetzen
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 |
Phase 3 – Recovery-Link (neues Dashboard-Tab)
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(),
]
);
}
}
PaymentIncidentunterstütztprovider=paypalund 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)