# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option) > **Status:** In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ inkl. Browser-E2E — **Notfall-Go-Live wegen PAYONE-Ausfall, Abos eingefroren, s. §7.1**; als Nächstes S4) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-08 > **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 - [x] SDK installiert, `composer.lock` aktualisiert, Test-Suite weiterhin grün. - [x] Keys nur via `.env`; kein Secret im Repo. (Hard-Fail bewusst in S2 verschoben, s. Abweichung.) - [x] 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 - [x] Schalter existiert, Default `payone`, Systemverhalten identisch zu vorher. - [x] 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.`); `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 - [x] `PaymentTransaction`-Schreibzugriffe für Stripe existieren **nur** im Recorder. - [x] Webhook idempotent, signaturgeprüft, protokolliert (per Test nachgewiesen). - [x] 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`). - [x] **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: ```bash 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: > > ```bash > 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: **UMGESETZT** (2026-07-08, Browser-E2E offen) ### 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 (Manualtest, offen):** Checkout → Redirect zu Stripe → Zahlung (inkl. 3DS-Testkarte) → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System. - [ ] 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 (Manualtest). - [x] 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 1. [ ] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`). 2. [ ] `php artisan migrate` (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`. 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 Bestellung (Live-Karte) → Stripe-Redirect → bezahlt → Rechnung; ggf. im Stripe-Dashboard erstatten. Gegenprobe im Inkognito-Fenster: Besucher sehen die Wartungsmeldung. 8. [ ] Checkout öffnen: **Wartungsmodus abhaken** — Einmalzahlungen laufen für alle über Stripe. 9. [ ] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard. 10. [ ] Nach S4: `abo-payments-frozen` zurücksetzen. --- ## 8. Phase S4 – Abos über Stripe ### 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. ### Aufgaben | # | Aufgabe | |---|---------| | S4.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer`; Zahlarten auf **wiederverwendbare** Methoden eingeschränkt (Konzept §3.2; Klarna/Amazon Pay nur nach positiver N1-Prüfung); nach `succeeded` PaymentMethod-ID aus dem PaymentIntent ermitteln. | | 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). | ### Tests - Erstzahlung → `UserAbo` mit Stripe-Feldern; PAYONE-Felder leer. - Cron-Abbuchung Stripe-Abo: Erfolg (Order paid, `user_abo_orders` fortgeschrieben), Decline (Status 3), Idempotenz (doppelter Cron-Lauf bucht nicht doppelt ab). - Gemischte Bestände: ein PAYONE-Abo + ein Stripe-Abo im selben Cron-Lauf → jeweils richtiger Gateway-Aufruf. ### Akzeptanzkriterien - [ ] Testmodus Ende-zu-Ende: Abo-Checkout → n Tage später Cron-Abbuchung off-session erfolgreich, Rechnung systemseitig. - [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging). --- ## 9. Phase S5 – Zahlungsmittel-Update über Stripe ### Ziel Der Flow aus dem CC-Update-Projekt (2026-06-25) funktioniert für Stripe-Abos: Zahlungsmittel erneuern **ohne Abbuchung**, nur durch den Abo-Inhaber. ### Aufgaben | # | Aufgabe | |---|---------| | S5.1 | `StripePaymentService::setupPaymentMethodUpdate()`: Checkout Session `mode=setup` (hosted, Redirect – konsistent zum Checkout-Flow); Einstieg über die bestehenden Card-Update-Views/Links (Verzweigung nach Abo-Provider). | | S5.2 | `setup_intent.succeeded`-Webhook bzw. Return-Bestätigung: neue `stripe_payment_method_id` am Abo speichern, alte PaymentMethod am Customer detachen; `user_abo_card_changes`-Protokoll weiternutzen. | | S5.3 | Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: `card_update_pending_at` vor externem Call setzen; API-OK → DB-Update; Fehlerfall → CRITICAL-Log + Incident (Alt-Plan §5.3.1). | | S5.4 | `AboPaymentMethodService` provider-fähig; `AboCardUpdateLink`-Flow (Berater→Kunde-Link) für Stripe-Abos freischalten. | ### Tests - SetupIntent-Erfolg aktualisiert nur das Ziel-Abo; keine Abbuchung entsteht. - Nicht-Inhaber wird abgewiesen (Policy unverändert). - Pending-Flag/Inkonsistenz-Pfad (API ok, DB-Fehler) erzeugt Incident. --- ## 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|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. | ### 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: Umsatz/Fehlerraten je Provider; Übersicht „Abos je Provider" (Fortschritt einer späteren Migration ablesbar). | | S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. | ### 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 (nur nach Entscheid O8) - 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. - Kampagnen-Command `abo:invite-stripe-migration` (Batch-Mails mit Link, Erinnerung, Fortschritts-Report) — Muster: Reminder-Commands aus dem Einmalprodukte-Projekt. - Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE. --- ## 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 1. ~~O-Fragen klären~~ ✅ **beantwortet 2026-07-08** (Konzept §10): Hosted Checkout mit Redirect, alle Stripe-Zahlarten inkl. PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna, nur EUR, SDK freigegeben, Account vorhanden, Bestands-Abo-Migration und Membership/ELV bewusst nach hinten gestellt (S8 bzw. Folgeprojekt). 2. Phase **S0** starten: Test-Keys + Webhook im (vorhandenen) Stripe-Account einrichten, `stripe/stripe-php` installieren, `config/stripe.php`, **N1-Prüfung** (Klarna/Amazon Pay Abo-Fähigkeit). 3. Danach S1 → S2 → S3; nach S3 erster produktiver Meilenstein (Einmalzahlung) möglich.