mivita/dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md
Kevin 671022a28d Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung
- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan
  (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs)
- Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich
  begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail)
- Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail
- Admin: Abo löschen (destroy) inkl. Test
- Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID
- VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt
- Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:19 +00:00

32 KiB
Raw Blame History

Entwicklungsplan: Kreditkarte aktualisieren (PAYONE)

Status: In Arbeit (Schritt 1–5 erledigt; offen: Schritt 6 Sandbox-Smoke) · Erstellt: 2026-06-25 · Scope: Nur Kreditkarten-Update (PAYONE) Übergeordnet: dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md (Phase 1, herausgelöst & verschärft) Voraussetzung erfüllt: Phase R (PAYONE-Refactoring) abgeschlossen & live-verifiziert.

Umsetzungsstatus

Schritt 1 – DTO + Form Request + Autorisierungs-Invariante ✅ ERLEDIGT (2026-06-25)

Dateien:

  • app/Services/Payment/Payone/DTOs/CreditCardCheckData.php (neu) – immutables Input-DTO; fromFormData() / toCheckArray() (cc_*-Keys, die PayonePaymentService::checkCreditCard() erwartet).
  • app/Http/Requests/Abo/UpdateAboCreditCardRequest.php (neu) – validiert die cc_*-Felder, toDto(), Meldungen via abo.*.
  • app/Models/UserAbo.php – isCreditCard() + canUpdatePaymentMethod() (guard-agnostische Invariante).
  • resources/lang/{de,en}/abo.php – 5 neue Karten-Validierungs-Keys.
  • tests/Feature/AboUpdateCreditCardRequestTest.php (neu) – 14 Tests (DTO-Mapping, Validierung happy/invalid via Dataset, Invariante cc/Status).

Abweichung (statt app/Policies/UserAboPolicy.php): Das Projekt nutzt keine registrierten Laravel-Policies, sondern Inline-Eigentümerprüfungen über zwei Guards (CRM = web/App\User → User\AboController::checkPermissions; Portal = customers/App\Models\Customer → Portal\AboController::checkPortalPermission). Eine dual-guard-Policy würde gegen Framework und Bestandskonvention arbeiten. Stattdessen: die guard-agnostische Regel als UserAbo::canUpdatePaymentMethod(); die guard-spezifische Eigentümerprüfung bleibt in den bestehenden Inline-Methoden (Schritt 3/4).

Hinweis CC-Eingabe: Der bestehende checkCreditCard-Pfad konsumiert rohe cc_*-Felder (server-seitiges creditcardcheck); das Update-Formular spiegelt exakt diese Felder. checkCreditCard()-Signatur bleibt vorerst unverändert (kein Checkout-Bruch); refreshAboCardToken() in Schritt 2 nutzt CreditCardCheckData::toCheckArray().

Tests: 14 grün; Regression AboPendingInitialPaymentTest + PayonePaymentServiceTest grün (27 gesamt). pint grün.

Schritt 2 – Service-Schicht (PAYONE-Token-Refresh + Orchestrierung) ✅ ERLEDIGT (2026-06-25)

Dateien:

  • app/Services/Payment/Payone/DTOs/CardUpdateResult.php (neu) – Ergebnis-DTO mit Zuständen approved(carddata,userid,txid) / redirect(url,carddata,txid) / error(message).
  • app/Services/Payment/Payone/PayonePaymentService.php – refreshAboCardToken(ShoppingUser, CreditCardCheckData, returnUrls, mode): CardUpdateResult (creditcardcheck + preauthorization amount=1, recurrence=recurring, customer_is_present=yes); releasePreauth(txid, mode) (= capture amount=0); Konstante CARD_VERIFICATION_AMOUNT=1.
  • app/Services/AboPaymentMethodService.php (neu) – updateCreditCard(UserAbo, CreditCardCheckData, returnUrls): Invariante-Check → Pending-Spur setzen → Token-Refresh → bei APPROVED applyNewCard() (atomar) + Reservierung freigeben; bei REDIRECT Pending-Spur halten + Redirect zurückgeben; bei ERROR Spur zurücknehmen. applyNewCard() ist idempotent (auch für 3DS-Rückkehr) und löst bei DB-Write-Fehler nach PAYONE-OK einen CRITICAL-Alert aus (card_update_pending_at bleibt als Spur).
  • database/migrations/2026_06_25_120000_add_card_update_pending_at_to_user_abos.php (neu) + UserAbo (fillable/cast).
  • resources/lang/{de,en}/abo.php – card_update_not_allowed, card_update_failed.
  • tests/Feature/AboPaymentMethodServiceTest.php (neu) – 5 Tests (APPROVED ohne Abbuchung, 3DS-REDIRECT ohne DB-Update, creditcardcheck-Fehler, preauth-Fehler, Nicht-CC-Abo).

Entscheidungen/Abweichungen:

  • mode (test/live) ohne Order: aus UserAbo::initialShoppingPayment()?->mode, Fallback config('payone.defaults.mode').
  • refreshAboCardToken() nimmt ShoppingUser (Personendaten) statt UserAbo – die UserAbo-DB-Schreibung bleibt in AboPaymentMethodService (saubere Schichtung: PAYONE-Service ohne Domänen-Persistenz).
  • releasePreauth-Fehler ist unkritisch (Reservierung verfällt) → nur Warning-Log, kein Abbruch.

Offen für Schritt 3 (Controller): 3DS-Rückkehr-Finalisierung – wo die Karten-Token-Daten über den Redirect zwischengespeichert werden (Session beim Selfservice, Link-Tabelle beim Berater→Kunde-Flow) und der finale applyNewCard()-Aufruf nach erfolgreicher 3DS-Authentifizierung.

Tests: 5 grün; Gesamt-Regression (Schritt 1+2 + PayonePaymentServiceTest + PayoneTransactionRecorderTest + AboPendingInitialPaymentTest) 39 grün. pint grün.

Schritt 3 – Portal-Selfservice (Kunde, in.mivita) ✅ ERLEDIGT (2026-06-25)

Dateien:

  • app/Http/Controllers/Portal/AboController.php – editPaymentMethod($id) (Formular, cc-only), updatePaymentMethod(UpdateAboCreditCardRequest, $id, AboPaymentMethodService) (3 Ausgänge: APPROVED→Flash+Redirect; REDIRECT→Karten-Token in Session stashen + away(3DS); ERROR→Flash+zurück), paymentMethodReturn($id, $status, …) (3DS-Rückkehr: success→applyNewCard() aus Stash; sonst Pending-Spur zurücknehmen).
  • routes/domains/portal.php – 3 Routen (portal_abos_payment_method_edit|update|return).
  • resources/views/portal/abo/payment_method.blade.php + _payment_method_form.blade.php (neu) – CC-Formular (gespiegelt vom Checkout) + 0,01-EUR-Hinweis + aktuelle (maskierte) Karte.
  • resources/views/portal/abo/my_abo.blade.php – Zahlungsmethoden-Karte mit Maske + „aktualisieren"-Button (nur cc + canUpdatePaymentMethod()).
  • app/Models/UserAbo.php – maskedCard() (nur truncatedcardpan+cardexpiredate, nie PAN/CVC).
  • app/Services/Payment/Payone/DTOs/CardUpdateResult.php + PayonePaymentService – redirect() trägt nun auch userid.
  • resources/lang/{de,en}/abo.php – Form-/Status-Keys (payment_method, update_payment_method, update_card_title, current_card, card_update_intro, card_update_success).
  • tests/Feature/AboPortalPaymentMethodTest.php (neu) – 4 Tests (Masken-Format, Formular-Render, 3DS-Rückkehr success→applyNewCard, cancel→Spur zurückgenommen).

Entscheidungen/Abweichungen:

  • 3DS-Rückkehr ohne Order-Webhook: Für ein Karten-Update existiert keine ShoppingOrder, daher greift der order-zentrierte PayoneWebhookHandler nicht. Abschluss-Signal ist die success-Return-URL; der Karten-Token wird über den Redirect in der Session zwischengespeichert und bei Rückkehr per applyNewCard() geschrieben. Die userid aus der preauthorization wird mitgeführt; ist sie (REDIRECT) 0, greift K1: die nächste Abbuchung mintet wie beim Erst-Checkout eine neue userid (Webhook-Recorder schreibt sie zurück).
  • Teststrategie konventionsgerecht: Portal/CRM-Controller werden im Projekt nicht über HTTP getestet (domain-aware Routing + customers-Guard, vgl. AboPendingInitialPaymentTest). Daher: View-Render-Test + direkte Controller-Methoden-Aufrufe (paymentMethodReturn) + die bereits in Schritt 2 abgedeckte Service-Logik. URL::resolveMissingNamedRoutesUsing stubbt fehlende benannte Routen.

Tests: 4 grün; Gesamt-Regression (Schritt 1–3 + PAYONE-Suite + AboPendingInitialPaymentTest + PayoneWebhookHandlerTest) 48 grün. pint grün.

Schritt 3a – UX-Erweiterungen (Sicherheitshinweis + Änderungs-Historie) ✅ ERLEDIGT (2026-06-26)

Nach Praxistest im Testsystem nachgezogen:

  • Sichtbare Sicherheits-Hinweisbox im CC-Formular (alert-info + Schloss-Icon): Kartendaten werden hier nicht gespeichert, sondern SSL-verschlüsselt an PAYONE zur Verifizierung übermittelt (abo.card_security_notice).
  • Änderungs-Historie der Karte: neue Tabelle user_abo_card_changes + Model UserAboCardChange; bei jedem erfolgreichen applyNewCard() wird ein Eintrag (Zeitpunkt, maskierte Karte, Quelle portal/später crm) innerhalb der DB-Transaktion geschrieben. Anzeige in der Zahlungsmethoden-Karte (my_abo) als Liste „DD.MM.YYYY HH:MM — 411111XXXXXX1234 (12/30)". UserAbo::formatMaskedCard() (static) + card_changes()-Relation.

Dateien: database/migrations/..._create_user_abo_card_changes_table.php, app/Models/UserAboCardChange.php, app/Models/UserAbo.php (Relation + formatMaskedCard), app/Services/AboPaymentMethodService.php (Historie + $source), app/Http/Controllers/Portal/AboController.php ('portal'-Quelle), resources/views/portal/abo/_payment_method_form.blade.php + my_abo.blade.php, resources/lang/{de,en}/abo.php.

Tests: Service- + Portal-Suite erweitert (Historien-Eintrag mit Maske/Quelle, Hinweisbox-Render) — 23 grün. pint grün. Migration ausgeführt.

Schritt 4 – CRM-Selfservice (Berater, my.mivita) ✅ ERLEDIGT (2026-06-26)

Spiegelt Schritt 3 für den Berater als Inhaber seines eigenen Abos (view=me).

Dateien:

  • app/Http/Controllers/User/AboController.php – editPaymentMethod($view,$id), updatePaymentMethod(UpdateAboCreditCardRequest,$view,$id,…), paymentMethodReturn($view,$id,$status,…) (Quelle 'crm') + privates Inhaber-Gate assertOwnerCardEntry().
  • routes/domains/crm.php – 3 Routen (user_abos_payment_method_edit|update|return, {view}/{id}).
  • resources/views/user/abo/payment_method.blade.php (neu) – nutzt das geteilte portal.abo._payment_method_form mit CRM-formAction/backUrl (Partial dafür um $formAction/$backUrl parametrisiert, Portal-Defaults unverändert).
  • resources/views/user/abo/detail.blade.php – Zahlungsmethoden-Karte (Maske + Button + Historie), nur view=me + cc.
  • tests/Feature/AboCrmPaymentMethodTest.php (neu) – 2 Tests (3DS-Rückkehr success→applyNewCard mit Quelle crm; Kunden-Abo ot→403).

Entscheidung – striktes Inhaber-Gate (E1/E2 + O4): assertOwnerCardEntry() lässt die direkte Karteneingabe nur zu, wenn view=me, is_for=me und user_id === Auth::id(). Damit ist kein Admin-Bypass (entgegen checkPermissions) und keine Kunden-Abo-Eingabe (ot) möglich – Kunden-Abos laufen über den Berater→Kunde-Link (Schritt 5).

Tests: 2 grün; Gesamt-Regression (Schritt 1–4 + PAYONE-Suite + PortalOrderCreateTest) 51 grün. pint grün.

Berater verschickt einem Kunden (Kunden-Abo ot) einen Link; der Kunde gibt die Karte selbst ein.

Dateien:

  • database/migrations/..._create_abo_card_update_links_table.php + app/Models/AboCardUpdateLink.php (neu) – Token wird nur als SHA-256-Hash gespeichert (indexiert, unique, O(1)-Lookup; kein bcrypt), Einmalverwendung (used_at), TTL via Setting('abo-card-update-link-ttl-hours'), Default 72h. generateFor() gibt den Klartext-Token nur zurück (nie persistiert).
  • app/Mail/AboCardUpdateLinkMail.php + resources/views/emails/abo_card_update_link.blade.php (neu) – Link zeigt via Util::getMyMivitaPortalUrl() immer auf die Kundendomain (in.mivita), unabhängig vom CRM-Versandkontext.
  • app/Http/Controllers/Web/AboCardUpdateController.php (neu) – show/store/return, login-frei; resolveRedeemableLink() → 410 Gone bei unbekannt/abgelaufen/benutzt/nicht aktualisierbar. Bei Erfolg used_at setzen; 3DS-Rückkehr wie Portal (Session-Stash). Quelle 'link'.
  • routes/domains/portal.php – 3 login-freie Routen abo/karte/{token}[/return/{status}], je throttle:10,1. Wichtig: bewusst außerhalb der auth:customers-Gruppe platziert (sonst Login-Redirect für den nicht eingeloggten Kunden) – Bugfix 2026-06-26.
  • app/Http/Controllers/User/AboController.php – sendCardUpdateLink($view,$id) (nur ot + cc + aktualisierbar) + CRM-Route user_abos_payment_method_send_link.
  • resources/views/user/abo/detail.blade.php – „Update-Link senden"-Button für ot-Abos; Maske/Historie jetzt für me und ot sichtbar.
  • resources/views/portal/abo/card_update_link.blade.php + card_update_done.blade.php (neu); Trust-Layout portal.layouts.public (neu): Portal-Header (Logo + Sprachwechsel), Footer (Impressum/Datenschutz über die öffentliche portal.loading_modal-Route) und „Zum Kundenportal"-Hinweis/Button (gegen Phishing-Eindruck, 2026-06-26).
  • resources/lang/{de,en}/abo.php – Link-/Mail-/Ergebnis-Keys.
  • tests/Feature/AboCardUpdateLinkTest.php (neu) – 6 Tests (Token nur als SHA-256 gespeichert, expired/used nicht einlösbar, 410, Store wendet Karte an + used_at, CRM-Versand ot→Mail+Link, me→kein Link).

Entscheidung/Sicherheit: SHA-256-Lookup-Token (Review-Korrektur 5.3 #2 umgesetzt), Single-Use + TTL + Rate-Limiting; Karteneingabe weiterhin nur durch den Inhaber (hier den Kunden über den Link). Offen (Schritt 6): Domain-Auflösung des Mail-Links im Live-Betrieb am Testsystem verifizieren (Kundendomain-Host korrekt).

Tests: 6 grün; Gesamt-Regression (Schritt 1–5 + PAYONE-Suite) 50 grün. pint grün. Migration ausgeführt.

Dokumentations-Hinweis (verbindlich)

Wie im übergeordneten Plan: phasenweise abarbeiten, jede Phase nach Abschluss auf ERLEDIGT (Datum, Dateien, Begründung), jede Änderung programmatisch testen (Pest), danach ./vendor/bin/pint.


1. Warum dieser eigenständige Plan

PayPal-direkt (Phasen 4–8 des Hauptplans) ist blockiert: Vom PayPal-Merchant gibt es bis heute keine Freigabe für die Subscriptions-API. Solange das offen ist, wird die direkte PayPal-Integration zurückgestellt. Wir ziehen daher die für den Betrieb wichtigste, unblockierte Anforderung vor und setzen sie isoliert um: die Kreditkarte eines bestehenden Abos aktualisieren (clearingtype=cc).

Nicht-Ziele dieses Plans: PayPal direkt, SEPA-Mandatswechsel, Recovery-Zahlungslink bei fehlgeschlagener Abbuchung (Hauptplan Phase 3), Provider-Abstraktion (Hauptplan Phase 4). Berührt werden ausschließlich CC-Abos über PAYONE.


2. Festgelegter Scope (Stakeholder-Entscheidungen 2026-06-25)

# Entscheidung Festlegung
E1 Wer darf die Karte eingeben? Ausschließlich der Abo-Inhaber selbst. Niemand gibt fremde Kartendaten ein – kein Berater, kein Admin.
E2 Zwei Abo-Typen / zwei Kontexte Berater-Abos im CRM (my.mivita, User\AboController), Kunden-Abos im Portal (in.mivita, Portal\AboController). In beiden Kontexten aktualisiert der jeweilige Inhaber seine eigene Karte.
E3 Verifizierung Der Inhaber muss sich mit den neuen Kartendaten erneut checkout-artig verifizieren (PAYONE creditcardcheck, gleiches Formular wie Checkout).
E4 Berater → Kunde Zusätzlich kann ein Berater im CRM einem Kunden einen Update-Link senden. Der Kunde öffnet den Link und gibt selbst die neue Karte ein (Inhaber-Prinzip aus E1 bleibt gewahrt).
E5 Abbuchungsverhalten Token-Refresh ohne Abbuchung. Das Update bucht nicht ab; der neue Token wird gespeichert und erst bei der nächsten regulären Abo-Abbuchung verwendet.

3. Mechanismus (PAYONE ohne updateuser)

Der wiederkehrende CC-Token eines Abos ist heute das Tupel UserAbo.payone_userid + UserAbo.carddata['pseudocardpan'] + ['cardexpiredate'] (siehe PayonePaymentService::chargeAbo()).

PAYONE-updateuser ist für den Live-Account nicht bestätigt (Hauptplan O2/O5). Entscheidung dort: keinen Update-Call, sondern neu erzeugen. Für reines Karten-Update heißt das konkret:

1. Inhaber gibt neue Kartendaten ein (PAYONE-Client-API-Felder wie im Checkout)
2. creditcardcheck (storecarddata=yes)  → neuer pseudocardpan, cardexpiredate, cardtype, truncatedcardpan
3. preauthorization (CIT, customer_is_present=yes, recurrence=recurring, amount=1 → 1 Cent)
     → 3DS/SCA-Authentifizierung (ggf. REDIRECT zur ACS, Rückkehr wie Checkout)
     → etabliert frisches Mandat für die neue Karte; reserviert NUR (kein capture)
     → liefert neue userid + txid
4. Reservierung freigeben: capture mit amount=0 (storniert beim Acquirer) ODER verfallen lassen
     → keine echte Abbuchung (1 Cent wird nicht belastet)
5. UserAbo.carddata + payone_userid mit den NEUEN Werten überschreiben (atomar, mit Pending-Spur)
6. Nächster Cron-Lauf (user:make_abo_order) bucht regulär über das neue Mandat ab.

K1 GEKLÄRT (PAYONE-Doku + Code-Analyse, 2026-06-25)

Recherche aus docs.payone.com/payment-methods/credit-cards und dem Ist-Code:

  • pseudocardpan ist der eigentliche, dauerhafte Kartentoken (merchant-unique); userid ist laut PAYONE-Doku optional (PAYONE-vergeben).
  • Es gibt kein updateuser/updatecard. Kartenwechsel = neuer creditcardcheck → neuer pseudocardpan (deckt sich mit Hauptplan O2/O5).
  • Code-Beleg: Die userid entsteht erst als Response der ersten Autorisierung (PayoneTransactionRecorder::recordFromResponse) und wird dann gespeichert.

Entscheidung (2026-06-25): direkt preauthorization (CIT, 3DS), kein MIT-Risiko

Eine reine creditcardcheck-Swap-Variante (neuer pseudocardpan, dann beim nächsten Cron als merchant-initiated abbuchen) würde voraussetzen, dass PAYONE die Folge-Abbuchung auf einer neuen, noch nie SCA-authentifizierten Karte ohne erneute 3DS akzeptiert. Aufgrund der PSD2-/SCA-Regeln ist das sehr wahrscheinlich nicht der Fall – das wiederkehrende Mandat hängt am ursprünglichen customer-initiated Vorgang (CIT) der alten Karte. Diese Variante wird daher verworfen.

Primärer Weg = preauthorization als CIT innerhalb des Update-Flows:

Parameter Wert Zweck
request preauthorization reserviert, bucht nicht ab (kein capture)
pseudocardpan neu (aus creditcardcheck) neuer Kartentoken
customer_is_present yes CIT – löst 3DS/SCA aus, Kunde authentifiziert sich aktiv
recurrence recurring etabliert das neue Folge-Mandat für künftige Abbuchungen
amount 1 (1 Cent) Verifizierungs-Reservierung; späterer Abo-Betrag noch nicht bekannt

Ergebnis: validierte Karte + frisches SCA-Mandat + neue userid/pseudocardpan – ohne echte Abbuchung (E5 erfüllt: Reservierung, kein Capture). Die preauthorization kann – wie 3DS im Checkout – einen REDIRECT auf die ACS-Seite zurückgeben; der Update-Flow behandelt das wie der bestehende Checkout-REDIRECT-Zweig (PayoneController::ResponseData) und kehrt über eine Erfolgs-/Fehler-URL zurück. (Konstante PREAUTHORIZATION existiert bereits.)

K1b GEKLÄRT (PAYONE-Vorgabe, 2026-06-25) → amount=1, Freigabe via capture amount=0

PAYONE beschreibt für „Initial transaction, followed by recurring payment" explizit: ist der spätere Betrag noch nicht bekannt, preauthorization mit amount=1 (1 Cent), recurrence=recurring, customer_is_present=yes über 3-D Secure senden. Der 1 Cent wird nicht belastet. Freigabe der Reservierung: capture mit amount=0 storniert das reservierte Geld beim Acquirer – alternativ die Reservierung je nach PAYONE-Setup verfallen lassen.

Entscheidung: amount=1 für die preauthorization; nach erfolgreichem 3DS + DB-Update die Reservierung per capture amount=0 aktiv freigeben (Verfall als Fallback, falls Setup das Capture nicht zulässt). UI-Hinweis für den Inhaber: „Zur Verifizierung kann eine Autorisierung von 0,01 EUR erfolgen, die nicht belastet wird."


4. Architektur

4.1 Neue Dateien

Datei Zweck
app/Services/AboPaymentMethodService.php Orchestriert das Update: Policy-Check → creditcardcheck → preauthorization (CIT/3DS, kein capture) → bei 3DS-REDIRECT pausieren/zurückkehren → UserAbo atomar aktualisieren → optional Reversal → SysLog. Kein HTTP.
app/Http/Requests/Abo/UpdateAboCreditCardRequest.php Form Request: validiert CC-Felder, baut ein typsicheres CreditCardCheckData-DTO (vgl. Hauptplan 5.3 #4). Keine PAN/CVC im Log.
app/Services/Payment/Payone/DTOs/CreditCardCheckData.php Immutables Input-DTO für PayonePaymentService (CLI-fest, kein $data['key'] auf ungeprüften Arrays).
app/Policies/UserAboPolicy.php → Abweichung (siehe Umsetzungsstatus Schritt 1) Statt Laravel-Policy: guard-agnostische Invariante UserAbo::canUpdatePaymentMethod() (cc-only, nicht gelöscht, kein terminaler Status) + bestehende Inline-Eigentümerprüfungen je Guard.
app/Models/AboCardUpdateLink.php + Migration Berater→Kunde-Link: user_abo_id, token_hash (indexiert, SHA-256), expires_at, used_at.
app/Mail/AboCardUpdateLinkMail.php E-Mail mit dem Update-Link an den Kunden.
app/Http/Controllers/Web/AboCardUpdateController.php Öffentlicher (login-freier) Einlöse-Flow des Berater-Links: Token prüfen → Formular → Update.

4.2 Erweiterungen bestehender Klassen

Datei Änderung
PayonePaymentService Neue Methode refreshAboCardToken(UserAbo, CreditCardCheckData, string $mode): PayonePaymentResult – kapselt creditcardcheck + preauthorization (CIT, recurrence=recurring, amount=1); Ergebnis kann redirectUrl (3DS) tragen. Plus releasePreauth(txid) (= capture amount=0). checkCreditCard() nimmt künftig das DTO statt rohem Array.
Portal\AboController editPaymentMethod($id) (Formular) + updatePaymentMethod(UpdateAboCreditCardRequest, $id) – Inhaber-Selfservice.
User\AboController editPaymentMethod($view,$id) + updatePaymentMethod(...) für eigene Berater-Abos; zusätzlich sendCardUpdateLink($view,$id) für Kunden-Abos (E4).
UserAbo Anzeige-Helfer maskedCard() (z. B. „Visa •••• 4242, gültig 12/27") aus carddata['truncatedcardpan']/cardexpiredate. Optional Spalte card_update_pending_at (siehe 6.2).
routes/domains/portal.php / crm.php / ggf. eigene Datei für den login-freien Link Neue Routen (siehe 4.3).
Views: portal/abo/my_abo.blade.php, CRM-Abo-Detail (admin.abo._detail Alias) Button „Zahlungsmethode aktualisieren" (nur bei clearingtype=cc) + maskierte Karteninfo.

4.3 Routen (Vorschlag, an bestehende Namensgebung angelehnt)

// portal.php  (in.mivita – Kunde, eigenes Abo)
Route::get ('portal/my-subscriptions/payment-method/{id}',        [AboController::class,'editPaymentMethod'])->name('portal_abos_payment_method_edit');
Route::post('portal/my-subscriptions/payment-method/{id}',        [AboController::class,'updatePaymentMethod'])->name('portal_abos_payment_method_update');
Route::get ('portal/my-subscriptions/payment-method/{id}/return/{status}', [AboController::class,'paymentMethodReturn'])->name('portal_abos_payment_method_return'); // 3DS-Rückkehr

// crm.php  (my.mivita – Berater, eigenes Abo + Link an Kunde)
Route::get ('/user/abos/payment-method/{view}/{id}',              'User\AboController@editPaymentMethod')->name('user_abos_payment_method_edit');
Route::post('/user/abos/payment-method/{view}/{id}',              'User\AboController@updatePaymentMethod')->name('user_abos_payment_method_update');
Route::get ('/user/abos/payment-method/{view}/{id}/return/{status}', 'User\AboController@paymentMethodReturn')->name('user_abos_payment_method_return'); // 3DS-Rückkehr
Route::post('/user/abos/payment-method/send-link/{view}/{id}',    'User\AboController@sendCardUpdateLink')->name('user_abos_payment_method_send_link');

// öffentlicher Einlöse-Link (login-frei, throttled) – Kunde gibt selbst ein
Route::get ('abo/karte/{token}',          [AboCardUpdateController::class,'show'])->middleware('throttle:10,1')->name('abo_card_update_show');
Route::post('abo/karte/{token}',          [AboCardUpdateController::class,'store'])->middleware('throttle:10,1')->name('abo_card_update_store');
Route::get ('abo/karte/{token}/return/{status}', [AboCardUpdateController::class,'return'])->middleware('throttle:10,1')->name('abo_card_update_return'); // 3DS-Rückkehr

3DS-Rückkehr: Die successurl/errorurl/backurl der preauthorization zeigen auf die jeweilige …_return-Route. Der Token/txid aus Schritt 2/3 wird bis zur Rückkehr zwischengespeichert (Session beim eingeloggten Selfservice; an der Link-Tabelle beim login-freien Flow), damit der finale UserAbo-Write nach erfolgreichem 3DS erfolgt. Muster: bestehender checkout.transaction_status-Flow.


5. Flows

5.1 Inhaber-Selfservice (Portal & CRM identisch)

Inhaber → "Zahlungsmethode aktualisieren" (nur clearingtype=cc sichtbar)
  → CC-Formular (gleiche PAYONE-Client-API-Felder wie Checkout)
  → POST updatePaymentMethod (UpdateAboCreditCardRequest)
    → UserAboPolicy@updatePaymentMethod (Inhaber? cc? aktiv?)
    → AboPaymentMethodService::update(userAbo, dto)
        → PayonePaymentService::refreshAboCardToken()  (creditcardcheck + preauthorization CIT/3DS)
        ├─ status REDIRECT (3DS) → away(redirectUrl) zur ACS; Rückkehr auf Erfolgs-/Fehler-URL
        │     (Token/txid bis zur Rückkehr zwischenspeichern, analog Checkout)
        └─ status APPROVED → UserAbo atomar: carddata + payone_userid (neu) überschreiben
              → Reservierung freigeben: capture amount=0 (Verfall als Fallback)
              → SysLog (wer/wann, nur Token-/Maskendaten, nie PAN/CVC)
    → Erfolg: "Karte aktualisiert. Nächste Abbuchung nutzt die neue Karte." (keine Abbuchung erfolgt)
    → Fehler/Abbruch (auch 3DS abgelehnt): Formular erneut mit PAYONE-customermessage, KEIN DB-Update

5.2 Berater → Kunde (E4)

Berater (CRM, Kunden-Abo) → "Update-Link an Kunden senden"
  → AboCardUpdateLink: token = Str::uuid(); token_hash = hash('sha256', token); TTL 72h; user_abo_id
  → AboCardUpdateLinkMail an UserAbo.email (nur Klartext-Token im Link, nie persistiert)
Kunde → öffnet abo/karte/{token}
  → Lookup via hash('sha256', token) (indexiert, O(1)); prüfen: nicht expired, nicht used, cc-Abo
  → gleiches CC-Formular → store → AboPaymentMethodService::update()
  → used_at setzen (Einmalverwendung)

6. Datenmodell

6.1 Keine Pflicht-Migration für den Token

UserAbo.carddata (JSON) und payone_userid existieren bereits – Update überschreibt nur deren Inhalt.

6.2 Empfohlen: Konsistenz-Spur (Hauptplan-Review 5.3 #1)

Externer PAYONE-Call und lokales DB-Update sind nicht in einer Transaktion gemeinsam atomar. Damit ein Crash nach erfolgreichem PAYONE-Call, aber vor dem DB-Write auffindbar bleibt:

  • Spalte user_abos.card_update_pending_at (nullable timestamp): vor dem PAYONE-Call setzen, nach erfolgreichem DB-Update zurücksetzen.
  • Schlägt das DB-Update fehl → CRITICAL-Log (payment-Kanal) + Auto-Incident (PaymentIncident, Provider payone) mit Bestandsmitteln.
  • Reparaturpfad idempotent: erneutes Schreiben desselben pseudocardpan ist gefahrlos wiederholbar.
  • Reihenfolge bleibt fix: PAYONE OK → DB-Update (nie umgekehrt).
id, user_abo_id (FK), token_hash VARCHAR(64) UNIQUE INDEX, expires_at, used_at NULL, created_by_user_id, timestamps

Token wird nur als SHA-256-Hash gespeichert (indexierter Lookup, O(1)) – nicht bcrypt (Hash::make erlaubt keinen direkten Lookup; vgl. Hauptplan-Review 5.3 #2). Klartext-Token nur im E-Mail-Link.


7. Sicherheit

Regel Umsetzung
Nur Inhaber gibt Karte ein (E1) UserAboPolicy@updatePaymentMethod; Berater/Admin haben keinen Eingabepfad
Kein PAN/CVC in Logs/DB Nur pseudocardpan + cardexpiredate + truncatedcardpan gespeichert; redactPayload() im Recorder greift bereits
Berater-Link SHA-256-Hash in DB, TTL 72h (konfigurierbar via Setting, Default 72), Einmalverwendung (used_at), Bindung an user_abo_id, throttle auf Einlöse-Endpoint
Kein Login am Link Token = Berechtigung; zusätzlich optional E-Mail-Abgleich mit user_abo.email
Atomarität PAYONE OK → DB-Update mit try/catch + CRITICAL-Alert + Pending-Spur (6.2)
Nur CC-Abos Policy + UI blenden alles außer clearingtype=cc aus
Test/Live-Trennung mode aus dem Abo-Kontext durchreichen (wie chargeAbo)

8. Tests (Pest)

Test Szenario
AboUpdateCreditCardTest Happy Path Portal: Mock PAYONE preauthorization APPROVED → carddata/payone_userid aktualisiert, kein capture/keine Abbuchung
CRM: Berater aktualisiert eigenes Abo → ok
3DS: preauthorization REDIRECT → away(redirectUrl); Rückkehr mit Erfolg → DB-Update erst danach
3DS abgelehnt / Abbruch an Return-URL → kein DB-Update
Auth: fremdes Abo → 403 (Policy)
Nicht-CC-Abo (z. B. wlt) → 422/abgewiesen
PAYONE ERROR/INVALID (creditcardcheck oder preauthorization) → kein DB-Update, Fehlermeldung
DB-Update-Fehler nach PAYONE-OK → card_update_pending_at bleibt gesetzt + CRITICAL geloggt
AboCardUpdateLinkTest Berater erzeugt Link → Mail versandt; Hash gespeichert, kein Klartext
Einlösen gültiger Token → Update ok, used_at gesetzt
Abgelaufener Token → 410; bereits benutzter → abgewiesen; throttle greift

9. Akzeptanzkriterien

  • Inhaber kann im Portal (Kunde) und CRM (Berater, eigenes Abo) die CC aktualisieren.
  • Update bucht nicht ab (E5); nächster user:make_abo_order-Lauf nutzt die neue Karte.
  • Berater kann einem Kunden einen Update-Link senden; Kunde aktualisiert selbst.
  • Karteneingabe ausschließlich durch den Inhaber (E1) – kein Berater-/Admin-Eingabepfad.
  • Maskierte Karteninfo im Abo-Detail sichtbar; keine PAN/CVC in Logs/DB.
  • Fehlgeschlagener PAYONE-Call → kein Partial-Update; DB-Inkonsistenz wird detektiert & alarmiert.
  • Berater-Link: SHA-256-Hash, TTL, Einmalverwendung, throttled.

10. Umsetzungsreihenfolge

Schritt 1: CreditCardCheckData-DTO + UpdateAboCreditCardRequest + UserAboPolicy
  ↓
Schritt 2: PayonePaymentService::refreshAboCardToken() (creditcardcheck + preauthorization, kein capture)
           + AboPaymentMethodService (+ Pending-Spur) + 3DS-Redirect/Return-Handling
  ↓
Schritt 3: Portal-Selfservice (Controller, Routes inkl. Return, View, Maske)   → testbar end-to-end
  ↓
Schritt 4: CRM-Selfservice (Berater eigenes Abo) – gleiche Service-Schicht
  ↓
Schritt 5: Berater→Kunde-Link (Tabelle, Mail, login-freier Controller inkl. Return, throttle)
  ↓
Schritt 6: Testbench/Sandbox-Smoke: 3DS-Durchlauf; bestätigen amount=1 + capture amount=0 (Freigabe);
           prüfen, dass 1 Cent nicht belastet wird und die nächste reguläre Abo-Abbuchung greift
  ↓
Schritt 7: Tests vervollständigen, pint, Live-Smoke im Test-System

Sofort startbar: K1 ist geklärt (preauthorization/CIT) – Schritt 1 und 2 können direkt umgesetzt werden. Nur K1b (Reservierungsbetrag/Reversal) wird in Schritt 6 empirisch finalisiert und betrifft nur den inneren Service-Call, nicht das Feature-Design.


11. Offene Mini-Fragen

ID Frage Hinweis
K1 userid nötig / MIT ohne 3DS? GEKLÄRT (2026-06-25): kein updateuser; MIT ohne erneute SCA wird (PSD2) verworfen → preauthorization als CIT/3DS, kein capture. Siehe Abschnitt 3.
K1b Reservierungs-amount + Reversal vs. Verfall? GEKLÄRT (2026-06-25, PAYONE-Vorgabe): amount=1 (1 Cent), Freigabe via capture amount=0 (Verfall als Fallback). Siehe Abschnitt 3.
K2 TTL Berater-Link 72h ok? Default 72h, via Setting konfigurierbar (analog Hauptplan O12)
K3 E-Mail-Abgleich am login-freien Link verpflichtend oder optional? Empfehlung: optional, Token genügt; E-Mail-Abgleich als Härtung
K4 Soll der CRM-Link-Versand auch für Berater-eigene Abos angeboten werden? Vermutlich nein – Berater nutzt dort den direkten Selfservice