mivita/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md
Kevin dd1c8f9a1e Stripe-Integration (S4, S5, S7.6): Abos, Zahlungsart ändern, Zahlarten-Kommunikation
Phase S4 – Abos über Stripe (E2E komplett im Testmodus):
- Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session
- StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key),
  StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert)
- Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei
  Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min
  Scheduler) verarbeitet liegengebliebene Events nach
- createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung;
  Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug)
- Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20)
  inkl. Reparatur der zu 'str' gekürzten Live-Zeilen

Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte):
- Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf
  Stripe um (Auslaufmodell §7.2)
- AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod
  (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded
  als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig
- Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte")

Phase S7.6 – Zahlarten-Kommunikation:
- Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos;
  Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu
  mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert)
  und Vorkasse (PAYONE); Sofort/SEPA entfernt

Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10),
PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:17 +00:00

69 KiB
Raw Blame History

Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option)

Status: In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ — live in Produktion; S4 ✓ E2E komplett (Abschluss, Cron, Decline→Recovery, authentication_required); S5 ✓ „Zahlungsart ändern" inkl. E2E über CRM + Kunden-Link (nur Portal-Pfad ohne Sichttest, gleicher Code-Pfad); S7.6 ✓ Zahlarten-Kommunikation — vor Abo-Freigabe: Deployment + 3 Migrationen, dann abo-payments-frozen aufheben, §16) · Erstellt: 2026-07-08 · Zuletzt aktualisiert: 2026-07-09

Lage-Update 2026-07-09 (Stakeholder): Das PAYONE-Konto ist wieder freigeschaltet. Daraus ergeben sich folgende verbindliche Entscheidungen (Details §7.2):

  1. Auslaufmodell statt Migration: Bestehende PAYONE-Abos laufen unverändert über PAYONE weiter (Kunden müssen nicht aktiv werden). Neue Abos werden über Stripe angelegt; wird das Zahlungsmittel eines Abos geändert (Recovery-Zahlung → S4, Kreditkarte-ändern-Flow → S5), wechselt das Abo dabei auf Stripe. PAYONE läuft so Stück für Stück im Hintergrund aus.
  2. Vorkasse (PAYONE) wieder anbieten: Der Checkout zeigt neben der Stripe-Online- Zahlung wieder „Vorkasse (PAYONE)" (Setting payment-vorkasse-hidden → aus).
  3. Zahlarten-Kommunikation (→ S7.6): Stripe-Zahlarten klar benennen, möglichst mit Logos — im Checkout, auf den Shop-Seiten, im Footer (Zahlungsmöglichkeiten) und auf der Seite /zahlungsarten. ✅ umgesetzt 2026-07-09 (§11, Umsetzungsstatus S7.6).
  4. Rückbuchungen & Stornierungen erfassen — als eigener, nachgelagerter Punkt aufgenommen (→ §12.1 / Phase S9). Test-Server: Schalter payment-active-provider steht auf stripe (seit 2026-07-08, für den Manualtest); zurückstellen: Setting::setContentBySlug('payment-active-provider', 'payone', 'text'). Begleitdokumente: ANALYSE-IST-ZUSTAND.md (Codebase-Analyse) · STRIPE-KONZEPT.md (Architektur & API-Mapping) Vorarbeiten: dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md (Phase R ✅), dev/2026-06-25-kreditkarte-update/

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

Stripe wird als primäre Zahlungsabwicklung eingeführt und ersetzt PAYONE im Tagesgeschäft. Dabei gilt:

# Anforderung
A Stripe übernimmt nur die Zahlungsabwicklung. Rechnungen, Lieferscheine, Provisionen, DATEV etc. bleiben im System.
B PAYONE wird nicht entfernt. Es bleibt vollständig funktionsfähig, sauber vom Stripe-Code separiert.
C Schalter in den Einstellungen wählt den aktiven Anbieter für neue Zahlungsvorgänge (payone ↔ stripe). Bestandszahlungen/-abos behalten ihren Anbieter.
D Scope-Reihenfolge: 1. Einmalzahlungen, 2. Abos (Erstzahlung + wiederkehrende Abbuchung).
E Netto/Brutto je Land wird weiterhin systemseitig berechnet; Stripe erhält nur den Bruttobetrag in Cent.

Kern-Architekturentscheidung (Begründung in STRIPE-KONZEPT.md §2): Stripe wird als reiner Charge-/Token-Provider genutzt — PaymentIntents + gespeicherte PaymentMethods, kein Stripe Billing / Invoicing / Tax. Der Abo-Zyklus (Cron user:make_abo_order, Betragsberechnung, Rechnungen) bleibt unverändert im System.

2. Zielarchitektur (Kurzfassung)

Checkout / Abo-Cron / Recovery / Widerruf
        │
        ▼
PaymentProviderManager  ← Einstellungs-Schalter (neu) + payment_provider-Spalte (Bestand)
        │
        ├── PayoneGateway  → Adapter um bestehenden PayonePaymentService (unverändert)
        └── StripeGateway  → app/Services/Payment/Stripe/* (neu)
                              ├── StripeClientWrapper (injizierbar, testbar)
                              ├── StripePaymentService (PaymentIntents, SetupIntents, Refunds)
                              ├── StripeWebhookHandler (+ DTOs)
                              └── StripeTransactionRecorder

Neue DB-Felder: payment_provider auf user_abos/shopping_payments/payment_transactions, stripe_customer_id/stripe_payment_method_id auf user_abos, stripe_customer_id am User/Customer, external_reference auf shopping_payments. Details: Konzept §4.3.

3. Phasen-Übersicht

Phase Titel Aufwand (Schätzung) Priorität Abhängigkeiten
S0 Grundlagen: Account, SDK, Config, Entscheidungen 1–2 PT Blocker – (O-Fragen beantwortet 2026-07-08)
S1 Provider-Abstraktion, Datenmodell & Einstellungs-Schalter 4–6 PT P0 S0
S2 Stripe-Basis: Client, Recorder, Webhook-Endpoint 4–6 PT P0 S1
S3 Einmalzahlung im Checkout über Stripe 6–10 PT P1 S2
S4 Abos: Erstzahlung + wiederkehrende Abbuchung 8–12 PT P1 S3
S5 Zahlungsmittel-Update über Stripe (SetupIntent) 3–5 PT P2 S4
S6 Fehlerfälle, 3DS-Recovery & Incident-Anbindung 4–6 PT P1–P2 S4
S7 Admin & Betrieb: Schalter-UI, Refunds, Monitoring 4–6 PT P2 S3 (Refunds), S1 (UI)
S8 Rollout & optionale Abo-Migration PAYONE → Stripe 3–5 PT P3 S4–S7

PT = Personentage (1 Entwickler). Einmalzahlung produktiv nach S3+S7-Teilen; Abos nach S4+S6.


4. Phase S0 – Grundlagen · Status: WEITGEHEND ERLEDIGT (2026-07-08)

Ziel

Technische und organisatorische Voraussetzungen schaffen; keine Feature-Logik.

Aufgaben

# Aufgabe Status
S0.1 Offene Fragen O1–O9 klären (Konzept §10); N2/N3 ebenfalls beantwortet. ✅ 2026-07-08 · N1 offen (Klarna/Amazon Pay off_session gegen aktuelle Stripe-Doku prüfen, spätestens zu S4-Beginn)
S0.2 Stripe-Account (vorhanden, Stakeholder verwaltet): Test-Keys beschaffen, Webhook-Endpoint (Test) konfigurieren, gewünschte Zahlarten im Dashboard aktivieren (Karte, PayPal, Apple/Google Pay, Amazon Pay, Klarna, …). ✅ 2026-07-08 — Test-Keys in .env, per Balance-API verifiziert (livemode: false). Webhook auf dem Test-Server via Stripe CLI (stripe listen --forward-to … --skip-verify, CLI-Signing-Secret als STRIPE_WEBHOOK_SECRET); Dashboard-Endpoint + eigenes whsec erst für Produktion nötig (Anleitung §6). Zahlarten-Aktivierung im Dashboard: vor S3-Manualtest prüfen.
S0.3 Composer: stripe/stripe-php installieren (Freigabe O6). ✅ 2026-07-08 — stripe/stripe-php ^20.3 in composer.json/composer.lock
S0.4 config/stripe.php anlegen (secret, key, webhook_secret, mode); .env.example, .env, phpunit.xml ergänzt. ✅ 2026-07-08 — Abweichung s. u.
S0.5 PaymentProvider-Enum anlegen. ✅ 2026-07-08 — app/Enums/PaymentProvider.php (Payone/Stripe, string-backed)

Abweichungen / Entscheidungen

  • Kein Boot-Hard-Fail in config/stripe.php (Plan sah das Muster aus config/payone.php vor): PAYONE ist produktiv im Einsatz — dort ist ein fehlender Key ein Fehler. Stripe dagegen wird per Dark Launch deployt (Rollout-Schritt 2, §12); ein Boot-Hard-Fail würde jedes Deployment ohne gesetzte Stripe-Keys sofort brechen. Stattdessen: Config liest env() tolerant, der StripeClientWrapper (Phase S2) schlägt beim ersten Zugriff hart fehl, wenn stripe.secret fehlt. Begründung als Kommentar in config/stripe.php dokumentiert.
  • Enum liegt unter app/Enums/ (neu, via make:enum) — im Plan §15 so vorgesehen.

Tests & Verifikation (2026-07-08)

  • tests/Feature/StripeFoundationTest.php (neu): Config-Werte aus Env, kein Boot-Fail bei fehlenden Secrets, Enum-Werte/from/tryFrom, SDK-Klasse vorhanden — 4 Tests, 11 Assertions, grün.
  • PAYONE-Regressionssuite (PayoneTest, PaymentMethodResolverTest, PayonePaymentServiceTest, PayoneWebhookHandlerTest, PayoneTransactionRecorderTest, PayoneTransportExceptionHandlingTest): 45 passed, 1 skipped (optionaler Netzwerktest), 209 Assertions — unverändert grün.
  • ./vendor/bin/pint --dirty grün.

Akzeptanzkriterien

  • SDK installiert, composer.lock aktualisiert, Test-Suite weiterhin grün.
  • Keys nur via .env; kein Secret im Repo. (Hard-Fail bewusst in S2 verschoben, s. Abweichung.)
  • Alle O-Fragen beantwortet oder explizit vertagt (N1 mit Vermerk offen).
  • Test-Keys + Webhook-Secret in .env eingetragen (S0.2, Stakeholder).

5. Phase S1 – Provider-Abstraktion, Datenmodell & Schalter · Status: ERLEDIGT (2026-07-08)

Ziel

Das System kennt zwei Anbieter. Jede Zahlung/jedes Abo trägt seinen Anbieter fest; ein Einstellungs-Schalter bestimmt den Anbieter für neue Vorgänge. PAYONE-Verhalten ändert sich nicht (Default payone, Schalter steht auf payone).

Aufgaben

# Aufgabe Status
S1.1 Migrationen: payment_provider auf user_abos, shopping_payments, payment_transactions (Default 'payone'); stripe_customer_id + stripe_payment_method_id auf user_abos; external_reference auf shopping_payments; stripe_customer_id an users/customers. ✅ 2026_07_08_140000_add_payment_provider_columns.php (idempotent mit hasColumn-Guards, Haus-Stil)
S1.2 PaymentGatewayInterface + DTOs definieren. ✅ Contracts/PaymentGatewayInterface.php, DTOs/AboChargeResult.php — Abweichung s. u.
S1.3 PayoneGateway als Adapter um PayonePaymentService — kein Umbau am PAYONE-Code. ✅ app/Services/Payment/PayoneGateway.php; PayonePaymentService unverändert
S1.4 PaymentProviderManager: providerForNewCheckout(), providerFor(), gateway(). ✅ app/Services/Payment/PaymentProviderManager.php
S1.5 Einstellungs-Schalter als persistente Admin-Einstellung. ✅ Bestehender Settings-Mechanismus (Setting::get/setContentBySlug), Slug payment-active-provider (Typ text); fehlender/ungültiger Wert → Fallback payone. Admin-UI + SysLog folgen in S7.1.
S1.6 Abo-Cron/Retry lesen den Provider des Abos (nicht den Schalter). ✅ Guard in UserMakeOrder::makePayment(): Nicht-PAYONE-Provider → Log + RuntimeException vor jedem PAYONE-Aufruf. Deckt auch AboRetryPaymentService ab (delegiert an UserMakeOrder). Echte Stripe-Verzweigung kommt in S4.4.

Abweichungen / Entscheidungen

  • Interface wächst phasenweise: Deklariert sind nur provider() und chargeAbo() (einziger realer Aufrufer heute). initiateCheckoutPayment() (S3), setupPaymentMethodUpdate() (S5) und refund() (S7) werden erst mit ihrem Aufrufer ergänzt — kein toter Vertrag, kein Zwang zu Dummy-Implementierungen im Adapter. Im Interface-Docblock dokumentiert.
  • payment_provider bleibt String-Spalte ohne Enum-Cast am Model — der PaymentProviderManager normalisiert via PaymentProvider::tryFrom() mit PAYONE-Fallback. So können keine bestehenden Blade-/Array-Zugriffe brechen.
  • Schalter-Ablage: bestehender settings-Mechanismus statt eigener Tabelle (gleiches Muster wie abo-lock-days-change, abo-card-update-link-ttl-hours).
  • PaymentProviderManager::gateway(Stripe) wirft bewusst RuntimeException, bis das Stripe-Gateway existiert (S2/S4) — Fail-fast statt stillem Fallback auf PAYONE.
  • Models: payment_provider/stripe_*/external_reference/provider in die $fillable von UserAbo, ShoppingPayment, PaymentTransaction aufgenommen.

Tests & Verifikation (2026-07-08)

  • tests/Feature/PaymentProviderManagerTest.php (neu): 12 Tests, 25 Assertions — Schalter-Default/stripe/ungültiger Wert, Backfill-Default payone, Spalte schlägt Schalter, Gateway-Auflösung (PayoneGateway / Stripe-Exception), AboChargeResult-Mapping (APPROVED/ERROR), Gateway-Delegation (gemockter PayonePaymentService), Cron-Guard blockt Stripe-Abo.
  • Regression: PAYONE-Suite + StripeFoundationTest (49 passed, 1 skipped, 220 Assertions) und Abo-Retry/Recovery-Suite (AboPendingInitialPaymentTest, AboRecoveryAdvanceTest, AboUserRetryTest, AdminAboRetryPaymentTest, RetryFailedPaypalAbosTest: 21 passed, 56 Assertions) — unverändert grün.
  • ./vendor/bin/pint --dirty grün (nur Format-Fixes in ShoppingPayment).

Akzeptanzkriterien

  • Schalter existiert, Default payone, Systemverhalten identisch zu vorher.
  • Kein PAYONE-Service wurde inhaltlich geändert (nur Adapter obendrauf; einzige Bestandsänderung: defensiver Provider-Guard im Cron + $fillable-Erweiterungen).

6. Phase S2 – Stripe-Basisdienste · Status: ERLEDIGT (2026-07-08)

Ziel

Stripe technisch angebunden: Client, Transaktions-Protokollierung, Webhook-Empfang — noch ohne Checkout-UI.

Aufgaben

# Aufgabe Status
S2.1 StripeClientWrapper — injizierbare Hülle um \Stripe\StripeClient; Singleton im AppServiceProvider. ✅ Lazy-Init; Hard-Fail bei Nutzung ohne STRIPE_SECRET (der aus S0 verschobene Fail-fast); isConfigured() für Dark-Launch-Checks
S2.2 StripeTransactionRecorder — einzige Stelle für PaymentTransaction mit provider='stripe'; Redaction. ✅ recordFromStripeObject(); redactPayload() entfernt client_secret rekursiv (Stripe-Payloads enthalten kein PAN — sensibel ist das Intent-Secret)
S2.3 StripeCustomerService — Customer idempotent anlegen, ID persistieren. ✅ customerIdFor(User|Customer); vorhandene ID wird nie überschrieben; Metadata mivita_type/mivita_id
S2.4 Webhook: Route, schlanker Controller, Handler mit Signaturprüfung + Idempotenz. ✅ POST /stripe/webhook (routes/api.php, läuft unter api.<domain>); Api\StripeWebhookController (Muster R5); StripeWebhookHandler + StripeWebhookResult-DTO
S2.5 Logging-Kanal stripe.log. ✅ config/logging.php, daily, Level info (bewusst gesprächiger als payone — Rollout-Beobachtung)

Abweichungen / Entscheidungen

  • Idempotenz über eigene Tabelle stripe_webhook_events (event_id unique, Payload redaktiert als Audit, processed_at): Migration 2026_07_08_150000_create_stripe_webhook_events_table.php + Model StripeWebhookEvent. Race-sicher: Exists-Check + Unique-Constraint-Catch (paralleler Stripe-Retry → duplicate).
  • HTTP-Statuscodes statt PAYONE-„immer TSOK": ungültige Signatur → 400 (kein Retry sinnvoll); fehlendes STRIPE_WEBHOOK_SECRET → 500 (Stripe stellt später erneut zu); Duplikat/unbehandelter Typ/verarbeitet → 200. Im DTO dokumentiert.
  • Zahlungs-Zuordnung im Handler: metadata.shopping_payment_id (wird ab S3 gesetzt) oder external_reference (PaymentIntent-/Session-ID); ohne Treffer wird das Event trotzdem gespeichert und bestätigt (Business-Verdrahtung folgt in S3/S4 an dispatchEvent()).
  • Behandelte Event-Typen als HANDLED_TYPES-Konstante gemäß Konzept §7 (inkl. checkout.session.completed/expired); unbekannte Typen werden protokolliert + 200.

Tests & Verifikation (2026-07-08)

  • tests/Feature/StripeWebhookHandlerTest.php (neu): 11 Tests, 35 Assertions — ungültige Signatur (400, nichts gespeichert), fehlendes Secret (500), Duplikat (200, eine Zeile), unbehandelter Typ (gespeichert + 200), Transaktion via Metadata (provider stripe, client_secret redaktiert), Auflösung via external_reference
    • processed_at, rekursive Redaction, Wrapper-Hard-Fail ohne Secret, Lazy-Client, CustomerService (vorhandene ID ohne API-Call; Create persistiert ID — Stripe-Client als Partial-Mock auf request, kein echter HTTP-Call).
  • Handler wird wie beim PAYONE-Pendant direkt getestet (HTTP-Routen sind in Pest nicht domain-aware); Route via php artisan route:list verifiziert: POST api.mivita.test/stripe/webhook → api.stripe_webhook.
  • Regression: PaymentProviderManagerTest + StripeFoundationTest + PayoneWebhookHandlerTest + PayoneTransactionRecorderTest: 35 passed, 131 Assertions. pint --dirty grün.

Akzeptanzkriterien

  • PaymentTransaction-Schreibzugriffe für Stripe existieren nur im Recorder.
  • Webhook idempotent, signaturgeprüft, protokolliert (per Test nachgewiesen).
  • Ende-zu-Ende gegen das laufende System verifiziert (2026-07-08): signierter payment_intent.succeeded-Request an api.mivita.test/stripe/webhook (echte Signaturprüfung mit dem whsec aus .env) → 200 processed, Event-Zeile mit processed_at; erneuter identischer Request → 200 duplicate (weiterhin 1 Zeile); manipulierte Signatur → 400 invalid_signature. Migrationen auf der Dev-MySQL ausgeführt (add_payment_provider_columns, stripe_webhook_events). Test-Keys per Balance-API bestätigt (livemode: false).
  • Echte Stripe-Events über stripe listen verifiziert (2026-07-08): Testmodus- PaymentIntent (API, pm_card_visa) → Listener → Endpoint. payment_intent.succeeded signaturgeprüft verarbeitet (processed_at gesetzt); Begleit-Events (payment_intent.created, charge.succeeded, charge.updated) korrekt als unbehandelte Typen gespeichert + bestätigt. Stolpersteine dabei (für die Nachwelt): 1. Listener lief zunächst mit dem API-Key eines anderen Stripe-Accounts, 2. api.mivita.test fehlte in der /etc/hosts des Hosts, 3. whsec muss vom mivita-Listener stammen (CLI-Secret ist account-gebunden).

Webhook-Einrichtung (S0.2)

Test-Server (aktuell im Einsatz): Stripe CLI auf dem Host — kein Dashboard-Endpoint nötig:

stripe listen --forward-to https://api.mivita.test/stripe/webhook --skip-verify

Das von stripe listen ausgegebene CLI-Signing-Secret (whsec_…) als STRIPE_WEBHOOK_SECRET in die .env. --skip-verify wegen des selbstsignierten .test-Zertifikats. Events auslösen: stripe trigger payment_intent.succeeded (bzw. echte Testmodus-Checkouts ab S3 — stripe listen leitet standardmäßig alle Events weiter, unser Handler bestätigt unbehandelte Typen mit 200 ignored).

⚠️ Mehrere Apps / Accounts auf demselben Test-Server: Das CLI-Signing-Secret gehört zum Stripe-Account des Listeners, nicht zur Forward-URL. Ein für eine andere App (anderer Account, z. B. pressekonto) laufender Listener empfängt keine mivita-Events, und sein whsec passt nicht zu unserer Signaturprüfung. Für mivita daher einen eigenen Listener parallel starten — einfachster Weg ohne Re-Login:

stripe listen --api-key sk_test_…(mivita) \
  --forward-to https://api.mivita.test/stripe/webhook --skip-verify

Das dabei ausgegebene whsec_… in die mivita-.env, dann php artisan config:clear. Testen: stripe trigger payment_intent.succeeded --api-key sk_test_…(mivita). (Stand 2026-07-08: das zuerst eingetragene whsec stammte vom pressekonto-Listener und muss durch das mivita-Listener-Secret ersetzt werden.)

Produktion (später, vor Rollout-Schritt 2):

  1. Dashboard → Developers → Webhooks → Add endpoint.
  2. Endpoint-URL: https://api.mivita.care/stripe/webhook.
  3. Events auswählen: checkout.session.completed, checkout.session.expired, payment_intent.succeeded, payment_intent.processing, payment_intent.payment_failed, charge.succeeded (Zahlart-Anzeige), charge.refunded, charge.dispute.created, setup_intent.succeeded.
  4. Das Endpoint-Signing-Secret (whsec_…, ein anderes als das CLI-Secret!) als STRIPE_WEBHOOK_SECRET in die Produktions-.env.

7. Phase S3 – Einmalzahlung im Checkout ⭐ · Status: ERLEDIGT & LIVE (2026-07-09)

Ziel

Steht der Schalter auf stripe, laufen neue Einmalzahlungen (Shop, Zahlungslinks) über die Stripe-Hosted-Checkout-Seite (Redirect-Flow, Entscheidung 2026-07-08, Konzept §3.1): Zahlartenwahl und Dateneingabe finden komplett bei Stripe statt. PAYONE-Checkout bleibt bei Schalter payone unverändert.

Ablauf (Soll)

mivita-Checkout (Adresse, Warenkorb, AGB — unverändert)
  → „Jetzt zahlen" → CheckoutController
  → PaymentProviderManager::providerForNewCheckout()
     ├── payone → bestehender Pfad inkl. Zahlartenwahl (unverändert)
     └── stripe → StripeGateway::initiateCheckoutPayment()
                   ├── ShoppingPayment anlegen (provider=stripe, mode, Betrag brutto)
                   ├── Checkout Session erstellen (mode=payment, amount=Cent, EUR,
                   │    customer, metadata: order/payment/mode, success_url, cancel_url;
                   │    Zahlarten dynamisch via Stripe-Dashboard)
                   └── 303 Redirect → checkout.stripe.com
Kunde wählt bei Stripe die Zahlart (Karte/PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna/…),
zahlt (inkl. 3DS) und wird zurückgeleitet:
  ├── success_url → Bestätigungsseite (Status nur zur UX-Anzeige)
  └── cancel_url  → zurück in den mivita-Checkout (Warenkorb bleibt erhalten)
Webhook checkout.session.completed / payment_intent.succeeded
  → paymentStatusPaidAction-Pfad (Order bezahlt, Mails, Incentive) — einzige Wahrheit

Aufgaben

# Aufgabe
S3.1 Checkout-Verzweigung nach Provider in CheckoutController; bei stripe entfällt die Zahlartenwahl — nach Checkout-Prüfung direkt zu Stripe (N2, entschieden 2026-07-08). Ausnahme: ist der Vorkasse-Schalter (S3.8) aktiv bzw. FNC freigeschaltet, wird eine reduzierte Wahl „Online bezahlen / Vorkasse (/ Rechnung)" angezeigt. Dabei die im Alt-Plan aufgeschobene Extraktion des Checkout-HTTP-Flows aus Pay\PayoneController nur soweit nötig vorziehen (keine PAYONE-Verhaltensänderung).
S3.2 StripePaymentService::initiateCheckoutPayment(): Checkout Session (mode=payment) erstellen. Betrag: bestehende Cart-Brutto-Summe, Rundung auf Cent an einer Stelle; Währung fest EUR; line_items aus dem Warenkorb (Anzeige auf der Stripe-Seite), Metadata mit Order-/Payment-Referenz; Session-ID in shopping_payments.external_reference.
S3.3 Redirect-Handling: 303 auf die Session-URL; success_url/cancel_url domain-korrekt generieren (Multi-Tenant beachten, routes/domains/…). cancel_url führt zurück in den Checkout, Warenkorb/ShoppingInstance bleiben nutzbar.
S3.4 Success-/Cancel-Controller: liest Session-/PaymentIntent-Status nur für UX (paid/processing/abgebrochen), verändert keine Bezahlt-Flags. Abgelaufene Sessions (checkout.session.expired) räumen den offenen Vorgang auf.
S3.5 Webhook-Paid-Pfad: checkout.session.completed + payment_intent.succeeded → gemeinsame, provider-neutrale Paid-Verarbeitung (Wiederverwendung Payment::paymentStatusPaidAction()-Logik; ggf. Extraktion in provider-neutralen Service). Preis-/Betragsabgleich wie beim PAYONE-Webhook (Guard 2005-Äquivalent).
S3.6 processing-Status (SEPA & Co.) im Bestell-/Zahlungsstatus sichtbar machen (Muster „Zahlung in Verarbeitung" aus Alt-Plan §5.2.1).
S3.7 Zahlungslinks (OrderPaymentService, ShoppingInstance) provider-fähig machen: neuer Link erzeugt bei Schalter stripe direkt eine Checkout Session (der Link ist dann faktisch die Stripe-Seite mit unserem Vor-/Nachlauf).
S3.8 Vorkasse-Sichtbarkeits-Schalter (N3, entschieden 2026-07-08): eigene Einstellung, um Vorkasse (vor, läuft über PAYONE) unabhängig vom Provider-Schalter aus-/einzublenden — Vorkasse bleibt technisch erhalten. Interne Rechnung (FNC) bleibt wie heute über payment_methods.active steuerbar (aktuell praktisch überall deaktiviert).

Tests

  • Feature: Checkout-Verzweigung je Schalterstellung; Session-Parameter (Betrag = Cart-Brutto in Cent, EUR, Metadata, success/cancel-URLs, Abo-Einschränkung greift hier nicht) gegen gemockten Client.
  • Webhook-Paid: Order wird bezahlt, Mails/Incentive-Pfad läuft, Duplikat-Event ändert nichts, Betrags-Mismatch wird abgewiesen + Incident; session.expired räumt auf.
  • Cancel-Pfad: Rücksprung lässt Warenkorb intakt, kein Zahlungs-/Order-Nebeneffekt.
  • Netto/Brutto: Testfälle mit mind. 2 Ländern/Steuersätzen (Cart-Brutto ↔ Rechnungs-Netto unverändert zu PAYONE-Pfad).
  • Regression: kompletter PAYONE-Checkout-Testbestand grün bei Schalter payone.

Umsetzungsstatus & Abweichungen (2026-07-08)

# Status
S3.1 ✅ CheckoutController: stripeCheckoutActive() (Schalter, Abos/Recovery bleiben bis S4 auf PAYONE); View zeigt bei Stripe eine einzige „Online-Zahlung"-Option + VOR/FNC (sofern aktiv); checkoutFinal validiert payment_method=stripe gegen den Schalter (sonst 404). Keine PAYONE-Extraktion nötig — Verzweigung liegt vor dem PAYONE-Pfad.
S3.2 ✅ StripePaymentService::initiateCheckoutPayment(). Abweichung: ein einzelnes line_item mit dem Gesamt-Bruttobetrag statt Warenkorb-Positionen — eliminiert jedes Rundungs-/Summenabweichungsrisiko zwischen Cart und Stripe; Positionsdetails stehen auf der Systemrechnung. Metadata (shopping_order_id, shopping_payment_id, reference, mode) auf Session und PaymentIntent.
S3.3 ✅ Redirect via redirect()->away($session->url); Session-ID → external_reference; Initiations-Transaktion checkout.session.created protokolliert (Bestätigungsseite hat dadurch immer eine pay_trans).
S3.4 ✅ Abweichung: kein neuer Return-Controller — success_url/cancel_url nutzen die bestehenden checkout.transaction_status-Routen (Status-UX, Cart-Destroy, Fehlerseiten inkl. Cancel-Rücksprung wie bei PAYONE-Redirects). checkout.session.expired räumt serverseitig auf (Status expired, Instanz link_canceled).
S3.5 ✅ StripeWebhookHandler::dispatchEvent() → gleiche txaction-Taxonomie wie PAYONE (appointed/pending/failed/paid + Prioritäten): session.completed(paid) & payment_intent.succeeded → Paid-Pfad (Payment::paymentStatusPaidAction, DB-Lock, Betrags-Guard analog 2006, Mail einmalig); session.completed(unpaid) → appointed + Bestellbestätigung; Reprocessing: gespeichertes, aber unverarbeitetes Event (Crash → Stripe-Retry) wird erneut verarbeitet statt als Duplikat verworfen.
S3.6 ✅ payment_intent.processing → txaction pending — vorhandene Statusanzeigen greifen durch die gemeinsame Taxonomie automatisch. Keine Mail (Bestellbestätigung ging bei appointed raus).
S3.7 ✅ implizit: Zahlungslinks führen in denselben Checkout → Verzweigung greift automatisch. Beim Browser-Manualtest mit verifizieren.
S3.8 ✅ Vorkasse-Schalter: Setting payment-vorkasse-hidden (bool; fehlend/false = sichtbar) → PaymentProviderManager::isVorkasseVisible(), Filter in CheckoutRepository::getPaymentsMethods() (wirkt provider-unabhängig, auch im PAYONE-Betrieb). Admin-UI folgt in S7.1.

Ergänzung Zahlart-Anzeige (2026-07-08): charge.succeeded wird jetzt verarbeitet — die payment_method_details (Typ, Kartenmarke, letzte 4, Apple/Google-Pay-Wallet, Ablauf) werden in shopping_payments.carddata gespeichert; ShoppingPayment::getPaymentType() zeigt daraus z. B. „Apple Pay (Amex •••• 2003)", „Kreditkarte (Visa •••• 4242)", „PayPal", „Klarna". Reihenfolge-sicher ohne API-Call: Kommt das Charge-Event vor dem PaymentIntent-Event, zieht markPaid() die Details aus dem Event-Store nach (backfillPaymentMethodDetails). Die PaymentIntent-ID wird dabei als external_reference an der Zahlung gemerkt. Checkout-UI-Feinschliff nach Stakeholder-Feedback: Radio heißt „Online-Zahlung", Stripe-Logo (payments-assets/stripe.png), darunter Zahlarten-Zeile + Hinweistext.

Weitere Entscheidung: payment_intent.payment_failed verschickt bewusst keine Mail — auf der Hosted Session kann der Kunde direkt erneut zahlen; jeder Fehlversuch feuert das Event (Mail pro Versuch wäre Spam). ShoppingPayment::getPaymentType() zeigt für Stripe-Zahlungen „Online-Zahlung (Stripe)"; neue Lang-Keys payment.stripe_* (de/en, du-Form).

Tests (2026-07-08): tests/Feature/StripeCheckoutTest.php (neu): 9 Tests — Session-Parameter/Payment/Initiations-Transaktion (gemockter Client), Paid-Pfad + genau eine Mail, kein Doppel-Mail bei zweitem Success-Event, Betrags-Mismatch verweigert, appointed + Bestellbestätigung (async), failed ohne Mail, expired, Crash-Retry- Reprocessing, Vorkasse-Schalter. Gesamt Stripe-Suite 36 Tests, 102 Assertions grün; Payment-Regression (PAYONE + Abo-Retry/Recovery, 66 Tests) grün; Blade kompiliert; Pint grün. (Hinweis: die volle Suite hat eine vorbestehende makeUser()-Redeclare-Kollision, unabhängig von S3.)

Akzeptanzkriterien

  • Browser-E2E: Checkout → Redirect zu Stripe → Zahlung → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System. (2026-07-08 im Testmodus; seit Go-Live in Produktion bestätigt — Stakeholder 2026-07-09: „steht und funktioniert".)
  • Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy.
  • Abbruch bei Stripe führt sauber in den Checkout zurück.
  • Umschalten des Schalters wechselt ausschließlich neue Checkouts (per Test nachgewiesen).

7.1 Notfall-Go-Live (2026-07-08): PAYONE-Ausfall — Einmalzahlungen live, Abos eingefroren

Lage: PAYONE ist ausgefallen. Entscheidung Stakeholder: Der S3-Stand (Einmalzahlungen über Stripe) geht sofort produktiv; Phase S4 (Abos) wird nachgelagert. Bis dahin werden alle Abo-Zahlungen eingefroren und mit einem Hinweis versehen („derzeit nicht möglich, wir arbeiten an einer technischen Lösung").

Not-Schalter abo-payments-frozen (Setting, bool)

Eingefroren Verhalten
Abo-Checkout (Neuabschluss) & Recovery-Checkout Zahlarten-Box + Kaufen-Button ersetzt durch Warnhinweis (payment.abo_payments_frozen); Server-Guard in checkoutFinal blockiert Submits zusätzlich.
Abbuchungs-Cron user:make_abo_order Kompletter Lauf wird übersprungen (Log-Warnung). Abos werden nicht als fehlgeschlagen markiert, next_date bleibt stehen → nach dem Auftauen holt der Cron alles regulär nach.
Abo-Retry (Admin/CRM/Portal, AboRetryPaymentService) Liefert Fehler mit dem Hinweistext.
Kreditkarte ändern (Kunden-Link, Portal, CRM) Zentraler Guard in AboPaymentMethodService::updateCreditCard() → CardUpdateResult::error mit Hinweistext; das gemeinsame Formular-Partial (portal/abo/_payment_method_form) zeigt statt der Kartenfelder die Warnbox; Versand neuer Card-Update-Links (User\AboController::sendCardUpdateLink) ist geblockt (Flash-Hinweis), damit keine Kunden-Mails auf ein eingefrorenes Formular führen.
Abschluss neuer Abos Bereits über den Abo-Checkout-Guard abgedeckt (Hinweis + Server-Block, s. o.).

Nicht eingefroren: Einmalzahlungen (Stripe), Vorkasse-/FNC-Sichtbarkeit (eigene Schalter), CC-Update-Flow (schlägt bei PAYONE-Ausfall mit sauberer Fehlermeldung fehl). Der Freeze wird nach S4-Abschluss aufgehoben (Setting → false); danach zahlen Bestands-PAYONE-Abos wieder über PAYONE (sofern wiederhergestellt) bzw. neue über Stripe.

Checkout-Wartungsmodus mit Admin-Bypass (checkout-maintenance, bool)

Für den kontrollierten Live-Start: Ist der Schalter an, sehen alle Besucher im Checkout nur „Technische Schwierigkeiten — wir sind in Kürze wieder für dich da" (payment.checkout_maintenance); Admins (User::isAdmin(), Session gilt domänenübergreifend) können normal bestellen. So lässt sich Stripe im Live-Modus mit einer echten Bestellung verproben, bevor der Checkout für alle öffnet. Guards in CheckoutController::checkout() (Meldungsseite) und checkoutFinal() (Redirect).

Admin-Settings-Karte „Zahlungen (Stripe / PAYONE)" (S7.1 vorgezogen)

Unter https://my.mivita.care/admin/settings (Karte nach „Kauf Einstellungen"):

  • Aktiver Zahlungsanbieter (Select PAYONE/Stripe → payment-active-provider)
  • Checkout-Wartungsmodus (Checkbox → checkout-maintenance)
  • Abo-Zahlungen einfrieren (Checkbox → abo-payments-frozen)
  • Vorkasse ausblenden (Checkbox → payment-vorkasse-hidden)

Alle Schalter sind damit ohne Tinker im Admin schaltbar (bestehender SettingController-Mechanismus). SysLog-Protokollierung folgt mit dem Rest von S7.1.

Tests: Retry-Freeze, Cron-Skip (Abo bleibt Status 2, keine user_abo_orders), CC-Update-Freeze (updateCreditCard → Error mit Hinweistext), Manager-Defaults aus — in StripeCheckoutTest.php; Karten-Update-Regressionssuite (17 Tests) unverändert grün.

Go-Live-Checkliste Produktion — ✅ durchgeführt am 2026-07-08 abends

  1. Deployment des aktuellen Stands (Branch payment/phase-r-payone-refactoring).
  2. php artisan migrate (damals 3 neue Migrationen: Provider-Spalten, stripe_webhook_events).
  3. Prod-.env: STRIPE_KEY/STRIPE_SECRET = Live-Keys (pk_live_…/sk_live_…), STRIPE_MODE=live.
  4. Stripe-Dashboard (Live-Modus): Zahlarten aktivieren; Webhook-Endpoint https://api.mivita.care/stripe/webhook mit der Event-Liste aus §6 anlegen; dessen Signing-Secret als STRIPE_WEBHOOK_SECRET in die Prod-.env. Stolpersteine (beide gelöst): falsches Webhook-Secret → alle Events 400; charge.succeeded war anfangs nicht abonniert → Zahlart-Anzeige blieb auf Fallback.
  5. Caches: php artisan config:clear && php artisan view:clear (bzw. Deploy-üblich config:cache).
  6. Schalter setzen — im Admin unter Einstellungen → „Zahlungen (Stripe / PAYONE)": ☑ Checkout-Wartungsmodus (nur Admins bestellen), ☑ Abo-Zahlungen einfrieren, ☑ Vorkasse ausblenden (wichtig: Vorkasse autorisiert über PAYONE und würde beim Ausfall crashen), Anbieter = Stripe.
  7. Smoke-Test als Admin bei aktivem Wartungsmodus: echte Bestellungen (u. a. 57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!).
  8. Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe.
  9. Kontrolle: storage/logs/stripe.log, stripe_webhook_events, Payment-Dashboard.
  10. Nach S4/S5 (JETZT offen, → §16): Deployment + 3 neue Migrationen, dann abo-payments-frozen aufheben und Vorkasse wieder einblenden.

7.2 PAYONE wieder freigeschaltet (2026-07-09): Auslaufmodell & Vorkasse

Lage: Das PAYONE-Konto ist wieder aktiv. Entscheidungen Stakeholder (2026-07-09):

# Entscheidung Umsetzung
1 Bestands-Abos bleiben auf PAYONE — Kunden müssen nicht aktiv werden, die alte Verknüpfung läuft weiter. Keine aktive Migrationskampagne (S8 damit vorerst obsolet). Bereits durch S1 abgedeckt (providerFor($abo) liest die Spalte). Nach S4-Abschluss: abo-payments-frozen → aus, der Cron bucht PAYONE-Abos wieder regulär ab.
2 Neue Abos → Stripe. Abo-Erstzahlung im Checkout läuft über Stripe (Schalter steht auf stripe). Phase S4 (dieser Sprint).
3 Zahlungsmittel-Änderung → Stripe. Wo ein Abo-Zahlungsmittel neu hinterlegt wird — Recovery-Zahlung (S4) und Kreditkarte-ändern-Flow (S5) — wechselt das Abo dabei von PAYONE auf Stripe. So läuft PAYONE Stück für Stück im Hintergrund aus. Recovery: S4. CC-Update: S5 (der Flow wird für PAYONE-Abos zum Stripe-SetupIntent-Flow → sanfte Migration; zieht den S8-Mechanismus faktisch vor).
4 Vorkasse (PAYONE) wieder anbieten. Der Checkout zeigt neben „Online-Zahlung" (Stripe) wieder Vorkasse — klar als „Vorkasse (PAYONE)" benannt. Ops: Setting payment-vorkasse-hidden im Admin abhaken. Code: Checkout-Label → payment.prepayment_payone („Vorkasse (PAYONE)").

Freeze-Aufhebung (nach S4-Deployment): abo-payments-frozen → aus. Danach: Bestands-PAYONE-Abos buchen wieder über PAYONE ab (Konto ist wieder frei), neue Abos entstehen mit payment_provider='stripe', eingefrorene Abbuchungen werden über das unveränderte next_date automatisch nachgeholt.


8. Phase S4 – Abos über Stripe · Status: ERLEDIGT (2026-07-09, Testmodus-E2E komplett; offen nur Prod-Rollout/Freeze-Aufhebung → §16)

Ziel

Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen unverändert über PAYONE (Auslaufmodell, §7.2). Recovery-Zahlungen laufen über Stripe und ziehen das Abo dabei auf Stripe um.

Aufgaben

# Aufgabe
S4.1 Abo-Erstzahlung: Checkout Session (mode=payment) mit setup_future_usage=off_session + customer_creation=always; nach succeeded PaymentMethod-/Customer-ID aus dem PaymentIntent ermitteln. N1 damit gelöst: Stripe filtert bei gesetztem setup_future_usage die Hosted-Checkout-Zahlarten automatisch auf wiederverwendbare Methoden (Dynamic Payment Methods) — keine eigene Whitelist nötig, Klarna/Amazon Pay erscheinen nur, wenn Stripe sie off-session-fähig anbietet. Gilt auch für Recovery-Checkouts (recovery_user_abo_id): Zahlung on-session, Zahlungsmittel wird gespeichert, Abo wechselt auf Stripe (§7.2 Nr. 3).
S4.2 AboHelper::createNewAbo(): bei Stripe-Erstzahlung payment_provider='stripe', stripe_customer_id, stripe_payment_method_id (statt payone_userid/carddata) übernehmen; maskierte Anzeige-Daten (Brand, letzte 4, Ablauf) als Anzeige-Metadaten speichern.
S4.3 StripePaymentService::chargeAbo(): off-session PaymentIntent (customer, payment_method, off_session=true, confirm=true), Idempotency-Key = user_abo_order-Referenz (Schutz gegen Doppel-Abbuchung bei Cron-Retry).
S4.4 UserMakeOrder::makePayment(): Provider-Dispatch über PaymentProviderManager::providerFor($abo) — PAYONE-Zweig byte-identisch.
S4.5 Fehler-Mapping: card_declined, authentication_required, insufficient_funds → bestehende Abo-Fehlerstatus (Status 3 / Retry) wie bei PAYONE-Declines; Ergebnis-DTO vereinheitlicht (AboChargeResult).
S4.6 Abo-Ansichten (CRM /user/abos, Portal, Admin AboController): Provider + maskiertes Zahlungsmittel anzeigen; PAYONE-Felder bleiben für PAYONE-Abos maßgeblich.
S4.7 Abo-Retry (AboRetryPaymentService) provider-fähig (nutzt chargeAbo des Abo-Providers).

Vorab-Designentscheidungen (2026-07-09)

  • Transport der Stripe-IDs: Neue Spalten stripe_customer_id, stripe_payment_method_id und user_abo_id auf shopping_payments. Der Webhook (markPaid) schreibt Customer/ PaymentMethod an die Zahlung, bevor der Paid-Pfad läuft; createNewAbo()/ advanceAboAfterRecoveryPayment() übernehmen sie von dort ans Abo.
  • Race Browser-Return vs. Webhook: Der Checkout-Erfolgs-Return ruft createNewAbo() ggf. auf, bevor der Webhook die Stripe-IDs geliefert hat. Für Stripe-Zahlungen legt createNewAbo() das Abo daher nur an, wenn die IDs bereits an der Zahlung stehen — sonst übernimmt der Webhook-Paid-Pfad die Anlage (einzige Wahrheit, wie bei S3).
  • Race Cron-Transaktion vs. Webhook: Der Abo-Cron bucht innerhalb einer DB-Transaktion ab — der Webhook kann eintreffen, bevor die shopping_payments-Zeile committet ist. Zwei Sicherungen: (1) shopping_payments.user_abo_id markiert Folgeabbuchungen, createNewAbo() überspringt sie (kein Duplikat-Abo, falls der Webhook vor dem user_abo_orders-Insert des Crons verarbeitet wird); (2) trägt ein Event unsere metadata.shopping_payment_id, ist die Zahlung aber (noch) nicht auffindbar, antwortet der Webhook non-2xx → Stripe stellt erneut zu, statt das Event als verarbeitet zu begraben. Fremd-Events ohne unsere Metadata werden weiterhin gespeichert + 200.
  • Recovery auch im Webhook-Pfad: Payment::paymentStatusPaidAction() ruft für Recovery-Bestellungen jetzt advanceAboAfterRecoveryPayment() statt createNewAbo() auf (vorher nur im Browser-Return — latenter Duplikat-Abo-Bug, wenn der PAYONE-TSOK vor dem Redirect lief; provider-neutral gefixt).
  • Idempotency-Key der Cron-Abbuchung: abo-{userAboId}-order-{shoppingOrderId} — schützt gegen Doppel-Abbuchung bei Netzwerk-Retry innerhalb desselben Laufs.

Umsetzungsstatus (2026-07-09)

# Status
S4.1 ✅ initiateCheckoutPayment(): Abo-/Recovery-Bestellungen → customer_creation=always + payment_intent_data.setup_future_usage=off_session; Einmalzahlungen unverändert. Zahlarten-Filterung an Stripe delegiert (Dynamic Payment Methods, N1 gelöst). CheckoutController::stripeCheckoutActive() ohne Abo-/Recovery-Ausschluss; Abo-Hinweistext (payment.stripe_abo_hint) unter der Online-Zahlung.
S4.2 ✅ createNewAbo(): Stripe-Zweig setzt payment_provider='stripe', stripe_customer_id, stripe_payment_method_id, payone_userid=0; ohne IDs (Browser-Return vor Webhook) wird die Anlage dem Webhook überlassen. Neue Migration 2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments + captureOffSessionPaymentMethod() im Webhook (PI-Event direkt, Event-Store-Backfill, API-Retrieve als letzter Ausweg).
S4.3 ✅ StripePaymentService::chargeAbo(): off-session PaymentIntent (customer, payment_method, off_session, confirm), Idempotency-Key abo-{aboId}-order-{orderId}, Transaktion als authorization protokolliert, Ergebnis als AboChargeResult.
S4.4 ✅ UserMakeOrder::makePayment(): Dispatch via providerFor($abo) — Stripe → StripeGateway (neu, Adapter wie PayoneGateway), PAYONE-Zweig byte-identisch; Response-Format (APPROVED/ERROR/PENDING) unverändert für Cron/Retry.
S4.5 ✅ Fehler-Mapping: CardException/ApiErrorException → ERROR (Cron setzt Status 3 + Pay-now-Mail wie bei PAYONE); Kunden-Meldungen für card_declined, insufficient_funds, expired_card, incorrect_cvc, authentication_required (bestehende payment.payment_error_reasons.*-Keys, neu payment.stripe_abo_*); PI-Status processing → PENDING (next_date bleibt stehen, Webhook schließt ab).
S4.6 ✅ UserAbo::getPaymentType() Stripe-aware (nutzt ShoppingPayment::stripeLabelFromSummary(), neu public static); UserAbo::formatMaskedCard() versteht die Stripe-Summary („Visa •••• 4242 (04/2031)"); Portal-Abo-Kopf zeigt bei Stripe die Abo-Nummer statt #0. Offen (kosmetisch → S7.3): Provider-Badge in Admin-Listen, payone_userid-Spalte im Admin-Datatable.
S4.7 ✅ implizit: AboRetryPaymentService delegiert an UserMakeOrder::makePayment() → Dispatch greift.

Zusätzliche Absicherungen (Umsetzung der Vorab-Designentscheidungen):

  • Payment::paymentStatusPaidAction() behandelt Recovery-Bestellungen jetzt provider-neutral (advanceAboAfterRecoveryPayment statt createNewAbo) — fixt den latenten Duplikat-Abo-Bug auch für PAYONE.
  • createNewAbo() überspringt Recovery- und Cron-Folgezahlungen (recovery_user_abo_id / shopping_payments.user_abo_id) grundsätzlich.
  • Webhook: Event mit unserer metadata.shopping_payment_id, dessen Zahlung (noch) nicht sichtbar ist → 503 payment_not_yet_visible (Stripe-Retry); Fremd-Events weiter 200.
  • advanceAboAfterRecoveryPayment(): Stripe-Recovery zieht das Abo auf Stripe um (Provider + IDs), PAYONE-Felder bleiben als Historie stehen; ohne IDs wird die Fortschreibung dem Webhook überlassen (Race Browser-Return).

Tests (2026-07-09)

tests/Feature/StripeAboTest.php (neu): 15 Tests, 70 Assertions — Session-Parameter (off-session nur für Abo/Recovery), Abo-Anlage aus dem Webhook (Stripe-Felder, payone_userid=0), Browser-Return-Race (Anlage wartet auf Webhook), Cron-Race (kein Duplikat-Abo; 503 → Retry → processed), Recovery-Umzug PAYONE→Stripe, chargeAbo (Erfolg inkl. Idempotency-Key, Decline-Mapping, fehlendes Zahlungsmittel ohne API-Call), Cron-Dispatch (APPROVED/ERROR), Anzeige-Labels. PaymentProviderManagerTest angepasst (Stripe-Gateway existiert jetzt; Cron-Guard-Test → Dispatch-Test).

Regression: Stripe-Suite gesamt 59 passed (196 Assertions); PAYONE- + CC-Update-Suite 50 passed (172 Assertions); Abo-Retry/Recovery 20 passed — der eine Fehler in AboPendingInitialPaymentTest („Session store not set", View-Render mit Form::open) ist vorbestehend auf HEAD (per git stash verifiziert), unabhängig von S4. Blade-Kompilat (view:cache) grün, ./vendor/bin/pint --dirty grün.

Abgleich mit dem ursprünglichen Testplan: Erstzahlung ✅ · Cron-Abbuchung Erfolg/Decline/Idempotenz ✅ · Recovery ✅ · Gemischte Bestände ▶ implizit über Dispatch-Tests + PAYONE-Regression, expliziter Mischlauf gehört in den Testmodus-E2E.

Akzeptanzkriterien

  • Testmodus Ende-zu-Ende ✅ komplett 2026-07-09 (alles an Abo #442 verprobt):
    1. Browser-Abo-Abschluss → Abo mit Provider stripe, Customer-/PM-ID, Status 2, Order paid via Webhook.
    2. Cron-Abbuchung --force → off-session PI erfolgreich, Order bezahlt, next_date fortgeschrieben, kein Duplikat-Abo.
    3. Abbuchung über per S5-Flow gewechselte Zahlart → erfolgreich (3DS-Karte …3155: Setup mit SCA, Folgeabbuchungen als MIT genehmigt — erwartetes Stripe-Verhalten).
    4. Decline (Karte …0341) → Order failed, Abo Status 3 (+ Pay-now-Mail).
    5. Recovery-Checkout → Recovery-Order bezahlt (recovery_user_abo_id gesetzt), Abo zurück auf Status 2.
    6. authentication_required (Karte …3184, verlangt 3DS bei jeder Transaktion) → off-session Decline, Abo Status 3 — der S6.1-Fall mündet korrekt im Recovery-Werkzeug. (Rechnung systemseitig: auf .test bewusst deaktiviert, Invoice-Gate; Mischlauf PAYONE+Stripe verifiziert sich nach Freeze-Aufhebung in Produktion.)
  • PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging).
  • Nach Freeze-Aufhebung: PAYONE-Bestandsabos buchen wieder ab, neue Abos entstehen mit payment_provider='stripe'.

Befund aus dem Cron-E2E (2026-07-09): Race trat real auf → stripe:process-pending-events (S6.4 vorgezogen)

Beim ersten echten Cron-Lauf (user:make_abo_order --abo=442 --force) trat das in den Designentscheidungen antizipierte Cron-Race sofort ein: Der Webhook (payment_intent.succeeded) kam ~5 s nach Zahlungsanlage — die Cron-DB-Transaktion war noch offen → korrekt 503 payment_not_yet_visible. Aber: Die Stripe CLI (stripe listen) stellt non-2xx NICHT erneut zu (anders als der Dashboard-Endpoint in Produktion) → das Event blieb unverarbeitet, die Bestellung „unbezahlt", obwohl die Abbuchung bei Stripe erfolgreich war.

Fix (Root Cause, deckt auch Prod-Retry-Latenz ab): Neues Wartungs-Command stripe:process-pending-events (--event= für Einzelfälle) — verarbeitet gespeicherte Events ohne processed_at über StripeWebhookHandler::processStoredEvent() nach (Signatur wurde beim Empfang geprüft; weiterhin nicht zuordenbare Events bleiben liegen). Verdrahtung: (1) läuft automatisch am Ende von user:make_abo_order (alle Zahlungen committet), (2) Scheduler alle 10 Minuten (Kernel.php, withoutOverlapping). Damit ist S6.4 im Kern vorgezogen; der geplante stripe:sync-payment-status (aktiver API-Abgleich bei komplett verlorenem Webhook) bleibt für S6 offen. Nebeneffekt beim ersten Lauf: 4 liegengebliebene charge.succeeded-Events vom Go-Live-Tag nachgeholt (Zahlart-Anzeige).

Befund aus dem Browser-E2E (2026-07-09): clearingtype war varchar(3)

Beim Verproben von Abo #442 fiel auf: clearingtype stand auf 'str' — die Spalten (shopping_payments, user_abos) waren varchar(3) (PAYONE-Kürzel), MySQL hat 'stripe' still abgeschnitten (betraf auch alle Live-Einmalzahlungen seit dem S3-Go-Live; SQLite in der Testsuite erzwingt keine Länge, daher unentdeckt). Kein Logik-Problem — alle Lese-Pfade prüfen payment_provider, nie clearingtype==='stripe' (per Grep verifiziert). Fix: Migration 2026_07_09_120000_widen_clearingtype_for_stripe (beide Spalten → varchar(20) + Reparatur der 'str'-Bestandszeilen; down() verkürzt bewusst nicht). Auf der Dev-DB ausgeführt, Abo #442 repariert. Muss beim Prod-Deployment mitlaufen (repariert dort die Live-'str'-Zeilen automatisch).


Ziel

Scope-Erweiterung (Stakeholder 2026-07-09): Nicht mehr nur „Kreditkarte aktualisieren" — der Abo-Inhaber kann seine Zahlungsart frei wechseln: Karte abgelaufen, PayPal-Konto weg → „Zahlungsart ändern" führt auf die Stripe-Hosted-Seite (Session mode=setup), dort freie Wahl unter allen wiederverwendbaren Zahlarten (Karte, PayPal, SEPA, … — Stripe filtert automatisch); die neue Methode wird ohne Abbuchung am Abo verankert und gilt für alle folgenden Abbuchungen. Nur durch den Abo-Inhaber (Policies aus dem CC-Update-Projekt 2026-06-25 unverändert).

Der Flow gilt für alle Abos, auch PAYONE-Bestandsabos (§7.2 Nr. 3): Wer die Zahlungsart ändert, wechselt dabei auf payment_provider='stripe' (PAYONE-Felder bleiben als Historie). Das ist die sanfte Auslauf-Migration; eine aktive Migrationskampagne (S8) ist damit nicht mehr geplant. Der bestehende PAYONE-CC-Update-Pfad bleibt im Code erhalten und greift nur noch, solange der Anbieter-Schalter auf payone steht.

Aufgaben

# Aufgabe
S5.1 StripePaymentService::setupPaymentMethodChange(): Checkout Session mode=setup (hosted, Redirect — konsistent zum Checkout-Flow) mit Customer (vorhandene stripe_customer_id des Abos oder neu via StripeCustomerService/Abo-Daten) und setup_intent_data.metadata.user_abo_id; freie Zahlartenwahl bei Stripe.
S5.2 Webhook checkout.session.completed (mode=setup) / setup_intent.succeeded: Abo via metadata.user_abo_id auflösen → payment_provider='stripe', stripe_customer_id, stripe_payment_method_id, clearingtype='stripe', Anzeige-Summary (carddata) aus der PaymentMethod; alte Stripe-PaymentMethod am Customer detachen (best effort); user_abo_card_changes-Protokoll weiternutzen.
S5.3 Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: card_update_pending_at vor Redirect setzen; Webhook-OK → DB-Update + Flag löschen; Fehlerfall → CRITICAL-Log (Alt-Plan §5.3.1).
S5.4 Einstiege umstellen (Verzweigung: Schalter stripe → Stripe-Setup-Flow, sonst bestehender PAYONE-CC-Pfad): Portal „Zahlungsart ändern", CRM (Berater-eigenes Abo), AboCardUpdateLink-Flow (Berater→Kunde-Link) — Formular-Partial zeigt statt Kartenfeldern einen „Zahlungsart bei Stripe ändern"-Button; gilt für PAYONE- UND Stripe-Abos.

Umsetzungsstatus (2026-07-09)

# Status
S5.1 ✅ StripePaymentService::createPaymentMethodChangeSession() (mode=setup, Metadata user_abo_id/source auf Session + SetupIntent); ensureAboStripeCustomer() legt für PAYONE-Bestandsabos den Stripe-Customer an und persistiert ihn sofort (Provider bleibt PAYONE bis zur Verankerung).
S5.2 ✅ Return-Pfad (finalizeStripeChangeFromSession, prüft Session-Zugehörigkeit via Metadata) UND Webhook setup_intent.succeeded rufen das idempotente applyStripePaymentMethod(): Provider→stripe, Customer-/PM-ID, clearingtype='stripe', Anzeige-Summary (retrievePaymentMethodSummary), user_abo_card_changes-Eintrag; alte Stripe-PM wird best effort detached.
S5.3 ✅ card_update_pending_at vor Session-Erstellung; Fehler → Spur zurück; DB-Write-Fehler nach Setup-Erfolg → CRITICAL-Log, Spur bleibt (Muster CC-Update-Projekt). Freeze-Schalter blockiert beginStripeChange.
S5.4 ✅ Drei Einstiege verzweigen nach Schalter: Portal (portal_abos_payment_method_stripe), CRM me-Abo (user_abos_payment_method_stripe), Kunden-Link (abo_card_update_stripe); gemeinsames Partial _payment_method_form zeigt bei Stripe einen „Neue Zahlungsart wählen"-Button statt Kartenfeldern; Rückkehr über die bestehenden …_return-Routen (?session_id={CHECKOUT_SESSION_ID}). Invariante sauber getrennt: Model bleibt pur (canUpdatePaymentMethod = nur CC; neu canChangePaymentMethodAnyType), die Flow-Wahl liegt in AboPaymentMethodService::canStartChange() (Controller + Views umgestellt). sendCardUpdateLink erlaubt den Link im Stripe-Flow für jede Zahlart. Abo-Detail-Karten (CRM/Portal) zeigen die Zahlart jetzt via getPaymentType() auch für Nicht-CC-/Stripe-Abos.

Neue Lang-Keys: abo.payment_method_change_*, abo.current_payment_method (de/en, du-Form). Der PAYONE-CC-Pfad ist unverändert und greift bei Schalter payone.

Tests (2026-07-09)

tests/Feature/StripePaymentMethodChangeTest.php (neu): 10 Tests — (Fixes nach Manualtests 2026-07-09: (1) Setup-Sessions mit dynamischen Zahlarten verlangen currency — ergänzt, fest eur; (2) Erfolgs-/Fehlerseite des Kunden-Links (card_update_done) und die Link-Mail (AboCardUpdateLinkMail) sprachen noch von „Kreditkarte"/PAYONE — Texte jetzt flow-abhängig: „Zahlungsart aktualisiert" bzw. Stripe-Wortlaut, neue Keys abo.payment_method_change_done_*/…_link_mail_*.) — Flow-Weiche (Nicht-CC-Abos nur im Stripe-Flow, terminale Abos nie), Setup-Session (Params, Pending-Spur), Customer-Anlage für PAYONE-Abos (Provider bleibt payone), Freeze-Guard, Verankern (PAYONE→Stripe-Umzug inkl. Anzeige/card_changes), Idempotenz + Detach der alten PM, Webhook-Verankerung, Return-Pfad inkl. Abweisung fremder Sessions. Regression: Stripe-Gesamtsuite + CC-Update- + Abo-Retry/Recovery-Suiten 99 passed (303 Assertions), Blade-Kompilat grün, Pint grün.

Ursprünglicher Testplan (Referenz): Setup ohne Abbuchung ✅ · Nicht-Inhaber abgewiesen ✅ (Policies unverändert, per bestehender Suite) · Pending-/Inkonsistenz-Pfad ✅ (CRITICAL-Log; Incident-Anbindung folgt mit S6.2).

Akzeptanzkriterien

  • Browser-E2E (Testmodus), CRM-Pfad: ✅ 2026-07-09 — Abo #442, neue Karte über die Setup-Seite hinterlegt: neue PM-ID verankert, carddata „Visa •••• 3155", card_update_pending_at geräumt, genau ein user_abo_card_changes-Eintrag (source crm — Return-Pfad und Webhook liefen beide, Idempotenz griff), keine Abbuchung.
  • Browser-E2E, Berater-Kunden-Link inkl. Nicht-Karten-Zahlart: ✅ 2026-07-09 — Abo #440 über den Link auf PayPal umgestellt: Provider stripe, PM-ID verankert, carddata {type: paypal}, Link entwertet (used_at), Wechsel-Protokoll source link, Status 2. Dabei gefixt: Erfolgsseite + Link-Mail sprachen noch von „Kreditkarte" → Texte jetzt flow-abhängig („Zahlungsart aktualisiert"). Offen: Portal-Pfad (gleicher Code-Pfad wie CRM, geringe Priorität).
  • PAYONE-Bestandsabo wechselt vollständig auf Stripe (Felder + Provider), PAYONE-Felder bleiben als Historie: ✅ Abo #440 war ein PAYONE-CC-Abo (Wechsel-Historie vorhanden) und läuft jetzt über Stripe/PayPal.

10. Phase S6 – Fehlerfälle, 3DS-Recovery & Incidents

Ziel

Fehlgeschlagene Stripe-Abbuchungen münden in die bestehenden Recovery-Werkzeuge; das Payment-Dashboard kennt Stripe.

Aufgaben

# Aufgabe
S6.1 authentication_required bei off-session: Abo auf Status 3 + Recovery-Checkout-Link (bestehender AboRecoveryCheckoutService); Recovery-Zahlung läuft on-session mit 3DS über den Abo-Provider; nach Erfolg advanceAboAfterRecoveryPayment wie gehabt.
S6.2 payment_intent.payment_failed / charge.dispute.created / charge.refunded → PaymentIncident (Provider stripe) + Payment-Dashboard-Kacheln.
S6.3 payments:check-accounts bzw. Uptime-Check um Stripe-Status ergänzen (API-Erreichbarkeit, offene processing-Zahlungen älter X Tage).
S6.4 Wartungs-Command `stripe:sync-payment-status {paymentIntentId

Tests

  • Failed-Webhook setzt Abo-/Order-Status korrekt, erzeugt Incident, keine Doppel-Mails.
  • Recovery-Checkout mit Stripe-Abo Ende-zu-Ende (gemockt).

11. Phase S7 – Admin & Betrieb

Aufgaben

# Aufgabe
S7.1 Schalter-UI im Admin (Einstellungen): aktueller Provider, Umschalten mit Bestätigungsdialog + Hinweistext („wirkt nur auf neue Zahlungen"), SysLog-Eintrag. Nur berechtigte Admin-Rolle.
S7.2 Refunds: OrderWithdrawalController (Widerruf) um automatische Erstattung erweitern — Provider der Ursprungszahlung entscheidet (Stripe: Refund-API; PAYONE: bestehender/manueller Weg). Teil-Erstattungen unterstützen.
S7.3 Admin-Detailansichten (ShoppingOrder, UserAbo, Zahlungen): Provider-Badge, Stripe-IDs verlinkt zum Stripe-Dashboard (Deep-Link https://dashboard.stripe.com/...).
S7.4 Payment-Dashboard (/admin/payment-dashboard) für Stripe anpassen/optimieren (Stakeholder 2026-07-09): Kacheln/Listen kennen bisher nur PAYONE — Stripe-Zahlungen korrekt ausweisen (Provider-Filter, Zahlart-Labels, Verlinkung ins Stripe-Dashboard), Umsatz/Fehlerraten je Provider, Übersicht „Abos je Provider" (Auslauf-Fortschritt ablesbar).
S7.5 Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen.
S7.6 Zahlarten-Kommunikation (Stakeholder 2026-07-09): Die über Stripe verfügbaren Zahlarten klar benennen, möglichst mit Logos — (a) im Checkout unter der „Online-Zahlung"-Option, (b) auf den Shop-Seiten, (c) im Footer bei den Zahlungsmöglichkeiten, (d) auf der Seite /zahlungsarten. Vorkasse als „Vorkasse (PAYONE)" ausweisen. ERLEDIGT ✅ (2026-07-09), siehe Umsetzungsstatus unten.

Umsetzungsstatus S7.6 (ERLEDIGT ✅, 2026-07-09)

Kommuniziertes Zahlarten-Set (per API gegen die aktive Stripe PaymentMethodConfiguration und die Account-Capabilities verifiziert, Erweiterung auf Stakeholder-Wunsch 2026-07-09): Kreditkarte (Visa, Mastercard, American Express), PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay, Link (Stripe), EPS, Bancontact sowie als Sammelgruppe „Weitere Zahlarten": BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay, PAYCO — plus Vorkasse (PAYONE). Sofortüberweisung (von Stripe eingestellt) und SEPA-Lastschrift (nicht aktiviert) wurden von der /zahlungsarten-Seite und aus dem Footer entfernt. ⚠️ Verifiziert gegen den Test-Modus — Test-/Live-Modus haben getrennte Zahlarten-Konfigurationen; das Live-Dashboard sollte dasselbe Set anbieten.

  • Logo-Assets: einheitliche Marken-SVGs (38×24-Kartenstil, Shopify-payment_icons-Set) unter public/images/payments-assets/: visa/mastercard/amex/paypal/applepay/googlepay/ klarna/amazonpay/eps/bancontact/blik/mbway/pix/satispay/samsungpay/kakaopay/naverpay/ payco.svg sowie selbst erstellte Badges vorkasse.svg („€ →") und link.svg (grünes Stripe-Link-Badge; kein Icon im Set verfügbar). Die alten PNGs bleiben liegen (Checkout-Badges stripe.png/vor.png/fnc.png werden weiter genutzt).
  • Wiederverwendbares Partial: resources/views/web/layouts/includes/payment_logos.blade.php (Parameter logoHeight, showVorkasse, showAll, wrapperClass; title/alt je Marke) — genutzt von Checkout, beiden Footern und /zahlungsarten. Standard (Checkout/Footer): nur die Kern-Zahlarten + „···"-Icon (more.svg, Tooltip „Weitere Zahlarten"); die regionalen Zahlarten (BLIK/MB WAY/Pix/…) erscheinen nur mit showAll = true auf /zahlungsarten (Stakeholder-Feedback 2026-07-09).
  • (a) Checkout (web/templates/checkout.blade.php): Logo-Leiste (ohne Vorkasse) unter der Option „Online-Zahlung", darunter weiterhin die benannte Liste (payment.stripe_online_methods, um Amazon Pay ergänzt). Vorkasse war bereits als „Vorkasse (PAYONE)" ausgewiesen (S4).
  • (b) Shop-Seiten / (c) Footer: beide Footer (web/layouts/includes/footer.blade.php, web/user/layouts/includes/footer.blade.php) zeigen statt der 5 veralteten PNGs (PayPal/Sofort/SEPA/CC/Vorkasse) die neue Logo-Leiste inkl. Vorkasse — damit auf allen Shop-Seiten sichtbar.
  • (d) /zahlungsarten (web/templates/zahlungsarten.blade.php): komplett neu — Intro (Stripe als Zahlungsdienstleister) + Logo-Leiste, dann je Zahlart eine Zeile mit Logo, Name und Kurzbeschreibung: Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay, Link (Stripe), EPS, Bancontact, Sammelzeile „Weitere Zahlarten" (BLIK/MB WAY/Pix/Satispay/Samsung Pay/Kakao Pay/Naver Pay/PAYCO), Vorkasse (PAYONE). Neue Lang-Keys weborder.payment_method_* in de (du-Form) und en; alte Sie-Form-Texte ersetzt, payment_method_direct*/_sepa* entfernt (waren nur dort verwendet).
  • Tests: tests/Feature/PaymentMethodsCommunicationTest.php (27 Tests: Partial mit/ohne Vorkasse, alle SVG-Assets vorhanden, alle de/en-Lang-Keys, „Vorkasse (PAYONE)"-Label).
  • Hinweis: Die Seite ist statisch — sollte der Provider-Schalter im Notfall zurück auf payone gestellt werden, stimmt die Kommunikation vorübergehend nicht (bewusst in Kauf genommen; der Checkout selbst bleibt maßgeblich).

Tests

  • Schalter-UI: Berechtigung, Persistenz, SysLog.
  • Refund: Stripe-Refund gegen gemockten Client; charge.refunded-Webhook schließt den Kreis; PAYONE-Zahlung nimmt PAYONE-/manuellen Pfad.

12. Phase S8 – Rollout & optionale Abo-Migration

Rollout-Plan (empfohlen)

Schritt Inhalt
1 Staging/Testmodus: alle Flows mit Stripe-Testkarten + stripe trigger; Schalter-Wechselproben.
2 Produktion, Schalter bleibt payone: Deployment ist verhaltensneutral (Dark Launch).
3 Pilot: Schalter auf stripe in verkehrsarmer Zeit; Einmalzahlungen beobachten (Dashboard, stripe.log, Incidents). Rollback = Schalter zurück.
4 Abos auf Stripe freigeben (neue Abo-Abschlüsse).
5 Beobachtungsphase; PAYONE bleibt für Bestands-Abos und als Fallback aktiv.

Rollback-Garantie: Der Schalter ist jederzeit rückstellbar; da jeder Vorgang seinen Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über Webhooks normal aus.

Optionale Abo-Migration PAYONE → Stripe

Entscheid 2026-07-09 (§7.2): Auslaufmodell statt Kampagne. PAYONE ist wieder aktiv, Bestandsabos laufen dort unbefristet weiter. Die „Migration" passiert passiv über die Zahlungsmittel-Änderung (S4 Recovery / S5 CC-Update) — eine aktive Einladungs-Kampagne (abo:invite-stripe-migration) ist nicht mehr geplant und bleibt hier nur als Option dokumentiert, falls PAYONE doch abgeschaltet werden soll.

  • PAYONE-Tokens sind nicht portabel → Migration heißt: Kunde hinterlegt Zahlungsmittel neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach payment_provider='stripe' am Abo, PAYONE-Felder bleiben als Historie.
  • Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE.

12.1 Phase S9 (nachgelagert) – Rückbuchungen & Stornierungen erfassen

Stakeholder-Entscheid 2026-07-09: Als eigener Punkt aufgenommen, bewusst nachgelagert (nach S4–S7). Zukünftig müssen Rückbuchungen und Stornierungen systemseitig miterfasst werden:

# Aufgabe
S9.1 Rückbuchungen (Chargebacks/Disputes): charge.dispute.created/charge.dispute.closed verarbeiten — Zahlung/Bestellung kennzeichnen, PaymentIncident erzeugen (Überschneidung S6.2), Betrag inkl. Dispute-Gebühr ausweisen. PAYONE-Pendant (Rückläufer/cancelation) mitdenken.
S9.2 Erstattungen: charge.refunded (Voll/Teil) an der Zahlung verbuchen; Verzahnung mit dem Widerrufs-/Refund-Flow aus S7.2.
S9.3 Stornierungen: Stripe-seitige Cancels (payment_intent.canceled, abgebrochene/stornierte Vorgänge) sauber am Vorgang dokumentieren.
S9.4 Buchhaltung: Klären, wie Rückbuchungen/Storni in Rechnungskorrektur/DATEV-Export einfließen (mit Stakeholder/Steuerberater).
S9.5 Auswertung: Kacheln/Listen im Payment-Dashboard (Überschneidung S7.4).

13. Teststrategie (übergreifend)

Ebene Inhalt
Unit Zahlarten-Mapping, Betrag→Cent-Rundung, Provider-Resolver, DTO-Mapping, Fehlercode-Mapping
Feature (Pest, SQLite) Checkout-Verzweigung, Webhook-Handler (Signatur/Idempotenz/Paid-Pfad), Cron-Dispatch, Recovery, Refund — Stripe-Client immer gemockt
Regression Gesamte PAYONE-Suite muss in jeder Phase unverändert grün sein (Schalter payone = Nullmessung)
Manuell (Testmodus) 3DS-Testkarten, SEPA-processing, stripe trigger-Events, Schalterwechsel unter Last

Hinweise: route() ist in Pest nicht domain-aware (bestehende Projektkonvention beachten); kein echter Netzwerkverkehr in der Suite; ./vendor/bin/pint nach jeder Phase.

14. Risiken & Mitigation

Risiko Mitigation
Regression im PAYONE-Bestand PAYONE-Code nur per Adapter kapseln, nie umbauen; Schalter-Default payone; volle Regressionssuite pro Phase
Doppel-Abbuchung durch Cron-Retry Stripe Idempotency-Keys pro user_abo_order; Tests dafür in S4
Webhook-Verlust → Order hängt in „offen" stripe:sync-payment-status-Command (S6.4); Stripe-Retry nutzt non-2xx
„Bezahlt" fälschlich aus Browser-Return Paid-Flag ausschließlich im Webhook-Pfad (wie PAYONE-TSOK)
SEPA-Zahlungen tagelang processing Eigener Anzeige-Status; Versand-/Abo-Logik erst bei succeeded (Klärung mit Stakeholder, ob Versand auf processing warten muss)
Schalter wird versehentlich umgelegt Bestätigungsdialog, Admin-Rolle, SysLog, Dashboard-Anzeige des aktiven Providers
Stripe-Konto-Einschränkungen (Radar, Verifizierung) Früh Live-Verifizierung anstoßen (S0/O7); Pilotphase klein halten
Scope-Kriechen Richtung Stripe Billing Architektur-Entscheidung (Konzept §2) ist verbindlich dokumentiert

15. Betroffene Dateien (Vorschau)

Neu

config/stripe.php
app/Enums/PaymentProvider.php
app/Services/Payment/PaymentProviderManager.php
app/Services/Payment/Contracts/PaymentGatewayInterface.php (+ DTOs)
app/Services/Payment/PayoneGateway.php            ← Adapter, kein Umbau
app/Services/Payment/Stripe/StripeClientWrapper.php
app/Services/Payment/Stripe/StripeCustomerService.php
app/Services/Payment/Stripe/StripePaymentService.php
app/Services/Payment/Stripe/StripeWebhookHandler.php
app/Services/Payment/Stripe/StripeTransactionRecorder.php
app/Services/Payment/Stripe/DTOs/*.php
app/Http/Controllers/Api/StripeWebhookController.php
app/Console/Commands/StripeSyncPaymentStatus.php
database/migrations/*_add_payment_provider_columns_*.php (+ Stripe-Felder)
app/Http/Controllers/Web/StripeCheckoutReturnController.php  (success/cancel)
dev/2026-07-08-stripe/*  (dieser Ordner: Plan, Analyse, Konzept, Runbook)

Anpassung (provider-bewusst, PAYONE-Pfad unverändert)

app/Http/Controllers/Web/CheckoutController.php
app/Cron/UserMakeOrder.php
app/Services/AboHelper.php
app/Services/AboRetryPaymentService.php
app/Services/AboRecoveryCheckoutService.php
app/Services/AboPaymentMethodService.php
app/Services/OrderPaymentService.php
app/Http/Controllers/Admin/OrderWithdrawalController.php
app/Http/Controllers/Admin/PaymentDashboardController.php
routes/api.php, routes/domains/*.php
Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)

16. Nächste Schritte (Stand 2026-07-09)

  1. Testmodus-E2E für S4/S5 ✅ komplett 2026-07-09: Abo-Abschluss, Cron-Abbuchung (inkl. stripe:process-pending-events), Zahlungsart-Wechsel (CRM + Kunden-Link, PayPal, PAYONE→Stripe-Umzug), Decline → Status 3, Recovery-Checkout, authentication_required. Aufräumen: Test-Abos #440/#442 (Abo 442 steht nach dem letzten Test auf Status 3 mit der …3184-Testkarte) und Test-Bestellungen bereinigen.
  2. Deployment + Migrationen (3 neue: Provider-/Stripe-Abo-Spalten, widen_clearingtype), dann im Admin: abo-payments-frozen aufheben (§7.2), Vorkasse wieder einblenden (payment-vorkasse-hidden aus). Ersten 04:00-Cron-Lauf beobachten (holt eingefrorene PAYONE-Abbuchungen nach → großer Lauf!): abo_order.log, stripe.log, stripe_webhook_events, Payment-Dashboard. Der Mischlauf PAYONE+Stripe verifiziert sich dabei. Dazu im Live-Stripe-Dashboard das Zahlarten-Set mit dem Testmodus abgleichen (S7.6 kommuniziert u. a. Link, EPS, Bancontact — Test/Live sind getrennt konfiguriert!).
  3. Phase S5 ✅ umgesetzt + E2E (2026-07-09, Scope erweitert auf „Zahlungsart ändern"): CRM-Pfad und Berater-Kunden-Link im Browser verprobt (inkl. PayPal und PAYONE→Stripe-Umzug, Abo 440). Nur der Portal-Pfad ist ohne Sichttest (identischer Code-Pfad, geringe Prio).
  4. S7.6 Zahlarten-Kommunikation ✅ umgesetzt (2026-07-09, siehe Umsetzungsstatus in §11): Logos + Benennung in Checkout, Footer (alle Shop-Seiten) und /zahlungsarten. Offen: kurzer Sichttest im Browser (Footer, /zahlungsarten, Checkout).
  5. S7.4 Payment-Dashboard für Stripe anpassen/optimieren (Stakeholder 2026-07-09).
  6. Nachgelagert: S9 Rückbuchungen & Stornierungen (§12.1).