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>
18 KiB
Stripe-Konzept: Architektur & API-Mapping
Stand: 2026-07-08 · Ergänzt
ENTWICKLUNGSPLAN-STRIPE.md(Phasen) undANALYSE-IST-ZUSTAND.md(Codebase). Hier stehen die Architektur-Entscheidungen und das Mapping der Stripe-API auf unsere Domäne.
1. Leitplanken (vom Stakeholder gesetzt)
- Stripe ersetzt PAYONE als Zahlungsabwicklung – Rechnungen, Lieferscheine, Provisionen etc. werden weiterhin im System generiert.
- PAYONE wird nicht entfernt. Es bleibt als Option bestehen und wird sauber separiert. Ein Schalter in den Einstellungen wechselt den aktiven Anbieter.
- Scope: Einmalzahlungen zuerst, dann Abos.
- Netto/Brutto je Land bleibt Systemaufgabe (bestehende
country_prices-Logik). - 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
payonewie heute. - 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)
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 bestehendesSysLog-Muster. - Der Schalter wirkt nur auf neue Zahlungsvorgänge (Checkout-Start, neue Abos, neue Zahlungslinks).
- Bestandsdaten sind schaltunabhängig: Jede
shopping_payments-Zeile und jedesuser_abosträgt seinenpayment_providerfest. 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
PaymentSettingoder Erweiterung des bestehenden Settings-Mechanismus (Setting/SySetting) → im Plan Phase S1 entscheiden, Konvention der Codebase folgt.
4.3 Datenmodell-Erweiterungen
-- 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_currencyexistiert → 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(inroutes/api.php, läuft global unterapi.<domain>– analog PAYONE-IPN). Ohne CSRF, mit Signaturprüfung (Stripe-Signature-Header,STRIPE_WEBHOOK_SECRET). - Handler nach dem Muster
PayoneWebhookHandler(Idempotenz überevent.id, Duplikat-Erkennung, Persistenz nur überStripeTransactionRecorder). - 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. |