Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live

Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen
(Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis
Phase S4 eingefroren.

- Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface,
  PaymentProviderManager (Settings-Schalter, provider-treu je Bestand),
  PayoneGateway als unveränderter Adapter
- StripePaymentService: Checkout Session (eine Gesamtposition, Metadata,
  success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch),
  StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction)
- Webhook /stripe/webhook: Signaturprüfung, Idempotenz über
  stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten,
  Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail
- Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet,
  auftragsunabhängiges Backfill aus dem Event-Store)
- Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis,
  Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar
- Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate
- Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse)
- Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail
  und checkout-final (PAYONE-Ausfall)
- Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-08 15:56:41 +00:00
parent 671022a28d
commit 6f21845607
48 changed files with 4428 additions and 831 deletions

View file

@ -0,0 +1,133 @@
# Ist-Analyse: Zahlungsarchitektur vor der Stripe-Einführung
> **Stand:** 2026-07-08 · Grundlage für `ENTWICKLUNGSPLAN-STRIPE.md`
> Vorarbeiten: `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R abgeschlossen),
> `dev/2026-06-25-kreditkarte-update/` (CC-Update-Feature)
## 1. Zahlungsfluss heute (PAYONE)
```
Checkout (Web/CheckoutController) ──┐
Abo-Cron (app/Cron/UserMakeOrder) ──┤
Membership (PaymentHelper) ──┤
▼
Pay\PayoneController (@deprecated Fassade)
│
▼
app/Services/Payment/Payone/PayonePaymentService
├── PayoneRequestBuilder + PaymentMethodResolver
├── PayoneTransactionRecorder (einzige Stelle für PaymentTransaction)
└── App\Services\Payone (HTTP-Client → api.pay1.de)
│
▼
Api\PayoneController → PayoneWebhookHandler (IPN/TSOK)
│
▼
Payment::paymentStatusPaidAction()
(Bestellung bezahlt, Abo anlegen, Mails, Incentive)
```
**Wichtig:** Durch Phase R (2026-06-10) ist die PAYONE-Logik bereits in einer testbaren
Service-Schicht unter `app/Services/Payment/Payone/` gekapselt. Das ist die ideale
Ausgangslage, um Stripe **parallel** daneben zu stellen, statt PAYONE anzufassen.
## 2. Kernerkenntnis: Das System ist seine eigene Billing-Engine
Die wiederkehrenden Abo-Abbuchungen werden **nicht vom Zahlungsanbieter** getriggert,
sondern vom eigenen Cron:
- `user:make_abo_order` (04:00 Uhr) → `app/Cron/UserMakeOrder.php`
- Der Cron berechnet den Betrag pro Ausführung selbst (Produkte, Länderpreise, Steuern)
und ruft dann `PayonePaymentService::chargeAbo()` mit gespeichertem Token
(`payone_userid` + `pseudocardpan`) auf.
- Rechnungen, Lieferscheine, Provisionsdaten entstehen im System (dompdf, DATEV-Export).
**Konsequenz für Stripe:** Wir brauchen von Stripe nur „belaste gespeichertes
Zahlungsmittel mit Betrag X" (PaymentIntents, off-session) – **nicht** Stripe Billing /
Stripe Subscriptions / Stripe Invoicing. Details und Begründung im Konzept
(`STRIPE-KONZEPT.md`, Abschnitt 2).
## 3. Datenmodell (Zahlung & Abo)
| Tabelle / Model | Relevante Felder | Rolle |
|---|---|---|
| `user_abos` / `UserAbo` | `payone_userid`, `clearingtype` (`cc`/`wlt`), `wallettype` (`PPE`), `carddata` (JSON: `pseudocardpan`, `cardexpiredate`), `card_update_pending_at` | Zahlungs-Token für wiederkehrende Abbuchung |
| `shopping_payments` / `ShoppingPayment` | `clearingtype`, `carddata`, `reference`, `is_abo`, `mode` (`test`/`live`) | Jede Zahlungstransaktion |
| `payment_transactions` / `PaymentTransaction` | `txid`, `userid`, `txaction`, `transmitted_data` | Anbieter-Callback-Protokoll (heute PAYONE) |
| `user_abo_orders` / `UserAboOrder` | `paid`, `status` | Ausführungs-Historie pro Abo |
| `payment_methods` / `PaymentMethod` | `short` (`CC`,`PP`,`SEPA`,`SB`,`VOR`,`FNC`), `active`, `is_abo` | Steuert, welche Zahlarten im Checkout angeboten werden |
| `user_abo_card_changes`, `abo_card_update_links` | – | CC-Update-Feature (2026-06-25) |
| `order_withdrawals` / `OrderWithdrawal` | – | Widerruf/Erstattung (neu, Juli 2026) |
**Bekannte Lücke (bereits im Alt-Plan notiert):** Es gibt **kein `payment_provider`-Feld** –
der Anbieter wird implizit über `clearingtype`/`wallettype` abgeleitet. Für Stripe zwingend
zu ergänzen (Stakeholder-Entscheid O1 im Alt-Plan: `payone_userid` bleibt bestehen,
Provider-Felder kommen **zusätzlich**).
## 4. Zahlarten-Auswahl im Checkout
- `CheckoutRepository::getPaymentsMethods()` ([CheckoutRepository.php:237](../../app/Repositories/CheckoutRepository.php))
liest aktive Methoden aus `payment_methods` (Flags `active`, `is_abo`), zusätzlich
User-spezifische Freischaltungen (`$user->payment_methods`).
- `isPaymentsMethodsActive()` mappt PAYONE-Codes → Kürzel:
`wlt#PPE`→PP, `cc`→CC, `sb#PNT`→SB, `elv`→SEPA, `vor`→VOR, `fnc#MIV`→FNC.
- `CheckoutController::processPaymentMethod()` (Z. ~262 ff.) verzweigt je Methode
(CC → creditcardcheck/pseudocardpan, ELV → Bankdaten-Check) und ruft
`setPrePayment()`.
**Konsequenz:** Der Checkout kennt bereits ein Schalt-Modell pro Zahlart. Der
Stripe/PAYONE-Schalter dockt hier an (Provider-Zuordnung je Zahlart bzw. global).
## 5. Netto/Brutto & Steuern je Land
- Preise pro Land in `country_prices` (`c_price`, `c_tax`, `c_currency`) –
Models `Country`, `CountryPrice`, `ShippingCountry`.
- Der Warenkorb (`packages/digital-bird/shoppingcart`) arbeitet mit `taxRate` und
`taxIncluded` pro Position: Bruttopreise inkl. länderspezifischer MwSt.
- Netto-Rückrechnung wo nötig, z. B. `AboOrderCart` (Z. ~351):
`lineNet = lineGross / ((100 + taxRate) / 100)`.
- Rechnungen (dompdf) und DATEV-Export (`DatevExportService`) nutzen diese Werte.
**Konsequenz:** Die Steuerlogik ist **anbieterunabhängig** und bleibt vollständig im
System. Stripe erhält nur den finalen **Bruttobetrag in Cent** + Währung. Stripe Tax wird
**nicht** benötigt (siehe Konzept, Abschnitt 5).
## 6. Wiederverwendbare Bausteine
| Baustein | Datei | Nutzen für Stripe |
|---|---|---|
| Service-Schicht-Muster | `app/Services/Payment/Payone/*` | 1:1-Vorlage für `app/Services/Payment/Stripe/*` |
| Webhook-Muster (Idempotenz, Duplikate, txaction-Priorität) | `PayoneWebhookHandler` | Vorlage für `StripeWebhookHandler` |
| Transaction-Recorder mit PCI-Redaction | `PayoneTransactionRecorder` | Vorlage für `StripeTransactionRecorder` |
| Paid-Pfad (Bestellung → Abo → Mails) | `App\Services\Payment::paymentStatusPaidAction()` | Provider-neutral wiederverwenden |
| Abo-Recovery-Checkout (Status 3 → Nachzahlung) | `AboRecoveryCheckoutService` | Für fehlgeschlagene Stripe-Abbuchungen (inkl. 3DS-Fall) |
| CC-Update-Flow (Inhaber-only, Link, kein Charge) | `AboCardUpdateController`, `AboCardUpdateLink`, `AboPaymentMethodService` | Muster für „Zahlungsmittel bei Stripe hinterlegen/erneuern" via SetupIntent |
| Widerruf/Erstattung | `OrderWithdrawalController`, `OrderWithdrawal` | Andockpunkt für Stripe-Refunds |
| Payment-Dashboard & Incidents | `Admin/PaymentDashboardController`, `PaymentIncident` (kennt bereits mehrere Provider) | Stripe als Provider ergänzen |
| Test-/Live-Trennung | `shopping_payments.mode`, `shopping_orders.mode` | Stripe Test-/Live-Keys analog |
| Secrets-Muster | `config/payone.php` (Hard-Fail bei fehlendem Key) | Gleiches Muster für Stripe-Keys |
## 7. Randbedingungen & Stolperfallen
1. **PAYONE-Tokens sind nicht portabel.** `pseudocardpan`/`payone_userid` können nicht zu
Stripe migriert werden. Bestehende Abos laufen daher auf PAYONE weiter, bis der Kunde
sein Zahlungsmittel neu hinterlegt (Wiederverwendung des Card-Update-Link-Flows).
Eine PCI-konforme Kartendaten-Migration zwischen Prozessoren ist theoretisch über die
Anbieter möglich, aber aufwendig → als Option in Phase S8 dokumentiert.
2. **PayPal-direkt-Projekt ist blockiert** (Merchant-Freigabe fehlt, siehe Alt-Plan).
Stripe unterstützt PayPal als Zahlungsart in der EU – das könnte den blockierten
Strang ersetzen (offene Frage im Plan).
3. **`Pay\PayoneController` ist @deprecated Fassade** – Checkout-HTTP-Flow
(`setPrePayment`/`ResponseData`) steckt noch dort. Die Stripe-Einführung erzwingt an
dieser Stelle die im Alt-Plan aufgeschobene Extraktion (Provider-Verzweigung im
Checkout).
4. **Multi-Tenant-Routing:** Neue Web-Routen (Checkout-Return, Webhook) müssen in die
richtigen `routes/domains/*.php` bzw. `routes/api.php`; Webhooks laufen global unter
`api.<domain>`.
5. **Tests:** Pest auf SQLite in-memory; `route()` ist in Tests nicht domain-aware
(siehe Memory/Projektkonvention) – Stripe-HTTP-Aufrufe im Test immer über
injizierbaren Client mocken (gleiches Muster wie `?Client $client` bei PAYONE).
6. **Sofortüberweisung (`sb#PNT`):** Stripe hat „Sofort" abgekündigt; Ersatz wäre Klarna
oder Wegfall → offene Frage an Stakeholder.
7. **Composer-Abhängigkeit:** `stripe/stripe-php` ist noch nicht installiert; Ergänzung
braucht Freigabe (Projektregel: keine Dependency-Änderungen ohne Zustimmung).

View file

@ -0,0 +1,639 @@
# 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.<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: **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.

View file

@ -0,0 +1,268 @@
# Stripe-Konzept: Architektur & API-Mapping
> **Stand:** 2026-07-08 · Ergänzt `ENTWICKLUNGSPLAN-STRIPE.md` (Phasen) und
> `ANALYSE-IST-ZUSTAND.md` (Codebase). Hier stehen die **Architektur-Entscheidungen**
> und das Mapping der Stripe-API auf unsere Domäne.
## 1. Leitplanken (vom Stakeholder gesetzt)
1. **Stripe ersetzt PAYONE als Zahlungsabwicklung** – Rechnungen, Lieferscheine,
Provisionen etc. werden **weiterhin im System** generiert.
2. **PAYONE wird nicht entfernt.** Es bleibt als Option bestehen und wird sauber
separiert. Ein **Schalter in den Einstellungen** wechselt den aktiven Anbieter.
3. Scope: **Einmalzahlungen zuerst**, dann **Abos**.
4. **Netto/Brutto je Land** bleibt Systemaufgabe (bestehende `country_prices`-Logik).
5. **Zahlartenwahl und Dateneingabe finden bei Stripe statt** (Stakeholder 2026-07-08):
Der mivita-Checkout leitet per Redirect zur **Stripe-Hosted-Checkout-Seite** weiter;
dort wählt der Kunde die Zahlungsmethode, zahlt und springt zurück. Im eigenen
Checkout gibt es bei aktivem Stripe **keine** Zahlarten-Formulare mehr (kein
Kreditkarten-iFrame etc.). Der PAYONE-Checkout bleibt bei Schalter `payone` wie heute.
6. Zahlarten-Scope: **Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna** und
weitere von Stripe bereitgestellte Methoden (Dashboard-gesteuert). Alle Zahlungen in **EUR**.
## 2. Grundsatzentscheidung: PaymentIntents statt Stripe Billing
| Kriterium | Stripe Billing (Subscriptions) | **PaymentIntents + gespeicherte Payment Methods (Empfehlung)** |
|---|---|---|
| Wer bestimmt Betrag & Rhythmus? | Stripe (Preise/Pläne bei Stripe gepflegt) | **Unser Cron** (`user:make_abo_order`) – wie heute bei PAYONE |
| Rechnungen | Stripe erzeugt eigene Invoices | **System erzeugt Rechnungen** (dompdf/DATEV) – Anforderung! |
| Abo-Inhalt ändert sich pro Ausführung (Produkte, Einmalprodukte, Länderpreise) | Schwer abbildbar (Preis-Objekte müssten synchron gehalten werden) | Trivial – Betrag wird pro Abbuchung frisch berechnet |
| Steuerlogik | Stripe Tax nötig oder Duplikation | Bleibt zu 100 % im System |
| Parallelität zu PAYONE | Zwei völlig verschiedene Abo-Modelle | **Gleicher Lebenszyklus wie heute**, nur anderer Charge-Kanal |
**Entscheidung (Empfehlung):** Abos bleiben in unserer Datenhaltung (`user_abos`,
`user_abo_orders`), der Cron berechnet wie bisher den Betrag und ruft je nach Provider
`chargeAbo()` bei PAYONE **oder** Stripe (off-session PaymentIntent) auf. Stripe Billing,
Stripe Invoicing und Stripe Tax werden **nicht** verwendet.
> Damit ist Stripe für uns ein reiner **Charge-/Token-Provider** – exakt die Rolle, die
> PAYONE heute hat. Das minimiert den Umbau und hält beide Anbieter symmetrisch hinter
> einer gemeinsamen Schnittstelle.
## 3. Stripe-Objekte ↔ unsere Domäne
| Unsere Domäne | PAYONE heute | Stripe |
|---|---|---|
| Zahlender Kunde | `payone_userid` | `Customer` (`cus_…`) – pro `User`/`Customer` einmalig anlegen, ID speichern |
| Gespeichertes Zahlungsmittel (Abo) | `pseudocardpan` + `cardexpiredate` in `user_abos.carddata` | `PaymentMethod` (`pm_…`), attached am Customer; ID in `user_abos.stripe_payment_method_id` |
| Einmalzahlung Checkout | `authorization` (preauth/auth) | **Checkout Session** (`cs_…`, `mode=payment`, hosted) → erzeugt intern `PaymentIntent` (`pi_…`) |
| Abo-Erstzahlung im Checkout | `authorization` + `recurrence=recurring` | **Checkout Session** `mode=payment` + `setup_future_usage=off_session` (Zahlungsmittel wird am Customer gespeichert) |
| Wiederkehrende Abbuchung (Cron) | `authorization` + `userid` + `pseudocardpan` | `PaymentIntent` mit `off_session=true`, `confirm=true`, `customer` + `payment_method` (API-Call, keine Hosted Page) |
| Zahlungsmittel aktualisieren ohne Abbuchung | `creditcardcheck` (`storecarddata=yes`) | **Checkout Session** `mode=setup` (hosted) → `SetupIntent` (`seti_…`), kein Charge, SCA-fähig |
| Anbieter-Callback | IPN/TSOK (`txaction`) | Webhook-Events (signiert, `whsec_…`) |
| Erstattung (Widerruf) | manuell / `refund`-Request | `Refund` (`re_…`) auf PaymentIntent/Charge |
| Test/Live | `mode` test/live + Portal | Test-Keys vs. Live-Keys (`sk_test_…`/`sk_live_…`) |
### 3.1 Integrationsform: Stripe Checkout (Hosted Page) — entschieden 2026-07-08
**Ablauf:** mivita-Checkout (Adresse, Warenkorb, AGB) → Button „Jetzt zahlen" →
Server erstellt eine **Checkout Session** → `303 Redirect` auf `checkout.stripe.com` →
Kunde wählt dort die Zahlart und zahlt → Redirect zurück auf unsere `success_url`
(bzw. `cancel_url` bei Abbruch). „Bezahlt" setzt weiterhin **nur der Webhook**
(`checkout.session.completed` / `payment_intent.succeeded`), nie der Browser-Return.
Warum Hosted Checkout (statt eingebettetem Payment Element):
- Entspricht der Stakeholder-Vorgabe: keine Zahlungsdaten-Eingabe mehr im eigenen Checkout.
- **Apple Pay / Google Pay funktionieren out of the box** (Domain-Verifizierung übernimmt
Stripe auf der eigenen Domain; Wallets erscheinen automatisch nur auf fähigen Geräten).
- **Dynamic Payment Methods:** Welche Zahlarten angezeigt werden, wird im
**Stripe-Dashboard** konfiguriert — neue Methoden aktivieren ohne Code-Deploy.
Der Code erzwingt nur Einschränkungen (z. B. Abo-Checkout → nur wiederverwendbare Methoden).
- Stripe optimiert Reihenfolge/Anzeige der Methoden je Gerät, Land, Betrag.
- PCI-Aufwand minimal (SAQ-A), kein Stripe.js-Embedding in unseren Blade-Views nötig.
Konsequenz für den bestehenden Checkout-Schritt „Zahlungsart wählen": Bei Schalter
`stripe` entfällt die Detailauswahl im mivita-Checkout (bzw. reduziert sich auf
systeminterne Methoden wie Vorkasse/Rechnung + „Online bezahlen via Stripe");
bei Schalter `payone` bleibt der heutige Schritt unverändert.
### 3.2 Zahlarten (Scope lt. Stakeholder: alles, was Stripe bereitstellt)
| Zahlart | Stripe-Typ | Einmalzahlung | Abo-fähig (off_session) | Hinweis |
|---|---|---|---|---|
| Kreditkarte | `card` | ✅ | ✅ | Kernfall |
| Apple Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Safari/iOS) |
| Google Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Chrome/Android) |
| PayPal | `paypal` | ✅ | ✅ (Vaulting via Stripe, EU) | Ersetzt das blockierte PayPal-direkt-Projekt (O2 = ja) |
| Klarna | `klarna` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | Ersetzt entfallene Sofortüberweisung (O3) |
| Amazon Pay | `amazon_pay` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | |
| SEPA-Lastschrift | `sepa_debit` | ✅ | ✅ (Mandat via Stripe) | `processing` dauert Tage → eigener Status |
| Weitere (iDEAL, Bancontact, …) | dynamisch | ✅ | teils | Dashboard-gesteuert, kein Code nötig |
| Vorkasse (VOR) | – | systemintern | – | Bleibt außerhalb des Gateways, providerunabhängig |
| Rechnung intern (FNC/MIV) | – | systemintern | – | Bleibt außerhalb des Gateways |
**Regel für Abo-Checkouts:** Die Checkout Session wird auf **wiederverwendbare**
Methoden eingeschränkt (`card` inkl. Wallets, `paypal`, `sepa_debit`; Klarna/Amazon Pay
nur, falls die S0-Prüfung `setup_future_usage=off_session` bestätigt). Einmalzahlungen
bekommen das volle Dashboard-Sortiment.
## 4. Provider-Abstraktion & Schalter
### 4.1 Schichtenmodell
```
UI: Checkout / Portal / CRM / Zahlungslink / Abo-Cron
│
▼
PaymentProviderManager (neu)
├── providerForNewCheckout(): PaymentProvider ← liest den Einstellungs-Schalter
├── providerFor(UserAbo|ShoppingPayment): PaymentProvider ← liest payment_provider-Spalte
└── gateway(PaymentProvider): PaymentGatewayInterface
│
├──────────────────────────────┐
▼ ▼
PayoneGateway (Adapter um StripeGateway (neu)
PayonePaymentService – bestehend, app/Services/Payment/Stripe/*
unverändert)
```
```php
enum PaymentProvider: string
{
case Payone = 'payone';
case Stripe = 'stripe';
}
interface PaymentGatewayInterface
{
public function initiateCheckoutPayment(CheckoutPaymentContext $ctx): PaymentInitiationResult;
public function chargeAbo(UserAbo $abo, ShoppingOrder $order, ShoppingUser $user, int $amountCents, string $currency): AboChargeResult;
public function setupPaymentMethodUpdate(UserAbo $abo): PaymentMethodUpdateSession; // CC-Update / SetupIntent
public function refund(ShoppingPayment $payment, int $amountCents, string $reason): RefundResult;
}
```
**Wichtig:** PAYONE-Code wird dafür **nicht umgebaut** – der `PayoneGateway` ist ein
dünner Adapter um den bestehenden `PayonePaymentService`. Ein Interface-Mismatch wird im
Adapter gelöst, nie im Bestandscode.
### 4.2 Der Schalter (Einstellungen)
- **Neue Setting** `payment.active_provider` (`payone` | `stripe`), gepflegt im
Admin-Bereich (CRM), Anzeige-/Änderungsprotokoll über bestehendes `SysLog`-Muster.
- Der Schalter wirkt **nur auf neue Zahlungsvorgänge** (Checkout-Start, neue Abos,
neue Zahlungslinks).
- **Bestandsdaten sind schaltunabhängig:** Jede `shopping_payments`-Zeile und jedes
`user_abos` trägt seinen `payment_provider` fest. Ein PAYONE-Abo wird auch nach dem
Umschalten weiter über PAYONE abgebucht; Refunds gehen immer an den Provider der
Ursprungszahlung.
- Granularität: Start mit **globalem** Schalter; optional später pro Zahlart
(`payment_methods.provider`-Spalte ist als Erweiterung vorgesehen) → **O4**.
- Empfohlene Ablage: eigene kleine Tabelle/Model `PaymentSetting` oder Erweiterung des
bestehenden Settings-Mechanismus (`Setting`/`SySetting`) → im Plan Phase S1 entscheiden,
Konvention der Codebase folgt.
### 4.3 Datenmodell-Erweiterungen
```sql
-- user_abos
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Enum-Werte s. o.
stripe_customer_id VARCHAR(255) NULL
stripe_payment_method_id VARCHAR(255) NULL
-- payone_userid, clearingtype, carddata bleiben unverändert (Stakeholder-Entscheid O1 Alt-Plan)
-- shopping_payments
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone'
external_reference VARCHAR(255) NULL -- Stripe PaymentIntent-ID (pi_…)
-- payment_transactions
provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Webhook-Protokoll beider Anbieter
-- users / customers (oder eigene Tabelle stripe_customers)
stripe_customer_id VARCHAR(255) NULL
```
Backfill-Migration: alle Bestandszeilen `payment_provider = 'payone'` (Default deckt
das ab). Keine Änderung an bestehenden PAYONE-Spalten.
## 5. Netto/Brutto & Währungen
- Betragsermittlung bleibt vollständig im System: `country_prices.c_tax` → Cart
(`taxRate`, `taxIncluded`) → Bruttobetrag. Rechnung weist Netto/MwSt/Brutto aus wie
heute.
- Stripe erhält ausschließlich: `amount` = **Bruttobetrag in Cent**, `currency`,
`description`/`metadata` (Order-Referenz). Keine Steuerdaten an Stripe, kein
Stripe Tax.
- Metadata-Konvention (für Support/Abgleich): `shopping_order_id`, `shopping_payment_id`,
`user_abo_id` (falls Abo), `mode`.
- Währung: heute faktisch EUR; `country_prices.c_currency` existiert → klären, ob
Nicht-EUR-Länder aktiv sind → **O5**.
## 6. SCA / 3-D Secure
| Situation | Verhalten |
|---|---|
| Checkout (on-session) | Die Stripe-Hosted-Checkout-Seite übernimmt die 3DS-Challenge komplett. Danach Redirect auf unsere `success_url` → Status-Anzeige (UX) → „bezahlt" via Webhook. |
| Abo-Erstzahlung | `setup_future_usage=off_session` in der Checkout Session → Bank weiß, dass Folgezahlungen kommen (MIT-Rahmen). |
| Wiederkehrende Abbuchung (Cron, off-session) | Normalfall: keine SCA nötig (Merchant Initiated). Ausnahme `authentication_required`: Abbuchung schlägt fehl → Abo auf Status 3, Kunde bekommt Link in den **bestehenden Recovery-Checkout** (`AboRecoveryCheckoutService`) und zahlt dort on-session (Hosted Checkout) mit 3DS. |
| Zahlungsmittel-Update | Checkout Session `mode=setup` (hosted) → SCA wird dabei gleich mit erledigt, Folgezahlungen laufen sauber. |
## 7. Webhooks
- Endpoint: `POST /api/stripe/webhook` (in `routes/api.php`, läuft global unter
`api.<domain>` – analog PAYONE-IPN). Ohne CSRF, **mit Signaturprüfung**
(`Stripe-Signature`-Header, `STRIPE_WEBHOOK_SECRET`).
- Handler nach dem Muster `PayoneWebhookHandler` (Idempotenz über `event.id`,
Duplikat-Erkennung, Persistenz **nur** über `StripeTransactionRecorder`).
- Relevante Events (Minimal-Set):
| Event | Reaktion |
|---|---|
| `checkout.session.completed` | Session→Order-Zuordnung festziehen; bei `payment_status=paid` Paid-Pfad, bei `unpaid` (SEPA etc.) auf `payment_intent.succeeded` warten |
| `checkout.session.expired` | Offenen Checkout-Vorgang aufräumen (Session-TTL 24 h) |
| `payment_intent.succeeded` | Paid-Pfad (`paymentStatusPaidAction`-Äquivalent): Order bezahlt, Abo anlegen/fortschreiben |
| `payment_intent.payment_failed` | Fehlerpfad; bei Abo → Retry-/Recovery-Logik, Incident |
| `payment_intent.processing` | SEPA: Zahlung unterwegs → Status „in Verarbeitung" (Muster aus Doppelabo-Schutz 5.2.1 Alt-Plan) |
| `charge.refunded` | Widerrufs-/Erstattungsstatus aktualisieren (`OrderWithdrawal`) |
| `charge.dispute.created` | Incident im Payment-Dashboard |
| `setup_intent.succeeded` | Zahlungsmittel-Update abschließen (`user_abos` aktualisieren) |
- **Redirect-Return ≠ Wahrheit:** Wie beim PAYONE-TSOK gilt der Webhook als Quelle der
Wahrheit für „bezahlt", der Browser-Return nur für UX. SEPA wird z. B. erst Tage
später `succeeded`.
## 8. Sicherheit & Compliance
| Thema | Umsetzung |
|---|---|
| PCI-DSS | **Stripe Hosted Checkout** → SAQ-A (geringstmöglicher Scope). Kartendaten berühren weder unseren Server noch unser Frontend. Secret Key nur serverseitig; ein Publishable Key wird beim Redirect-Flow nicht einmal im Frontend benötigt. |
| Secrets | `config/stripe.php` mit `env('STRIPE_SECRET')`, `env('STRIPE_KEY')`, `env('STRIPE_WEBHOOK_SECRET')`; Hard-Fail-Muster wie `config/payone.php` (Boot-Exception bei fehlendem Secret im Live-Betrieb). Kein `env()` außerhalb von Config-Dateien. |
| Logging | Recorder-Redaction wie PAYONE: keine vollständigen PM-Details in `transmitted_data`/Logs; Stripe-IDs (`pi_`, `pm_`, `cus_`) sind unkritisch und ausdrücklich erwünscht (Traceability). |
| Webhook | Signaturprüfung + Idempotenz-Store; bei Verarbeitungsfehler kein 200 → Stripe wiederholt (gleiches Prinzip wie TSOK-Retry). |
| AuthZ | Zahlungsmittel-Update nur Abo-Inhaber (Regel aus CC-Update-Projekt: „nur Inhaber gibt Karte ein" gilt unverändert für Stripe). |
| Test/Live | Stripe-Testmodus ↔ `mode=test`; Rechnungen weiterhin nur in Produktion (bestehende Test-System-Sperre). |
## 9. SDK-Entscheidung
**Empfehlung: `stripe/stripe-php` (offizielles SDK), kein Laravel Cashier.**
Begründung: Cashier ist um Stripe Billing/Subscriptions herum gebaut (eigene Tabellen
`subscriptions`, Trait am User-Model) – genau das Modell, das wir laut Abschnitt 2
nicht verwenden. Das nackte SDK passt zur bestehenden Architektur
(Service-Schicht + injizierbarer Client wie `?Client $client` bei PAYONE) und bleibt
testbar (`StripeClient`-Interface-Wrapper, im Test gemockt).
→ Composer-Ergänzung `stripe/stripe-php`: **freigegeben** (O6, 2026-07-08).
## 10. Offene Fragen (Stakeholder) — beantwortet 2026-07-08
| ID | Frage | Antwort (Stakeholder, 2026-07-08) |
|----|-------|-----------------------------------|
| O1 | Welche Zahlarten zum Stripe-Start? | **Alle verfügbaren:** Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna + weitere Stripe-Methoden. Anzeige über Hosted Checkout / Dashboard (s. §3.1/§3.2). Abo-Checkouts auf wiederverwendbare Methoden eingeschränkt. |
| O2 | PayPal über Stripe? | **Ja** — implizit durch O1; ersetzt das blockierte PayPal-direkt-Projekt aus dem Alt-Plan. |
| O3 | Ersatz für Sofortüberweisung? | **Klarna** (Teil des Zahlarten-Scopes aus O1). |
| O4 | Schalter-Granularität | Global starten (Empfehlung angenommen, kein Widerspruch); pro Zahlart als Ausbaustufe. |
| O5 | Nicht-EUR-Währungen? | **Nein — alle Zahlungen in EUR.** |
| O6 | Freigabe `stripe/stripe-php`? | **Ja, freigegeben.** |
| O7 | Stripe-Account? | **Angelegt.** Stakeholder hat Zugang und verwaltet ihn (Keys, Webhook-Konfiguration, Dashboard-Zahlarten). |
| O8 | Migration Bestands-Abos? | **Ja, aber später** — bewusst nach hinten gestellt (Phase S8 bleibt am Ende des Plans; kein Blocker für den Rollout). |
| O9 | Membership-/ELV-Zahlungen im Scope? | **Nach hinten gestellt** — vorerst PAYONE-only, eigener Folgeschritt nach dem Kern-Rollout. |
### Neue offene Punkte (aus den Entscheidungen abgeleitet)
| ID | Punkt | Klärung in | Antwort |
|----|-------|-----------|---------|
| N1 | Klarna & Amazon Pay: unterstützt Stripe dort aktuell `setup_future_usage=off_session` (Abo-Fähigkeit)? Gegen aktuelle Stripe-Doku prüfen. | Phase S0 | offen — bis zur Prüfung gelten beide als **nur Einmalzahlung** |
| N2 | UX des Checkout-Schritts „Zahlungsart" bei Schalter `stripe`? | beantwortet 2026-07-08 | **Keine Zahlartenwahl mehr im mivita-Checkout:** Nach Checkout + Prüfung geht es direkt zu Stripe. Für Abos schränkt die Session serverseitig auf abo-fähige Methoden ein; alles andere wird dort ausgeblendet. |
| N3 | Vorkasse (VOR) / interne Rechnung (FNC) im Stripe-Betrieb? | beantwortet 2026-07-08 | **Interne Rechnung (FNC):** bleibt als Option im System erhalten (ist ohnehin fast überall deaktiviert), wird aktuell ausgeblendet. **Vorkasse (VOR):** läuft heute über PAYONE → bekommt einen **eigenen Sichtbarkeits-Schalter in den Einstellungen**, damit sie unabhängig vom Provider-Schalter ein-/ausgeblendet werden kann. Ist Vorkasse ausgeblendet und Stripe aktiv, entfällt der Zahlarten-Schritt vollständig. |