mivita/dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md
Kevin 1108381988 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

406 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Entwicklungsplan: 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.
### 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 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()`](../../app/Services/Payment/Payone/PayonePaymentService.php#L70-L127)).
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](https://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`](../../app/Services/Payment/Payone/PayoneTransactionRecorder.php#L33))
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`](../../app/Http/Controllers/Pay/PayoneController.php#L243-L255))
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`](../../app/Services/Payment/Payone/PayonePaymentService.php) | 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`](../../app/Http/Controllers/Portal/AboController.php) | `editPaymentMethod($id)` (Formular) + `updatePaymentMethod(UpdateAboCreditCardRequest, $id)` – Inhaber-Selfservice. |
| [`User\AboController`](../../app/Http/Controllers/User/AboController.php) | `editPaymentMethod($view,$id)` + `updatePaymentMethod(...)` für **eigene** Berater-Abos; zusätzlich `sendCardUpdateLink($view,$id)` für Kunden-Abos (E4). |
| [`UserAbo`](../../app/Models/UserAbo.php) | 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)
```php
// 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).
### 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 | ~~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 |