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>
69 KiB
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-frozenaufheben, §16) · Erstellt: 2026-07-08 · Zuletzt aktualisiert: 2026-07-09Lage-Update 2026-07-09 (Stakeholder): Das PAYONE-Konto ist wieder freigeschaltet. Daraus ergeben sich folgende verbindliche Entscheidungen (Details §7.2):
- 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.
- Vorkasse (PAYONE) wieder anbieten: Der Checkout zeigt neben der Stripe-Online- Zahlung wieder „Vorkasse (PAYONE)" (Setting
payment-vorkasse-hidden→ aus).- 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).- Rückbuchungen & Stornierungen erfassen — als eigener, nachgelagerter Punkt aufgenommen (→ §12.1 / Phase S9). Test-Server: Schalter
payment-active-providersteht 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
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
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 ausconfig/payone.phpvor): 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 liestenv()tolerant, derStripeClientWrapper(Phase S2) schlägt beim ersten Zugriff hart fehl, wennstripe.secretfehlt. Begründung als Kommentar inconfig/stripe.phpdokumentiert. - Enum liegt unter
app/Enums/(neu, viamake: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 --dirtygrün.
Akzeptanzkriterien
- SDK installiert,
composer.lockaktualisiert, 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
.enveingetragen (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()undchargeAbo()(einziger realer Aufrufer heute).initiateCheckoutPayment()(S3),setupPaymentMethodUpdate()(S5) undrefund()(S7) werden erst mit ihrem Aufrufer ergänzt — kein toter Vertrag, kein Zwang zu Dummy-Implementierungen im Adapter. Im Interface-Docblock dokumentiert. payment_providerbleibt String-Spalte ohne Enum-Cast am Model — derPaymentProviderManagernormalisiert viaPaymentProvider::tryFrom()mit PAYONE-Fallback. So können keine bestehenden Blade-/Array-Zugriffe brechen.- Schalter-Ablage: bestehender
settings-Mechanismus statt eigener Tabelle (gleiches Muster wieabo-lock-days-change,abo-card-update-link-ttl-hours). PaymentProviderManager::gateway(Stripe)wirft bewusstRuntimeException, bis das Stripe-Gateway existiert (S2/S4) — Fail-fast statt stillem Fallback auf PAYONE.- Models:
payment_provider/stripe_*/external_reference/providerin die$fillablevonUserAbo,ShoppingPayment,PaymentTransactionaufgenommen.
Tests & Verifikation (2026-07-08)
tests/Feature/PaymentProviderManagerTest.php(neu): 12 Tests, 25 Assertions — Schalter-Default/stripe/ungültiger Wert, Backfill-Defaultpayone, Spalte schlägt Schalter, Gateway-Auflösung (PayoneGateway / Stripe-Exception),AboChargeResult-Mapping (APPROVED/ERROR), Gateway-Delegation (gemockterPayonePaymentService), 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 --dirtygrün (nur Format-Fixes inShoppingPayment).
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_idunique, Payload redaktiert als Audit,processed_at): Migration2026_07_08_150000_create_stripe_webhook_events_table.php+ ModelStripeWebhookEvent. Race-sicher: Exists-Check + Unique-Constraint-Catch (paralleler Stripe-Retry →duplicate). - HTTP-Statuscodes statt PAYONE-„immer TSOK": ungültige Signatur →
400(kein Retry sinnvoll); fehlendesSTRIPE_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) oderexternal_reference(PaymentIntent-/Session-ID); ohne Treffer wird das Event trotzdem gespeichert und bestätigt (Business-Verdrahtung folgt in S3/S4 andispatchEvent()). - 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 (providerstripe,client_secretredaktiert), Auflösung viaexternal_referenceprocessed_at, rekursive Redaction, Wrapper-Hard-Fail ohne Secret, Lazy-Client, CustomerService (vorhandene ID ohne API-Call; Create persistiert ID — Stripe-Client als Partial-Mock aufrequest, 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:listverifiziert:POST api.mivita.test/stripe/webhook → api.stripe_webhook. - Regression:
PaymentProviderManagerTest+StripeFoundationTest+PayoneWebhookHandlerTest+PayoneTransactionRecorderTest: 35 passed, 131 Assertions.pint --dirtygrü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 anapi.mivita.test/stripe/webhook(echte Signaturprüfung mit demwhsecaus.env) →200 processed, Event-Zeile mitprocessed_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 listenverifiziert (2026-07-08): Testmodus- PaymentIntent (API,pm_card_visa) → Listener → Endpoint.payment_intent.succeededsignaturgeprüft verarbeitet (processed_atgesetzt); 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.testfehlte in der/etc/hostsdes Hosts, 3.whsecmuss 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
whsecpasst 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-verifyDas dabei ausgegebene
whsec_…in die mivita-.env, dannphp artisan config:clear. Testen:stripe trigger payment_intent.succeeded --api-key sk_test_…(mivita). (Stand 2026-07-08: das zuerst eingetragenewhsecstammte vom pressekonto-Listener und muss durch das mivita-Listener-Secret ersetzt werden.)
Produktion (später, vor Rollout-Schritt 2):
- Dashboard → Developers → Webhooks → Add endpoint.
- Endpoint-URL:
https://api.mivita.care/stripe/webhook. - 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. - Das Endpoint-Signing-Secret (
whsec_…, ein anderes als das CLI-Secret!) alsSTRIPE_WEBHOOK_SECRETin 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.expiredrä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
- Deployment des aktuellen Stands (Branch
payment/phase-r-payone-refactoring). php artisan migrate(damals 3 neue Migrationen: Provider-Spalten,stripe_webhook_events).- Prod-
.env:STRIPE_KEY/STRIPE_SECRET= Live-Keys (pk_live_…/sk_live_…),STRIPE_MODE=live. - Stripe-Dashboard (Live-Modus): Zahlarten aktivieren; Webhook-Endpoint
https://api.mivita.care/stripe/webhookmit der Event-Liste aus §6 anlegen; dessen Signing-Secret alsSTRIPE_WEBHOOK_SECRETin die Prod-.env. Stolpersteine (beide gelöst): falsches Webhook-Secret → alle Events 400;charge.succeededwar anfangs nicht abonniert → Zahlart-Anzeige blieb auf Fallback. - Caches:
php artisan config:clear && php artisan view:clear(bzw. Deploy-üblichconfig:cache). - 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.
- Smoke-Test als Admin bei aktivem Wartungsmodus: echte Bestellungen (u. a. 57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!).
- Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe.
- Kontrolle:
storage/logs/stripe.log,stripe_webhook_events, Payment-Dashboard. - Nach S4/S5 (JETZT offen, → §16): Deployment + 3 neue Migrationen, dann
abo-payments-frozenaufheben 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_idunduser_abo_idaufshopping_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 legtcreateNewAbo()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_idmarkiert Folgeabbuchungen,createNewAbo()überspringt sie (kein Duplikat-Abo, falls der Webhook vor demuser_abo_orders-Insert des Crons verarbeitet wird); (2) trägt ein Event unseremetadata.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 jetztadvanceAboAfterRecoveryPayment()stattcreateNewAbo()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 (advanceAboAfterRecoveryPaymentstattcreateNewAbo) — 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 weiter200. 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):
- Browser-Abo-Abschluss → Abo mit Provider
stripe, Customer-/PM-ID, Status 2, Order paid via Webhook. - Cron-Abbuchung
--force→ off-session PI erfolgreich, Order bezahlt,next_datefortgeschrieben, kein Duplikat-Abo. - Abbuchung über per S5-Flow gewechselte Zahlart → erfolgreich (3DS-Karte
…3155: Setup mit SCA, Folgeabbuchungen als MIT genehmigt — erwartetes Stripe-Verhalten). - Decline (Karte
…0341) → Order failed, Abo Status 3 (+ Pay-now-Mail). - Recovery-Checkout → Recovery-Order bezahlt (
recovery_user_abo_idgesetzt), Abo zurück auf Status 2. - 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.)
- Browser-Abo-Abschluss → Abo mit Provider
- 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).
9. Phase S5 – Zahlungsart ändern über Stripe · Status: ERLEDIGT (2026-07-09, E2E über CRM + Kunden-Link; nur Portal-Pfad ohne Sichttest)
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_atgeräumt, genau einuser_abo_card_changes-Eintrag (sourcecrm— 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 sourcelink, 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) unterpublic/images/payments-assets/:visa/mastercard/amex/paypal/applepay/googlepay/ klarna/amazonpay/eps/bancontact/blik/mbway/pix/satispay/samsungpay/kakaopay/naverpay/ payco.svgsowie selbst erstellte Badgesvorkasse.svg(„€ →") undlink.svg(grünes Stripe-Link-Badge; kein Icon im Set verfügbar). Die alten PNGs bleiben liegen (Checkout-Badgesstripe.png/vor.png/fnc.pngwerden weiter genutzt). - Wiederverwendbares Partial:
resources/views/web/layouts/includes/payment_logos.blade.php(ParameterlogoHeight,showVorkasse,showAll,wrapperClass;title/altje 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 mitshowAll = trueauf/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-Keysweborder.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
payonegestellt 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)
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.- Deployment + Migrationen (3 neue: Provider-/Stripe-Abo-Spalten,
widen_clearingtype), dann im Admin:abo-payments-frozenaufheben (§7.2), Vorkasse wieder einblenden (payment-vorkasse-hiddenaus). 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!). 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).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).- S7.4 Payment-Dashboard für Stripe anpassen/optimieren (Stakeholder 2026-07-09).
- Nachgelagert: S9 Rückbuchungen & Stornierungen (§12.1).