mivita/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md
Kevin 8e09aaefe8 Stripe-Integration (S7.4): Payment-Dashboard für Stripe
- Provider-Status/Uptime: Stripe-Kachel aktiviert; payment:check-uptime prüft
  zusätzlich api.stripe.com/v1/ (401 ohne Key = erreichbar) inkl. Auto-Incident
- Zahlungen: Anbieter-Filter, Provider-Badges, Umsatz je Provider,
  Stripe-Dashboard-Deep-Links (ShoppingPayment/PaymentTransaction::
  stripeDashboardUrl, pi_-Guard, testmodus-bewusst)
- Rohe Transaktionen: Anbieter-Filter/-Spalte, Erfolgsraten je Anbieter;
  Bugfix: Stringable-Vergleich ließ den txaction-Filter immer mit leerem Wert
  greifen — Liste war dauerhaft leer
- Neue Kachel „Abos je Anbieter" (Index + GF-Ansicht) mit Fehlerzählern und
  Stripe-Anteil-Fortschrittsbalken (Auslaufmodell PAYONE → Stripe ablesbar)
- Incident-Kachel weist Stripe/PAYONE getrennt aus; Log-Viewer mit
  Kanal-Umschalter stripe/payone
- Plan-Doku: S7.4-Umsetzungsstatus, Abo-Go-Live 2026-07-10 vermerkt

Tests: PaymentDashboardProviderTest (9 neu), Dashboard-Suite 49 grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:57:50 +00:00

936 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option)
> **Status:** In Arbeit (S0–S5 ✓, S7.4 ✓, S7.6 ✓ — **alles live seit 2026-07-10 inkl. Abos**, Freeze aufgehoben, Vorkasse wieder sichtbar, läuft fehlerfrei; offen: S7.1/S7.2/S7.3/S7.5, S6-Rest, S9 → §16) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-10
>
> **Lage-Update 2026-07-09 (Stakeholder):** Das PAYONE-Konto ist **wieder freigeschaltet**.
> Daraus ergeben sich folgende verbindliche Entscheidungen (Details §7.2):
> 1. **Auslaufmodell statt Migration:** Bestehende PAYONE-Abos laufen unverändert über
> PAYONE weiter (Kunden müssen nicht aktiv werden). **Neue** Abos werden über Stripe
> angelegt; wird das **Zahlungsmittel eines Abos geändert** (Recovery-Zahlung → S4,
> Kreditkarte-ändern-Flow → S5), wechselt das Abo dabei auf Stripe. PAYONE läuft so
> Stück für Stück im Hintergrund aus.
> 2. **Vorkasse (PAYONE) wieder anbieten:** Der Checkout zeigt neben der Stripe-Online-
> Zahlung wieder „Vorkasse (PAYONE)" (Setting `payment-vorkasse-hidden` → aus).
> 3. **Zahlarten-Kommunikation** (→ S7.6): Stripe-Zahlarten klar benennen, möglichst
> mit Logos — im Checkout, auf den Shop-Seiten, im Footer (Zahlungsmöglichkeiten)
> und auf der Seite `/zahlungsarten`. ✅ **umgesetzt 2026-07-09** (§11, Umsetzungsstatus S7.6).
> 4. **Rückbuchungen & Stornierungen erfassen** — als eigener, **nachgelagerter** Punkt
> aufgenommen (→ §12.1 / Phase S9).
> **Test-Server:** Schalter `payment-active-provider` steht auf **stripe** (seit 2026-07-08, für den Manualtest); zurückstellen: `Setting::setContentBySlug('payment-active-provider', 'payone', 'text')`.
> **Begleitdokumente:** `ANALYSE-IST-ZUSTAND.md` (Codebase-Analyse) · `STRIPE-KONZEPT.md` (Architektur & API-Mapping)
> **Vorarbeiten:** `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R ✅), `dev/2026-06-25-kreditkarte-update/`
## Dokumentations-Hinweis (verbindlich)
Dieser Plan wird **phasenweise** abgearbeitet. Für **jede Phase** gilt:
- Der **Umsetzungsstatus** wird nach Abschluss auf `ERLEDIGT` gesetzt und sauber
dokumentiert (Datum, betroffene Dateien, kurze Begründung).
- Abweichungen, Detail-Entscheidungen und Verworfenes werden dokumentiert.
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
- Nach Code-Änderungen läuft `./vendor/bin/pint`.
---
## 1. Ziel
Stripe wird als **primäre Zahlungsabwicklung** eingeführt und ersetzt PAYONE im
Tagesgeschäft. Dabei gilt:
| # | Anforderung |
|---|-------------|
| A | Stripe übernimmt **nur die Zahlungsabwicklung**. Rechnungen, Lieferscheine, Provisionen, DATEV etc. bleiben im System. |
| B | **PAYONE wird nicht entfernt.** Es bleibt vollständig funktionsfähig, sauber vom Stripe-Code separiert. |
| C | **Schalter in den Einstellungen** wählt den aktiven Anbieter für **neue** Zahlungsvorgänge (`payone` ↔ `stripe`). Bestandszahlungen/-abos behalten ihren Anbieter. |
| D | Scope-Reihenfolge: **1. Einmalzahlungen**, **2. Abos** (Erstzahlung + wiederkehrende Abbuchung). |
| E | **Netto/Brutto je Land** wird weiterhin systemseitig berechnet; Stripe erhält nur den Bruttobetrag in Cent. |
**Kern-Architekturentscheidung** (Begründung in `STRIPE-KONZEPT.md` §2):
Stripe wird als reiner **Charge-/Token-Provider** genutzt — **PaymentIntents +
gespeicherte PaymentMethods**, *kein* Stripe Billing / Invoicing / Tax. Der Abo-Zyklus
(Cron `user:make_abo_order`, Betragsberechnung, Rechnungen) bleibt unverändert im System.
## 2. Zielarchitektur (Kurzfassung)
```
Checkout / Abo-Cron / Recovery / Widerruf
│
▼
PaymentProviderManager ← Einstellungs-Schalter (neu) + payment_provider-Spalte (Bestand)
│
├── PayoneGateway → Adapter um bestehenden PayonePaymentService (unverändert)
└── StripeGateway → app/Services/Payment/Stripe/* (neu)
├── StripeClientWrapper (injizierbar, testbar)
├── StripePaymentService (PaymentIntents, SetupIntents, Refunds)
├── StripeWebhookHandler (+ DTOs)
└── StripeTransactionRecorder
```
Neue DB-Felder: `payment_provider` auf `user_abos`/`shopping_payments`/`payment_transactions`,
`stripe_customer_id`/`stripe_payment_method_id` auf `user_abos`, `stripe_customer_id` am
User/Customer, `external_reference` auf `shopping_payments`. Details: Konzept §4.3.
## 3. Phasen-Übersicht
| Phase | Titel | Aufwand (Schätzung) | Priorität | Abhängigkeiten |
|-------|-------|---------------------|-----------|----------------|
| **S0** | Grundlagen: Account, SDK, Config, Entscheidungen | 1–2 PT | Blocker | – (O-Fragen beantwortet 2026-07-08) |
| **S1** | Provider-Abstraktion, Datenmodell & Einstellungs-Schalter | 4–6 PT | P0 | S0 |
| **S2** | Stripe-Basis: Client, Recorder, Webhook-Endpoint | 4–6 PT | P0 | S1 |
| **S3** | Einmalzahlung im Checkout über Stripe | 6–10 PT | **P1** | S2 |
| **S4** | Abos: Erstzahlung + wiederkehrende Abbuchung | 8–12 PT | **P1** | S3 |
| **S5** | Zahlungsmittel-Update über Stripe (SetupIntent) | 3–5 PT | P2 | S4 |
| **S6** | Fehlerfälle, 3DS-Recovery & Incident-Anbindung | 4–6 PT | P1–P2 | S4 |
| **S7** | Admin & Betrieb: Schalter-UI, Refunds, Monitoring | 4–6 PT | P2 | S3 (Refunds), S1 (UI) |
| **S8** | Rollout & optionale Abo-Migration PAYONE → Stripe | 3–5 PT | P3 | S4–S7 |
*PT = Personentage (1 Entwickler). Einmalzahlung produktiv nach S3+S7-Teilen; Abos nach S4+S6.*
---
## 4. Phase S0 – Grundlagen · Status: **WEITGEHEND ERLEDIGT** (2026-07-08)
### Ziel
Technische und organisatorische Voraussetzungen schaffen; keine Feature-Logik.
### Aufgaben
| # | Aufgabe | Status |
|---|---------|--------|
| S0.1 | Offene Fragen O1–O9 klären (Konzept §10); N2/N3 ebenfalls beantwortet. | ✅ 2026-07-08 · **N1 offen** (Klarna/Amazon Pay off_session gegen aktuelle Stripe-Doku prüfen, spätestens zu S4-Beginn) |
| S0.2 | Stripe-Account (vorhanden, Stakeholder verwaltet): Test-Keys beschaffen, Webhook-Endpoint (Test) konfigurieren, gewünschte Zahlarten im Dashboard aktivieren (Karte, PayPal, Apple/Google Pay, Amazon Pay, Klarna, …). | ✅ 2026-07-08 — Test-Keys in `.env`, per Balance-API verifiziert (`livemode: false`). Webhook auf dem Test-Server via **Stripe CLI** (`stripe listen --forward-to … --skip-verify`, CLI-Signing-Secret als `STRIPE_WEBHOOK_SECRET`); Dashboard-Endpoint + eigenes `whsec` erst für Produktion nötig (Anleitung §6). Zahlarten-Aktivierung im Dashboard: vor S3-Manualtest prüfen. |
| S0.3 | Composer: `stripe/stripe-php` installieren (Freigabe O6). | ✅ 2026-07-08 — `stripe/stripe-php` **^20.3** in `composer.json`/`composer.lock` |
| S0.4 | `config/stripe.php` anlegen (`secret`, `key`, `webhook_secret`, `mode`); `.env.example`, `.env`, `phpunit.xml` ergänzt. | ✅ 2026-07-08 — **Abweichung s. u.** |
| S0.5 | `PaymentProvider`-Enum anlegen. | ✅ 2026-07-08 — `app/Enums/PaymentProvider.php` (`Payone`/`Stripe`, string-backed) |
### Abweichungen / Entscheidungen
- **Kein Boot-Hard-Fail in `config/stripe.php`** (Plan sah das Muster aus
`config/payone.php` vor): PAYONE ist produktiv im Einsatz — dort ist ein fehlender Key
ein Fehler. Stripe dagegen wird per **Dark Launch** deployt (Rollout-Schritt 2, §12);
ein Boot-Hard-Fail würde jedes Deployment ohne gesetzte Stripe-Keys sofort brechen.
Stattdessen: Config liest `env()` tolerant, der **`StripeClientWrapper` (Phase S2)
schlägt beim ersten Zugriff hart fehl**, wenn `stripe.secret` fehlt. Begründung als
Kommentar in `config/stripe.php` dokumentiert.
- Enum liegt unter `app/Enums/` (neu, via `make:enum`) — im Plan §15 so vorgesehen.
### Tests & Verifikation (2026-07-08)
- `tests/Feature/StripeFoundationTest.php` *(neu)*: Config-Werte aus Env, kein
Boot-Fail bei fehlenden Secrets, Enum-Werte/`from`/`tryFrom`, SDK-Klasse vorhanden —
**4 Tests, 11 Assertions, grün**.
- PAYONE-Regressionssuite (PayoneTest, PaymentMethodResolverTest,
PayonePaymentServiceTest, PayoneWebhookHandlerTest, PayoneTransactionRecorderTest,
PayoneTransportExceptionHandlingTest): **45 passed, 1 skipped (optionaler
Netzwerktest), 209 Assertions** — unverändert grün.
- `./vendor/bin/pint --dirty` grün.
### Akzeptanzkriterien
- [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.<domain>`); `Api\StripeWebhookController` (Muster R5); `StripeWebhookHandler` + `StripeWebhookResult`-DTO |
| S2.5 | Logging-Kanal `stripe.log`. | ✅ `config/logging.php`, daily, Level `info` (bewusst gesprächiger als `payone` — Rollout-Beobachtung) |
### Abweichungen / Entscheidungen
- **Idempotenz über eigene Tabelle `stripe_webhook_events`** (`event_id` unique, Payload
redaktiert als Audit, `processed_at`): Migration
`2026_07_08_150000_create_stripe_webhook_events_table.php` + Model
`StripeWebhookEvent`. Race-sicher: Exists-Check + Unique-Constraint-Catch
(paralleler Stripe-Retry → `duplicate`).
- **HTTP-Statuscodes statt PAYONE-„immer TSOK":** ungültige Signatur → `400` (kein
Retry sinnvoll); fehlendes `STRIPE_WEBHOOK_SECRET` → `500` (Stripe stellt später
erneut zu); Duplikat/unbehandelter Typ/verarbeitet → `200`. Im DTO dokumentiert.
- **Zahlungs-Zuordnung** im Handler: `metadata.shopping_payment_id` (wird ab S3 gesetzt)
oder `external_reference` (PaymentIntent-/Session-ID); ohne Treffer wird das Event
trotzdem gespeichert und bestätigt (Business-Verdrahtung folgt in S3/S4 an
`dispatchEvent()`).
- Behandelte Event-Typen als `HANDLED_TYPES`-Konstante gemäß Konzept §7 (inkl.
`checkout.session.completed`/`expired`); unbekannte Typen werden protokolliert + 200.
### Tests & Verifikation (2026-07-08)
- `tests/Feature/StripeWebhookHandlerTest.php` *(neu)*: **11 Tests, 35 Assertions** —
ungültige Signatur (400, nichts gespeichert), fehlendes Secret (500), Duplikat
(200, eine Zeile), unbehandelter Typ (gespeichert + 200), Transaktion via Metadata
(provider `stripe`, `client_secret` redaktiert), Auflösung via `external_reference`
+ `processed_at`, rekursive Redaction, Wrapper-Hard-Fail ohne Secret, Lazy-Client,
CustomerService (vorhandene ID ohne API-Call; Create persistiert ID — Stripe-Client
als Partial-Mock auf `request`, kein echter HTTP-Call).
- Handler wird wie beim PAYONE-Pendant **direkt** getestet (HTTP-Routen sind in Pest
nicht domain-aware); Route via `php artisan route:list` verifiziert:
`POST api.mivita.test/stripe/webhook → api.stripe_webhook`.
- Regression: `PaymentProviderManagerTest` + `StripeFoundationTest` +
`PayoneWebhookHandlerTest` + `PayoneTransactionRecorderTest`: **35 passed,
131 Assertions**. `pint --dirty` grün.
### Akzeptanzkriterien
- [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: **ERLEDIGT & LIVE** (2026-07-09)
### Ziel
Steht der Schalter auf `stripe`, laufen **neue Einmalzahlungen** (Shop, Zahlungslinks)
über die **Stripe-Hosted-Checkout-Seite** (Redirect-Flow, Entscheidung 2026-07-08,
Konzept §3.1): Zahlartenwahl und Dateneingabe finden komplett bei Stripe statt.
PAYONE-Checkout bleibt bei Schalter `payone` unverändert.
### Ablauf (Soll)
```
mivita-Checkout (Adresse, Warenkorb, AGB — unverändert)
→ „Jetzt zahlen" → CheckoutController
→ PaymentProviderManager::providerForNewCheckout()
├── payone → bestehender Pfad inkl. Zahlartenwahl (unverändert)
└── stripe → StripeGateway::initiateCheckoutPayment()
├── ShoppingPayment anlegen (provider=stripe, mode, Betrag brutto)
├── Checkout Session erstellen (mode=payment, amount=Cent, EUR,
│ customer, metadata: order/payment/mode, success_url, cancel_url;
│ Zahlarten dynamisch via Stripe-Dashboard)
└── 303 Redirect → checkout.stripe.com
Kunde wählt bei Stripe die Zahlart (Karte/PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna/…),
zahlt (inkl. 3DS) und wird zurückgeleitet:
├── success_url → Bestätigungsseite (Status nur zur UX-Anzeige)
└── cancel_url → zurück in den mivita-Checkout (Warenkorb bleibt erhalten)
Webhook checkout.session.completed / payment_intent.succeeded
→ paymentStatusPaidAction-Pfad (Order bezahlt, Mails, Incentive) — einzige Wahrheit
```
### Aufgaben
| # | Aufgabe |
|---|---------|
| S3.1 | Checkout-Verzweigung nach Provider in `CheckoutController`; bei `stripe` **entfällt die Zahlartenwahl** — nach Checkout-Prüfung direkt zu Stripe (N2, entschieden 2026-07-08). Ausnahme: ist der Vorkasse-Schalter (S3.8) aktiv bzw. FNC freigeschaltet, wird eine reduzierte Wahl „Online bezahlen / Vorkasse (/ Rechnung)" angezeigt. Dabei die im Alt-Plan aufgeschobene Extraktion des Checkout-HTTP-Flows aus `Pay\PayoneController` **nur soweit nötig** vorziehen (keine PAYONE-Verhaltensänderung). |
| S3.2 | `StripePaymentService::initiateCheckoutPayment()`: Checkout Session (`mode=payment`) erstellen. Betrag: bestehende Cart-Brutto-Summe, Rundung auf Cent an **einer** Stelle; Währung fest EUR; `line_items` aus dem Warenkorb (Anzeige auf der Stripe-Seite), Metadata mit Order-/Payment-Referenz; Session-ID in `shopping_payments.external_reference`. |
| S3.3 | Redirect-Handling: 303 auf die Session-URL; `success_url`/`cancel_url` domain-korrekt generieren (Multi-Tenant beachten, `routes/domains/…`). `cancel_url` führt zurück in den Checkout, Warenkorb/ShoppingInstance bleiben nutzbar. |
| S3.4 | Success-/Cancel-Controller: liest Session-/PaymentIntent-Status nur für UX (`paid`/`processing`/abgebrochen), verändert **keine** Bezahlt-Flags. Abgelaufene Sessions (`checkout.session.expired`) räumen den offenen Vorgang auf. |
| S3.5 | Webhook-Paid-Pfad: `checkout.session.completed` + `payment_intent.succeeded` → gemeinsame, provider-neutrale Paid-Verarbeitung (Wiederverwendung `Payment::paymentStatusPaidAction()`-Logik; ggf. Extraktion in provider-neutralen Service). Preis-/Betragsabgleich wie beim PAYONE-Webhook (Guard 2005-Äquivalent). |
| S3.6 | `processing`-Status (SEPA & Co.) im Bestell-/Zahlungsstatus sichtbar machen (Muster „Zahlung in Verarbeitung" aus Alt-Plan §5.2.1). |
| S3.7 | Zahlungslinks (`OrderPaymentService`, `ShoppingInstance`) provider-fähig machen: neuer Link erzeugt bei Schalter `stripe` direkt eine Checkout Session (der Link **ist** dann faktisch die Stripe-Seite mit unserem Vor-/Nachlauf). |
| S3.8 | **Vorkasse-Sichtbarkeits-Schalter** (N3, entschieden 2026-07-08): eigene Einstellung, um Vorkasse (`vor`, läuft über PAYONE) unabhängig vom Provider-Schalter aus-/einzublenden — Vorkasse bleibt technisch erhalten. Interne Rechnung (FNC) bleibt wie heute über `payment_methods.active` steuerbar (aktuell praktisch überall deaktiviert). |
### Tests
- Feature: Checkout-Verzweigung je Schalterstellung; Session-Parameter (Betrag = Cart-Brutto in Cent, EUR, Metadata, success/cancel-URLs, Abo-Einschränkung greift hier nicht) gegen gemockten Client.
- Webhook-Paid: Order wird bezahlt, Mails/Incentive-Pfad läuft, Duplikat-Event ändert nichts, Betrags-Mismatch wird abgewiesen + Incident; `session.expired` räumt auf.
- Cancel-Pfad: Rücksprung lässt Warenkorb intakt, kein Zahlungs-/Order-Nebeneffekt.
- Netto/Brutto: Testfälle mit mind. 2 Ländern/Steuersätzen (Cart-Brutto ↔ Rechnungs-Netto unverändert zu PAYONE-Pfad).
- Regression: kompletter PAYONE-Checkout-Testbestand grün bei Schalter `payone`.
### Umsetzungsstatus & Abweichungen (2026-07-08)
| # | Status |
|---|--------|
| S3.1 | ✅ `CheckoutController`: `stripeCheckoutActive()` (Schalter, **Abos/Recovery bleiben bis S4 auf PAYONE**); View zeigt bei Stripe eine einzige „Online-Zahlung"-Option + VOR/FNC (sofern aktiv); `checkoutFinal` validiert `payment_method=stripe` gegen den Schalter (sonst 404). Keine PAYONE-Extraktion nötig — Verzweigung liegt **vor** dem PAYONE-Pfad. |
| S3.2 | ✅ `StripePaymentService::initiateCheckoutPayment()`. **Abweichung:** ein einzelnes `line_item` mit dem Gesamt-Bruttobetrag statt Warenkorb-Positionen — eliminiert jedes Rundungs-/Summenabweichungsrisiko zwischen Cart und Stripe; Positionsdetails stehen auf der Systemrechnung. Metadata (`shopping_order_id`, `shopping_payment_id`, `reference`, `mode`) auf Session **und** PaymentIntent. |
| S3.3 | ✅ Redirect via `redirect()->away($session->url)`; Session-ID → `external_reference`; Initiations-Transaktion `checkout.session.created` protokolliert (Bestätigungsseite hat dadurch immer eine `pay_trans`). |
| S3.4 | ✅ **Abweichung:** kein neuer Return-Controller — `success_url`/`cancel_url` nutzen die bestehenden `checkout.transaction_status`-Routen (Status-UX, Cart-Destroy, Fehlerseiten inkl. Cancel-Rücksprung wie bei PAYONE-Redirects). `checkout.session.expired` räumt serverseitig auf (Status `expired`, Instanz `link_canceled`). |
| S3.5 | ✅ `StripeWebhookHandler::dispatchEvent()` → gleiche txaction-Taxonomie wie PAYONE (`appointed`/`pending`/`failed`/`paid` + Prioritäten): `session.completed(paid)` & `payment_intent.succeeded` → Paid-Pfad (`Payment::paymentStatusPaidAction`, DB-Lock, Betrags-Guard analog 2006, Mail einmalig); `session.completed(unpaid)` → `appointed` + Bestellbestätigung; Reprocessing: gespeichertes, aber unverarbeitetes Event (Crash → Stripe-Retry) wird erneut verarbeitet statt als Duplikat verworfen. |
| S3.6 | ✅ `payment_intent.processing` → txaction `pending` — vorhandene Statusanzeigen greifen durch die gemeinsame Taxonomie automatisch. **Keine Mail** (Bestellbestätigung ging bei `appointed` raus). |
| S3.7 | ✅ implizit: Zahlungslinks führen in denselben Checkout → Verzweigung greift automatisch. Beim Browser-Manualtest mit verifizieren. |
| S3.8 | ✅ Vorkasse-Schalter: Setting **`payment-vorkasse-hidden`** (bool; fehlend/false = sichtbar) → `PaymentProviderManager::isVorkasseVisible()`, Filter in `CheckoutRepository::getPaymentsMethods()` (wirkt provider-unabhängig, auch im PAYONE-Betrieb). Admin-UI folgt in S7.1. |
**Ergänzung Zahlart-Anzeige (2026-07-08):** `charge.succeeded` wird jetzt verarbeitet —
die `payment_method_details` (Typ, Kartenmarke, letzte 4, Apple/Google-Pay-Wallet, Ablauf)
werden in `shopping_payments.carddata` gespeichert; `ShoppingPayment::getPaymentType()`
zeigt daraus z. B. „Apple Pay (Amex •••• 2003)", „Kreditkarte (Visa •••• 4242)", „PayPal",
„Klarna". Reihenfolge-sicher ohne API-Call: Kommt das Charge-Event vor dem
PaymentIntent-Event, zieht `markPaid()` die Details aus dem Event-Store nach
(`backfillPaymentMethodDetails`). Die PaymentIntent-ID wird dabei als
`external_reference` an der Zahlung gemerkt. Checkout-UI-Feinschliff nach
Stakeholder-Feedback: Radio heißt „Online-Zahlung", Stripe-Logo
(`payments-assets/stripe.png`), darunter Zahlarten-Zeile + Hinweistext.
**Weitere Entscheidung:** `payment_intent.payment_failed` verschickt bewusst **keine** Mail —
auf der Hosted Session kann der Kunde direkt erneut zahlen; jeder Fehlversuch feuert das
Event (Mail pro Versuch wäre Spam). `ShoppingPayment::getPaymentType()` zeigt für
Stripe-Zahlungen „Online-Zahlung (Stripe)"; neue Lang-Keys `payment.stripe_*` (de/en, du-Form).
**Tests (2026-07-08):** `tests/Feature/StripeCheckoutTest.php` *(neu)*: **9 Tests** —
Session-Parameter/Payment/Initiations-Transaktion (gemockter Client), Paid-Pfad + genau
eine Mail, kein Doppel-Mail bei zweitem Success-Event, Betrags-Mismatch verweigert,
`appointed` + Bestellbestätigung (async), `failed` ohne Mail, `expired`, Crash-Retry-
Reprocessing, Vorkasse-Schalter. Gesamt Stripe-Suite **36 Tests, 102 Assertions** grün;
Payment-Regression (PAYONE + Abo-Retry/Recovery, 66 Tests) grün; Blade kompiliert; Pint grün.
*(Hinweis: die volle Suite hat eine vorbestehende `makeUser()`-Redeclare-Kollision,
unabhängig von S3.)*
### Akzeptanzkriterien
- [x] **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".)*
- [x] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy.
- [x] Abbruch bei Stripe führt sauber in den Checkout zurück.
- [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 — ✅ durchgeführt am 2026-07-08 abends
1. [x] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`).
2. [x] `php artisan migrate` (damals 3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`).
3. [x] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`.
4. [x] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint
`https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen;
dessen Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die Prod-`.env`.
*Stolpersteine (beide gelöst): falsches Webhook-Secret → alle Events 400;
`charge.succeeded` war anfangs nicht abonniert → Zahlart-Anzeige blieb auf Fallback.*
5. [x] Caches: `php artisan config:clear && php artisan view:clear` (bzw. Deploy-üblich `config:cache`).
6. [x] 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. [x] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellungen (u. a.
57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!).
8. [x] Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe.
9. [x] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
10. [x] **Nach S4/S5:** Deployment + 3 neue Migrationen, `abo-payments-frozen`
aufgehoben, Vorkasse wieder eingeblendet — ✅ **live seit 2026-07-10, läuft
fehlerfrei** (→ §16).
---
## 7.2 PAYONE wieder freigeschaltet (2026-07-09): Auslaufmodell & Vorkasse
**Lage:** Das PAYONE-Konto ist wieder aktiv. Entscheidungen Stakeholder (2026-07-09):
| # | Entscheidung | Umsetzung |
|---|--------------|-----------|
| 1 | **Bestands-Abos bleiben auf PAYONE** — Kunden müssen nicht aktiv werden, die alte Verknüpfung läuft weiter. Keine aktive Migrationskampagne (S8 damit vorerst obsolet). | Bereits durch S1 abgedeckt (`providerFor($abo)` liest die Spalte). Nach S4-Abschluss: `abo-payments-frozen` → aus, der Cron bucht PAYONE-Abos wieder regulär ab. |
| 2 | **Neue Abos → Stripe.** Abo-Erstzahlung im Checkout läuft über Stripe (Schalter steht auf `stripe`). | Phase S4 (dieser Sprint). |
| 3 | **Zahlungsmittel-Änderung → Stripe.** Wo ein Abo-Zahlungsmittel neu hinterlegt wird — Recovery-Zahlung (S4) und Kreditkarte-ändern-Flow (S5) — wechselt das Abo dabei von PAYONE auf Stripe. So läuft PAYONE Stück für Stück im Hintergrund aus. | Recovery: S4. CC-Update: S5 (der Flow wird für PAYONE-Abos zum Stripe-SetupIntent-Flow → sanfte Migration; zieht den S8-Mechanismus faktisch vor). |
| 4 | **Vorkasse (PAYONE) wieder anbieten.** Der Checkout zeigt neben „Online-Zahlung" (Stripe) wieder Vorkasse — klar als „Vorkasse (PAYONE)" benannt. | Ops: Setting `payment-vorkasse-hidden` im Admin abhaken. Code: Checkout-Label → `payment.prepayment_payone` („Vorkasse (PAYONE)"). |
**Freeze-Aufhebung (nach S4-Deployment):** `abo-payments-frozen` → aus. Danach:
Bestands-PAYONE-Abos buchen wieder über PAYONE ab (Konto ist wieder frei), neue
Abos entstehen mit `payment_provider='stripe'`, eingefrorene Abbuchungen werden über
das unveränderte `next_date` automatisch nachgeholt.
---
## 8. Phase S4 – Abos über Stripe · Status: **ERLEDIGT** (2026-07-09, Testmodus-E2E komplett; offen nur Prod-Rollout/Freeze-Aufhebung → §16)
### Ziel
Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende
Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen
unverändert über PAYONE (Auslaufmodell, §7.2). Recovery-Zahlungen laufen über Stripe
und ziehen das Abo dabei auf Stripe um.
### Aufgaben
| # | Aufgabe |
|---|---------|
| S4.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer_creation=always`; nach `succeeded` PaymentMethod-/Customer-ID aus dem PaymentIntent ermitteln. **N1 damit gelöst:** Stripe filtert bei gesetztem `setup_future_usage` die Hosted-Checkout-Zahlarten automatisch auf wiederverwendbare Methoden (Dynamic Payment Methods) — keine eigene Whitelist nötig, Klarna/Amazon Pay erscheinen nur, wenn Stripe sie off-session-fähig anbietet. Gilt auch für Recovery-Checkouts (`recovery_user_abo_id`): Zahlung on-session, Zahlungsmittel wird gespeichert, Abo wechselt auf Stripe (§7.2 Nr. 3). |
| S4.2 | `AboHelper::createNewAbo()`: bei Stripe-Erstzahlung `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id` (statt `payone_userid`/`carddata`) übernehmen; maskierte Anzeige-Daten (Brand, letzte 4, Ablauf) als Anzeige-Metadaten speichern. |
| S4.3 | `StripePaymentService::chargeAbo()`: off-session PaymentIntent (`customer`, `payment_method`, `off_session=true`, `confirm=true`), Idempotency-Key = `user_abo_order`-Referenz (Schutz gegen Doppel-Abbuchung bei Cron-Retry). |
| S4.4 | `UserMakeOrder::makePayment()`: Provider-Dispatch über `PaymentProviderManager::providerFor($abo)` — PAYONE-Zweig byte-identisch. |
| S4.5 | Fehler-Mapping: `card_declined`, `authentication_required`, `insufficient_funds` → bestehende Abo-Fehlerstatus (Status 3 / Retry) wie bei PAYONE-Declines; Ergebnis-DTO vereinheitlicht (`AboChargeResult`). |
| S4.6 | Abo-Ansichten (CRM `/user/abos`, Portal, Admin `AboController`): Provider + maskiertes Zahlungsmittel anzeigen; PAYONE-Felder bleiben für PAYONE-Abos maßgeblich. |
| S4.7 | Abo-Retry (`AboRetryPaymentService`) provider-fähig (nutzt `chargeAbo` des Abo-Providers). |
### Vorab-Designentscheidungen (2026-07-09)
- **Transport der Stripe-IDs:** Neue Spalten `stripe_customer_id`, `stripe_payment_method_id`
und `user_abo_id` auf `shopping_payments`. Der Webhook (`markPaid`) schreibt Customer/
PaymentMethod an die Zahlung, bevor der Paid-Pfad läuft; `createNewAbo()`/
`advanceAboAfterRecoveryPayment()` übernehmen sie von dort ans Abo.
- **Race Browser-Return vs. Webhook:** Der Checkout-Erfolgs-Return ruft `createNewAbo()`
ggf. auf, bevor der Webhook die Stripe-IDs geliefert hat. Für Stripe-Zahlungen legt
`createNewAbo()` das Abo daher **nur** an, wenn die IDs bereits an der Zahlung stehen —
sonst übernimmt der Webhook-Paid-Pfad die Anlage (einzige Wahrheit, wie bei S3).
- **Race Cron-Transaktion vs. Webhook:** Der Abo-Cron bucht **innerhalb einer
DB-Transaktion** ab — der Webhook kann eintreffen, bevor die `shopping_payments`-Zeile
committet ist. Zwei Sicherungen: (1) `shopping_payments.user_abo_id` markiert
Folgeabbuchungen, `createNewAbo()` überspringt sie (kein Duplikat-Abo, falls der Webhook
vor dem `user_abo_orders`-Insert des Crons verarbeitet wird); (2) trägt ein Event unsere
`metadata.shopping_payment_id`, ist die Zahlung aber (noch) nicht auffindbar, antwortet
der Webhook **non-2xx** → Stripe stellt erneut zu, statt das Event als verarbeitet zu
begraben. Fremd-Events ohne unsere Metadata werden weiterhin gespeichert + 200.
- **Recovery auch im Webhook-Pfad:** `Payment::paymentStatusPaidAction()` ruft für
Recovery-Bestellungen jetzt `advanceAboAfterRecoveryPayment()` statt `createNewAbo()`
auf (vorher nur im Browser-Return — latenter Duplikat-Abo-Bug, wenn der PAYONE-TSOK
vor dem Redirect lief; provider-neutral gefixt).
- **Idempotency-Key** der Cron-Abbuchung: `abo-{userAboId}-order-{shoppingOrderId}` —
schützt gegen Doppel-Abbuchung bei Netzwerk-Retry innerhalb desselben Laufs.
### Umsetzungsstatus (2026-07-09)
| # | Status |
|---|--------|
| S4.1 | ✅ `initiateCheckoutPayment()`: Abo-/Recovery-Bestellungen → `customer_creation=always` + `payment_intent_data.setup_future_usage=off_session`; Einmalzahlungen unverändert. Zahlarten-Filterung an Stripe delegiert (Dynamic Payment Methods, N1 gelöst). `CheckoutController::stripeCheckoutActive()` ohne Abo-/Recovery-Ausschluss; Abo-Hinweistext (`payment.stripe_abo_hint`) unter der Online-Zahlung. |
| S4.2 | ✅ `createNewAbo()`: Stripe-Zweig setzt `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id`, `payone_userid=0`; ohne IDs (Browser-Return vor Webhook) wird die Anlage dem Webhook überlassen. Neue Migration `2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments` + `captureOffSessionPaymentMethod()` im Webhook (PI-Event direkt, Event-Store-Backfill, API-Retrieve als letzter Ausweg). |
| S4.3 | ✅ `StripePaymentService::chargeAbo()`: off-session PaymentIntent (`customer`, `payment_method`, `off_session`, `confirm`), Idempotency-Key `abo-{aboId}-order-{orderId}`, Transaktion als `authorization` protokolliert, Ergebnis als `AboChargeResult`. |
| S4.4 | ✅ `UserMakeOrder::makePayment()`: Dispatch via `providerFor($abo)` — Stripe → `StripeGateway` (neu, Adapter wie PayoneGateway), PAYONE-Zweig byte-identisch; Response-Format (`APPROVED`/`ERROR`/`PENDING`) unverändert für Cron/Retry. |
| S4.5 | ✅ Fehler-Mapping: `CardException`/`ApiErrorException` → ERROR (Cron setzt Status 3 + Pay-now-Mail wie bei PAYONE); Kunden-Meldungen für `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `authentication_required` (bestehende `payment.payment_error_reasons.*`-Keys, neu `payment.stripe_abo_*`); PI-Status `processing` → PENDING (next_date bleibt stehen, Webhook schließt ab). |
| S4.6 | ✅ `UserAbo::getPaymentType()` Stripe-aware (nutzt `ShoppingPayment::stripeLabelFromSummary()`, neu public static); `UserAbo::formatMaskedCard()` versteht die Stripe-Summary („Visa •••• 4242 (04/2031)"); Portal-Abo-Kopf zeigt bei Stripe die Abo-Nummer statt `#0`. *Offen (kosmetisch → S7.3): Provider-Badge in Admin-Listen, `payone_userid`-Spalte im Admin-Datatable.* |
| S4.7 | ✅ implizit: `AboRetryPaymentService` delegiert an `UserMakeOrder::makePayment()` → Dispatch greift. |
**Zusätzliche Absicherungen (Umsetzung der Vorab-Designentscheidungen):**
- `Payment::paymentStatusPaidAction()` behandelt Recovery-Bestellungen jetzt provider-neutral
(`advanceAboAfterRecoveryPayment` statt `createNewAbo`) — fixt den latenten
Duplikat-Abo-Bug auch für PAYONE.
- `createNewAbo()` überspringt Recovery- und Cron-Folgezahlungen (`recovery_user_abo_id` /
`shopping_payments.user_abo_id`) grundsätzlich.
- Webhook: Event mit unserer `metadata.shopping_payment_id`, dessen Zahlung (noch) nicht
sichtbar ist → `503 payment_not_yet_visible` (Stripe-Retry); Fremd-Events weiter `200`.
- `advanceAboAfterRecoveryPayment()`: Stripe-Recovery zieht das Abo auf Stripe um
(Provider + IDs), PAYONE-Felder bleiben als Historie stehen; ohne IDs wird die
Fortschreibung dem Webhook überlassen (Race Browser-Return).
### Tests (2026-07-09)
`tests/Feature/StripeAboTest.php` *(neu)*: **15 Tests, 70 Assertions** — Session-Parameter
(off-session nur für Abo/Recovery), Abo-Anlage aus dem Webhook (Stripe-Felder, `payone_userid=0`),
Browser-Return-Race (Anlage wartet auf Webhook), Cron-Race (kein Duplikat-Abo; 503 → Retry →
processed), Recovery-Umzug PAYONE→Stripe, `chargeAbo` (Erfolg inkl. Idempotency-Key,
Decline-Mapping, fehlendes Zahlungsmittel ohne API-Call), Cron-Dispatch (APPROVED/ERROR),
Anzeige-Labels. `PaymentProviderManagerTest` angepasst (Stripe-Gateway existiert jetzt;
Cron-Guard-Test → Dispatch-Test).
Regression: Stripe-Suite gesamt **59 passed (196 Assertions)**; PAYONE- + CC-Update-Suite
**50 passed (172 Assertions)**; Abo-Retry/Recovery **20 passed** — der eine Fehler in
`AboPendingInitialPaymentTest` („Session store not set", View-Render mit `Form::open`) ist
**vorbestehend auf HEAD** (per `git stash` verifiziert), unabhängig von S4. Blade-Kompilat
(`view:cache`) grün, `./vendor/bin/pint --dirty` grün.
*Abgleich mit dem ursprünglichen Testplan:* Erstzahlung ✅ · Cron-Abbuchung
Erfolg/Decline/Idempotenz ✅ · Recovery ✅ · Gemischte Bestände ▶ implizit über
Dispatch-Tests + PAYONE-Regression, expliziter Mischlauf gehört in den Testmodus-E2E.
### Akzeptanzkriterien
- [x] Testmodus Ende-zu-Ende ✅ **komplett 2026-07-09** (alles an Abo #442 verprobt):
1. Browser-Abo-Abschluss → Abo mit Provider `stripe`, Customer-/PM-ID, Status 2, Order paid via Webhook.
2. Cron-Abbuchung `--force` → off-session PI erfolgreich, Order bezahlt, `next_date` fortgeschrieben, kein Duplikat-Abo.
3. Abbuchung über per S5-Flow gewechselte Zahlart → erfolgreich (3DS-Karte `…3155`: Setup mit SCA, Folgeabbuchungen als MIT genehmigt — erwartetes Stripe-Verhalten).
4. **Decline** (Karte `…0341`) → Order failed, Abo Status 3 (+ Pay-now-Mail).
5. **Recovery-Checkout** → Recovery-Order bezahlt (`recovery_user_abo_id` gesetzt), Abo zurück auf Status 2.
6. **authentication_required** (Karte `…3184`, verlangt 3DS bei jeder Transaktion) → off-session Decline, Abo Status 3 — der S6.1-Fall mündet korrekt im Recovery-Werkzeug.
*(Rechnung systemseitig: auf .test bewusst deaktiviert, Invoice-Gate; Mischlauf PAYONE+Stripe verifiziert sich nach Freeze-Aufhebung in Produktion.)*
- [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging).
- [ ] Nach Freeze-Aufhebung: PAYONE-Bestandsabos buchen wieder ab, neue Abos entstehen mit `payment_provider='stripe'`.
### Befund aus dem Cron-E2E (2026-07-09): Race trat real auf → `stripe:process-pending-events` (S6.4 vorgezogen)
Beim ersten echten Cron-Lauf (`user:make_abo_order --abo=442 --force`) trat das in den
Designentscheidungen antizipierte **Cron-Race** sofort ein: Der Webhook
(`payment_intent.succeeded`) kam ~5 s nach Zahlungsanlage — die Cron-DB-Transaktion war
noch offen → korrekt **503 `payment_not_yet_visible`**. Aber: Die **Stripe CLI
(`stripe listen`) stellt non-2xx NICHT erneut zu** (anders als der Dashboard-Endpoint in
Produktion) → das Event blieb unverarbeitet, die Bestellung „unbezahlt", obwohl die
Abbuchung bei Stripe erfolgreich war.
**Fix (Root Cause, deckt auch Prod-Retry-Latenz ab):** Neues Wartungs-Command
**`stripe:process-pending-events`** (`--event=` für Einzelfälle) — verarbeitet
gespeicherte Events ohne `processed_at` über `StripeWebhookHandler::processStoredEvent()`
nach (Signatur wurde beim Empfang geprüft; weiterhin nicht zuordenbare Events bleiben
liegen). Verdrahtung: (1) läuft automatisch **am Ende von `user:make_abo_order`**
(alle Zahlungen committet), (2) **Scheduler alle 10 Minuten** (`Kernel.php`,
withoutOverlapping). Damit ist S6.4 im Kern vorgezogen; der geplante
`stripe:sync-payment-status` (aktiver API-Abgleich bei komplett verlorenem Webhook)
bleibt für S6 offen. Nebeneffekt beim ersten Lauf: 4 liegengebliebene
`charge.succeeded`-Events vom Go-Live-Tag nachgeholt (Zahlart-Anzeige).
### Befund aus dem Browser-E2E (2026-07-09): `clearingtype` war varchar(3)
Beim Verproben von Abo #442 fiel auf: `clearingtype` stand auf **`'str'`** — die Spalten
(`shopping_payments`, `user_abos`) waren `varchar(3)` (PAYONE-Kürzel), MySQL hat
`'stripe'` **still abgeschnitten** (betraf auch alle Live-Einmalzahlungen seit dem
S3-Go-Live; SQLite in der Testsuite erzwingt keine Länge, daher unentdeckt). Kein
Logik-Problem — alle Lese-Pfade prüfen `payment_provider`, nie `clearingtype==='stripe'`
(per Grep verifiziert). Fix: Migration `2026_07_09_120000_widen_clearingtype_for_stripe`
(beide Spalten → varchar(20) + Reparatur der `'str'`-Bestandszeilen; `down()` verkürzt
bewusst nicht). Auf der Dev-DB ausgeführt, Abo #442 repariert. **Muss beim
Prod-Deployment mitlaufen** (repariert dort die Live-`'str'`-Zeilen automatisch).
---
## 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
- [x] **Browser-E2E (Testmodus), CRM-Pfad:** ✅ 2026-07-09 — Abo #442, neue Karte über die Setup-Seite hinterlegt: neue PM-ID verankert, carddata „Visa •••• 3155", `card_update_pending_at` geräumt, **genau ein** `user_abo_card_changes`-Eintrag (source `crm` — Return-Pfad und Webhook liefen beide, Idempotenz griff), keine Abbuchung.
- [x] **Browser-E2E, Berater-Kunden-Link inkl. Nicht-Karten-Zahlart:** ✅ 2026-07-09 — Abo #440 über den Link auf **PayPal** umgestellt: Provider `stripe`, PM-ID verankert, carddata `{type: paypal}`, Link entwertet (`used_at`), Wechsel-Protokoll source `link`, Status 2. Dabei gefixt: Erfolgsseite + Link-Mail sprachen noch von „Kreditkarte" → Texte jetzt flow-abhängig („Zahlungsart aktualisiert"). *Offen: Portal-Pfad (gleicher Code-Pfad wie CRM, geringe Priorität).*
- [x] **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|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. **Teilweise vorgezogen (2026-07-09, s. §8):** `stripe:process-pending-events` verarbeitet gespeicherte, unverarbeitete Events nach (Cron-Ende + Scheduler alle 10 min). Offen bleibt der aktive API-Abgleich für komplett verlorene Webhooks. |
### 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). **ERLEDIGT ✅ (2026-07-10), siehe Umsetzungsstatus S7.4 unten.** |
| 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.4 (ERLEDIGT ✅, 2026-07-10)
`/admin/payment-dashboard` kennt jetzt beide Provider (Alt-Zeilen ohne Provider-Wert
zählen defensiv als PAYONE):
- **Provider-Status/Uptime:** Stripe in `getProviderStats()`/`getUptimeStats()` aktiviert;
`payment:check-uptime` prüft zusätzlich `https://api.stripe.com/v1/` (401 ohne Key
belegt Erreichbarkeit → kein API-Key nötig) inkl. Auto-Incident/Auto-Resolve wie PAYONE.
Incident-Kachel zeigt Stripe+PAYONE getrennt (`stripe_incidents_30d`).
- **Zahlungen** (`payments`): Anbieter-Filter (Stripe/PAYONE), Provider-Badge statt
rohem clearingtype, Umsatz-je-Provider-Kacheln (bezahlt/gesamt + Summe), Deep-Link
ins Stripe-Dashboard an Zahlung (external_reference `pi_…`, testmodus-bewusst) und
an jeder Stripe-Transaktion (txid). Helper: `ShoppingPayment::stripeDashboardUrl()`,
`PaymentTransaction::stripeDashboardUrl()`.
- **Rohe Transaktionen** (`transactions`): Anbieter-Filter + -Spalte, Karte
„Erfolgsraten je Anbieter". **Bugfix nebenbei:** `request()->string()` liefert ein
`Stringable` (immer truthy, `!== ''`) → der txaction-Filter griff IMMER mit leerem
Wert, die Liste war dauerhaft leer; jetzt `->toString()` wie in `payments()`.
- **Abos je Anbieter** (Index + GF-Ansicht): neue Kachel mit aktiven Abos je Provider
(`scopeActiveAbo`), Fehlerzähler (Status 3) und Fortschrittsbalken „Anteil Stripe" —
der Auslauf PAYONE → Stripe (§7.2) ist damit direkt ablesbar. GF-Ansicht zeigt
zusätzlich Zahlungsquote je Provider im Anbieter-Status.
- **Log-Viewer:** Kanal-Umschalter stripe/payone (Default stripe, Whitelist),
Tab heißt jetzt „Provider-Logs".
- **Tests:** `tests/Feature/PaymentDashboard/PaymentDashboardProviderTest.php` (9 Tests:
Filter, Stats je Provider, Abo-Auslauf, Deep-Links, Log-Kanäle, Uptime via Http::fake);
gesamte Dashboard-Suite (49 Tests) grün.
### Umsetzungsstatus S7.6 (ERLEDIGT ✅, 2026-07-09)
Kommuniziertes Zahlarten-Set (per API gegen die aktive Stripe `PaymentMethodConfiguration`
und die Account-Capabilities verifiziert, Erweiterung auf Stakeholder-Wunsch 2026-07-09):
**Kreditkarte (Visa, Mastercard, American Express), PayPal, Apple Pay, Google Pay,
Klarna, Amazon Pay, Link (Stripe), EPS, Bancontact** sowie als Sammelgruppe „Weitere
Zahlarten": **BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay, PAYCO** —
plus **Vorkasse (PAYONE)**. Sofortüberweisung (von Stripe eingestellt) und
SEPA-Lastschrift (nicht aktiviert) wurden von der `/zahlungsarten`-Seite und aus dem
Footer **entfernt**. ⚠️ Verifiziert gegen den **Test-Modus** — Test-/Live-Modus haben
getrennte Zahlarten-Konfigurationen; das Live-Dashboard sollte dasselbe Set anbieten.
- **Logo-Assets:** einheitliche Marken-SVGs (38×24-Kartenstil, Shopify-`payment_icons`-Set)
unter `public/images/payments-assets/`: `visa/mastercard/amex/paypal/applepay/googlepay/
klarna/amazonpay/eps/bancontact/blik/mbway/pix/satispay/samsungpay/kakaopay/naverpay/
payco.svg` sowie selbst erstellte Badges `vorkasse.svg` („€ →") und `link.svg`
(grünes Stripe-Link-Badge; kein Icon im Set verfügbar). Die alten PNGs bleiben
liegen (Checkout-Badges `stripe.png`/`vor.png`/`fnc.png` werden weiter genutzt).
- **Wiederverwendbares Partial:** `resources/views/web/layouts/includes/payment_logos.blade.php`
(Parameter `logoHeight`, `showVorkasse`, `showAll`, `wrapperClass`; `title`/`alt` je
Marke) — genutzt von Checkout, beiden Footern und `/zahlungsarten`. Standard
(Checkout/Footer): nur die Kern-Zahlarten + „···"-Icon (`more.svg`, Tooltip „Weitere
Zahlarten"); die regionalen Zahlarten (BLIK/MB WAY/Pix/…) erscheinen nur mit
`showAll = true` auf `/zahlungsarten` (Stakeholder-Feedback 2026-07-09).
- **(a) Checkout** (`web/templates/checkout.blade.php`): Logo-Leiste (ohne Vorkasse) unter
der Option „Online-Zahlung", darunter weiterhin die benannte Liste
(`payment.stripe_online_methods`, um Amazon Pay ergänzt). Vorkasse war bereits als
„Vorkasse (PAYONE)" ausgewiesen (S4).
- **(b) Shop-Seiten / (c) Footer:** beide Footer (`web/layouts/includes/footer.blade.php`,
`web/user/layouts/includes/footer.blade.php`) zeigen statt der 5 veralteten PNGs
(PayPal/Sofort/SEPA/CC/Vorkasse) die neue Logo-Leiste inkl. Vorkasse — damit auf allen
Shop-Seiten sichtbar.
- **(d) `/zahlungsarten`** (`web/templates/zahlungsarten.blade.php`): komplett neu —
Intro (Stripe als Zahlungsdienstleister) + Logo-Leiste, dann je Zahlart eine Zeile mit
Logo, Name und Kurzbeschreibung: Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna,
Amazon Pay, Link (Stripe), EPS, Bancontact, Sammelzeile „Weitere Zahlarten"
(BLIK/MB WAY/Pix/Satispay/Samsung Pay/Kakao Pay/Naver Pay/PAYCO), Vorkasse (PAYONE).
Neue Lang-Keys `weborder.payment_method_*` in de (du-Form) und en; alte
Sie-Form-Texte ersetzt, `payment_method_direct*`/`_sepa*` entfernt (waren nur dort
verwendet).
- **Tests:** `tests/Feature/PaymentMethodsCommunicationTest.php` (27 Tests: Partial mit/ohne
Vorkasse, alle SVG-Assets vorhanden, alle de/en-Lang-Keys, „Vorkasse (PAYONE)"-Label).
- **Hinweis:** Die Seite ist statisch — sollte der Provider-Schalter im Notfall zurück auf
`payone` gestellt werden, stimmt die Kommunikation vorübergehend nicht (bewusst in Kauf
genommen; der Checkout selbst bleibt maßgeblich).
### Tests
- Schalter-UI: Berechtigung, Persistenz, SysLog.
- Refund: Stripe-Refund gegen gemockten Client; `charge.refunded`-Webhook schließt den Kreis; PAYONE-Zahlung nimmt PAYONE-/manuellen Pfad.
---
## 12. Phase S8 – Rollout & optionale Abo-Migration
### Rollout-Plan (empfohlen)
| Schritt | Inhalt |
|---|---|
| 1 | Staging/Testmodus: alle Flows mit Stripe-Testkarten + `stripe trigger`; Schalter-Wechselproben. |
| 2 | Produktion, Schalter bleibt `payone`: Deployment ist verhaltensneutral (Dark Launch). |
| 3 | Pilot: Schalter auf `stripe` in verkehrsarmer Zeit; Einmalzahlungen beobachten (Dashboard, `stripe.log`, Incidents). Rollback = Schalter zurück. |
| 4 | Abos auf Stripe freigeben (neue Abo-Abschlüsse). |
| 5 | Beobachtungsphase; PAYONE bleibt für Bestands-Abos und als Fallback aktiv. |
**Rollback-Garantie:** Der Schalter ist jederzeit rückstellbar; da jeder Vorgang seinen
Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über
Webhooks normal aus.
### Optionale Abo-Migration PAYONE → Stripe
> **Entscheid 2026-07-09 (§7.2): Auslaufmodell statt Kampagne.** PAYONE ist wieder
> aktiv, Bestandsabos laufen dort unbefristet weiter. Die „Migration" passiert passiv
> über die Zahlungsmittel-Änderung (S4 Recovery / S5 CC-Update) — eine aktive
> Einladungs-Kampagne (`abo:invite-stripe-migration`) ist **nicht mehr geplant** und
> bleibt hier nur als Option dokumentiert, falls PAYONE doch abgeschaltet werden soll.
- PAYONE-Tokens sind **nicht portabel** → Migration heißt: Kunde hinterlegt Zahlungsmittel
neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach
`payment_provider='stripe'` am Abo, PAYONE-Felder bleiben als Historie.
- Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE.
---
## 12.1 Phase S9 (nachgelagert) – Rückbuchungen & Stornierungen erfassen
**Stakeholder-Entscheid 2026-07-09:** Als eigener Punkt aufgenommen, bewusst
**nachgelagert** (nach S4–S7). Zukünftig müssen Rückbuchungen und Stornierungen
systemseitig miterfasst werden:
| # | Aufgabe |
|---|---------|
| S9.1 | **Rückbuchungen (Chargebacks/Disputes):** `charge.dispute.created`/`charge.dispute.closed` verarbeiten — Zahlung/Bestellung kennzeichnen, `PaymentIncident` erzeugen (Überschneidung S6.2), Betrag inkl. Dispute-Gebühr ausweisen. PAYONE-Pendant (Rückläufer/`cancelation`) mitdenken. |
| S9.2 | **Erstattungen:** `charge.refunded` (Voll/Teil) an der Zahlung verbuchen; Verzahnung mit dem Widerrufs-/Refund-Flow aus S7.2. |
| S9.3 | **Stornierungen:** Stripe-seitige Cancels (`payment_intent.canceled`, abgebrochene/stornierte Vorgänge) sauber am Vorgang dokumentieren. |
| S9.4 | **Buchhaltung:** Klären, wie Rückbuchungen/Storni in Rechnungskorrektur/DATEV-Export einfließen (mit Stakeholder/Steuerberater). |
| S9.5 | Auswertung: Kacheln/Listen im Payment-Dashboard (Überschneidung S7.4). |
---
## 13. Teststrategie (übergreifend)
| Ebene | Inhalt |
|---|---|
| Unit | Zahlarten-Mapping, Betrag→Cent-Rundung, Provider-Resolver, DTO-Mapping, Fehlercode-Mapping |
| Feature (Pest, SQLite) | Checkout-Verzweigung, Webhook-Handler (Signatur/Idempotenz/Paid-Pfad), Cron-Dispatch, Recovery, Refund — Stripe-Client **immer gemockt** |
| Regression | Gesamte PAYONE-Suite muss in jeder Phase unverändert grün sein (Schalter `payone` = Nullmessung) |
| Manuell (Testmodus) | 3DS-Testkarten, SEPA-`processing`, `stripe trigger`-Events, Schalterwechsel unter Last |
Hinweise: `route()` ist in Pest nicht domain-aware (bestehende Projektkonvention
beachten); kein echter Netzwerkverkehr in der Suite; `./vendor/bin/pint` nach jeder Phase.
## 14. Risiken & Mitigation
| Risiko | Mitigation |
|---|---|
| Regression im PAYONE-Bestand | PAYONE-Code nur per Adapter kapseln, nie umbauen; Schalter-Default `payone`; volle Regressionssuite pro Phase |
| Doppel-Abbuchung durch Cron-Retry | Stripe Idempotency-Keys pro `user_abo_order`; Tests dafür in S4 |
| Webhook-Verlust → Order hängt in „offen" | `stripe:sync-payment-status`-Command (S6.4); Stripe-Retry nutzt non-2xx |
| „Bezahlt" fälschlich aus Browser-Return | Paid-Flag ausschließlich im Webhook-Pfad (wie PAYONE-TSOK) |
| SEPA-Zahlungen tagelang `processing` | Eigener Anzeige-Status; Versand-/Abo-Logik erst bei `succeeded` (Klärung mit Stakeholder, ob Versand auf `processing` warten muss) |
| Schalter wird versehentlich umgelegt | Bestätigungsdialog, Admin-Rolle, SysLog, Dashboard-Anzeige des aktiven Providers |
| Stripe-Konto-Einschränkungen (Radar, Verifizierung) | Früh Live-Verifizierung anstoßen (S0/O7); Pilotphase klein halten |
| Scope-Kriechen Richtung Stripe Billing | Architektur-Entscheidung (Konzept §2) ist verbindlich dokumentiert |
## 15. Betroffene Dateien (Vorschau)
### Neu
```
config/stripe.php
app/Enums/PaymentProvider.php
app/Services/Payment/PaymentProviderManager.php
app/Services/Payment/Contracts/PaymentGatewayInterface.php (+ DTOs)
app/Services/Payment/PayoneGateway.php ← Adapter, kein Umbau
app/Services/Payment/Stripe/StripeClientWrapper.php
app/Services/Payment/Stripe/StripeCustomerService.php
app/Services/Payment/Stripe/StripePaymentService.php
app/Services/Payment/Stripe/StripeWebhookHandler.php
app/Services/Payment/Stripe/StripeTransactionRecorder.php
app/Services/Payment/Stripe/DTOs/*.php
app/Http/Controllers/Api/StripeWebhookController.php
app/Console/Commands/StripeSyncPaymentStatus.php
database/migrations/*_add_payment_provider_columns_*.php (+ Stripe-Felder)
app/Http/Controllers/Web/StripeCheckoutReturnController.php (success/cancel)
dev/2026-07-08-stripe/* (dieser Ordner: Plan, Analyse, Konzept, Runbook)
```
### Anpassung (provider-bewusst, PAYONE-Pfad unverändert)
```
app/Http/Controllers/Web/CheckoutController.php
app/Cron/UserMakeOrder.php
app/Services/AboHelper.php
app/Services/AboRetryPaymentService.php
app/Services/AboRecoveryCheckoutService.php
app/Services/AboPaymentMethodService.php
app/Services/OrderPaymentService.php
app/Http/Controllers/Admin/OrderWithdrawalController.php
app/Http/Controllers/Admin/PaymentDashboardController.php
routes/api.php, routes/domains/*.php
Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
```
## 16. Nächste Schritte (Stand 2026-07-09)
1. ~~**Testmodus-E2E für S4/S5**~~ ✅ **komplett 2026-07-09**: Abo-Abschluss, Cron-Abbuchung
(inkl. `stripe:process-pending-events`), Zahlungsart-Wechsel (CRM + Kunden-Link,
PayPal, PAYONE→Stripe-Umzug), Decline → Status 3, Recovery-Checkout,
authentication_required. Aufräumen: Test-Abos #440/#442 (Abo 442 steht nach dem
letzten Test auf Status 3 mit der `…3184`-Testkarte) und Test-Bestellungen bereinigen.
2. ~~**Deployment + Migrationen + Freeze aufheben**~~ ✅ **LIVE seit 2026-07-10**
(Stakeholder): S4/S5/S7.6 deployt, Abo-Freeze aufgehoben, Vorkasse wieder sichtbar —
läuft bisher fehlerfrei. **Weiter beobachten:** die ersten 04:00-Cron-Läufe
(`abo_order.log`, `stripe.log`, `stripe_webhook_events`, Payment-Dashboard) und im
**Live**-Stripe-Dashboard das Zahlarten-Set mit dem Testmodus abgleichen (S7.6
kommuniziert u. a. Link, EPS, Bancontact — Test/Live sind getrennt konfiguriert!).
3. ~~Phase **S5**~~ ✅ umgesetzt + E2E (2026-07-09, Scope erweitert auf „Zahlungsart
ändern"): CRM-Pfad und Berater-Kunden-Link im Browser verprobt (inkl. PayPal und
PAYONE→Stripe-Umzug, Abo 440). Nur der **Portal-Pfad** ist ohne Sichttest
(identischer Code-Pfad, geringe Prio).
4. ~~**S7.6 Zahlarten-Kommunikation**~~ ✅ umgesetzt (2026-07-09, siehe Umsetzungsstatus
in §11): Logos + Benennung in Checkout, Footer (alle Shop-Seiten) und `/zahlungsarten`.
**Offen:** kurzer Sichttest im Browser (Footer, `/zahlungsarten`, Checkout).
5. ~~**S7.4 Payment-Dashboard**~~ ✅ umgesetzt (2026-07-10, siehe Umsetzungsstatus in
§11): Provider-Filter/-Badges, Stripe-Deep-Links, Umsatz-/Erfolgsraten je Provider,
Abos-je-Anbieter-Kachel (Auslauf-Fortschritt), Stripe-Uptime-Check, Log-Viewer für
stripe/payone. **Offen:** kurzer Sichttest im Browser.
6. Verbleibend aus S7: S7.1 (SysLog für Schalter), S7.2 (Refunds), S7.3
(Admin-Detailansichten mit Provider-Badge/Deep-Links), S7.5 (Betriebs-Runbook).
7. Nachgelagert: **S9 Rückbuchungen & Stornierungen** (§12.1).