- 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>
32 KiB
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, diePayonePaymentService::checkCreditCard()erwartet).app/Http/Requests/Abo/UpdateAboCreditCardRequest.php(neu) – validiert die cc_*-Felder,toDto(), Meldungen viaabo.*.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ändenapproved(carddata,userid,txid)/redirect(url,carddata,txid)/error(message).app/Services/Payment/Payone/PayonePaymentService.php–refreshAboCardToken(ShoppingUser, CreditCardCheckData, returnUrls, mode): CardUpdateResult(creditcardcheck +preauthorizationamount=1,recurrence=recurring,customer_is_present=yes);releasePreauth(txid, mode)(=capture amount=0); KonstanteCARD_VERIFICATION_AMOUNT=1.app/Services/AboPaymentMethodService.php(neu) –updateCreditCard(UserAbo, CreditCardCheckData, returnUrls): Invariante-Check → Pending-Spur setzen → Token-Refresh → bei APPROVEDapplyNewCard()(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_atbleibt 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: ausUserAbo::initialShoppingPayment()?->mode, Fallbackconfig('payone.defaults.mode').refreshAboCardToken()nimmtShoppingUser(Personendaten) stattUserAbo– dieUserAbo-DB-Schreibung bleibt inAboPaymentMethodService(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()(nurtruncatedcardpan+cardexpiredate, nie PAN/CVC).app/Services/Payment/Payone/DTOs/CardUpdateResult.php+PayonePaymentService–redirect()trägt nun auchuserid.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-zentriertePayoneWebhookHandlernicht. Abschluss-Signal ist die success-Return-URL; der Karten-Token wird über den Redirect in der Session zwischengespeichert und bei Rückkehr perapplyNewCard()geschrieben. Dieuseridaus der preauthorization wird mitgeführt; ist sie (REDIRECT)0, greift K1: die nächste Abbuchung mintet wie beim Erst-Checkout eine neueuserid(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::resolveMissingNamedRoutesUsingstubbt 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+ ModelUserAboCardChange; bei jedem erfolgreichenapplyNewCard()wird ein Eintrag (Zeitpunkt, maskierte Karte, Quelleportal/spätercrm) 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-GateassertOwnerCardEntry().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 geteilteportal.abo._payment_method_formmit CRM-formAction/backUrl(Partial dafür um$formAction/$backUrlparametrisiert, Portal-Defaults unverändert).resources/views/user/abo/detail.blade.php– Zahlungsmethoden-Karte (Maske + Button + Historie), nurview=me+ cc.tests/Feature/AboCrmPaymentMethodTest.php(neu) – 2 Tests (3DS-Rückkehrsuccess→applyNewCardmit Quellecrm; Kunden-Aboot→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.
Schritt 5 – Berater→Kunde-Update-Link (login-frei) ✅ ERLEDIGT (2026-06-26)
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 viaSetting('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 viaUtil::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 Erfolgused_atsetzen; 3DS-Rückkehr wie Portal (Session-Stash). Quelle'link'.routes/domains/portal.php– 3 login-freie Routenabo/karte/{token}[/return/{status}], jethrottle:10,1. Wichtig: bewusst außerhalb derauth: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)(nurot+ cc + aktualisierbar) + CRM-Routeuser_abos_payment_method_send_link.resources/views/user/abo/detail.blade.php– „Update-Link senden"-Button fürot-Abos; Maske/Historie jetzt fürmeundotsichtbar.resources/views/portal/abo/card_update_link.blade.php+card_update_done.blade.php(neu); Trust-Layoutportal.layouts.public(neu): Portal-Header (Logo + Sprachwechsel), Footer (Impressum/Datenschutz über die öffentlicheportal.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-Versandot→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:
pseudocardpanist der eigentliche, dauerhafte Kartentoken (merchant-unique);useridist laut PAYONE-Doku optional (PAYONE-vergeben).- Es gibt kein
updateuser/updatecard. Kartenwechsel = neuercreditcardcheck→ neuerpseudocardpan(deckt sich mit Hauptplan O2/O5). - Code-Beleg: Die
useridentsteht 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 |
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/backurlderpreauthorizationzeigen 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 finaleUserAbo-Write nach erfolgreichem 3DS erfolgt. Muster: bestehendercheckout.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, Providerpayone) mit Bestandsmitteln. - Reparaturpfad idempotent: erneutes Schreiben desselben
pseudocardpanist gefahrlos wiederholbar. - Reihenfolge bleibt fix: PAYONE OK → DB-Update (nie umgekehrt).
6.3 Link-Tabelle abo_card_update_links
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 | 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 |