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>
This commit is contained in:
Kevin 2026-07-08 15:56:19 +00:00
parent 83ef5f1e52
commit 1108381988
36 changed files with 2366 additions and 26 deletions

View file

@ -1,6 +1,16 @@
# Entwicklungsplan: Zahlungsanbieter & Abo-Subscription-Management
> **Status:** In Arbeit (Phase R abgeschlossen) · **Erstellt:** 2026-06-09 · **Zuletzt aktualisiert:** 2026-06-10 · **Briefing:** `docs/salescenter/Zahlungabieter.md`
> **Status:** In Arbeit (Phase R abgeschlossen) · **Erstellt:** 2026-06-09 · **Zuletzt aktualisiert:** 2026-06-25 · **Briefing:** `docs/salescenter/Zahlungabieter.md`
> ⚠️ **Statushinweis (2026-06-25): PayPal-direkt zurückgestellt.** Der PayPal-Merchant hat die
> Subscriptions-API bis heute **nicht freigegeben**. Die Phasen **4–8 (Provider-Abstraktion, PayPal
> direkt, Migration)** sind damit **blockiert und pausiert**, bis die Freigabe vorliegt. Stattdessen
> wird die wichtigste unblockierte Anforderung – **Kreditkarte aktualisieren (Phase 1)** – isoliert und
> verschärft in einem eigenen Plan umgesetzt:
> **`dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md`**.
> Dort sind die Stakeholder-Festlegungen (nur Inhaber gibt Karte ein; CRM + Portal; Berater→Kunde-Link;
> Token-Refresh **ohne** Abbuchung) und die geklärte PAYONE-Frage K1 (kein `updateuser`,
> `pseudocardpan` genügt) dokumentiert. PAYONE-bezogene Phasen (0–3) bleiben hier gültig.
## Dokumentations-Hinweis (verbindlich)
@ -774,6 +784,11 @@ Nach Abschluss von R1–R7 + Review-Fixes wurden im Test-System echte Checkout-B
## 7. Phase 1 – Kreditkarte aktualisieren (PAYONE) ⭐ Priorität 1
> 📌 **Ausgelagert & aktualisiert (2026-06-25):** Diese Phase wird eigenständig umgesetzt in
> `dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md` (mit
> Stakeholder-Festlegungen, geklärter K1-Frage und Berater→Kunde-Link). Der folgende Abschnitt ist die
> ursprüngliche Skizze; bei Abweichungen gilt der ausgelagerte Plan.
### Ziel
Authentifizierter User kann bei einem aktiven CC-Abo (`clearingtype=cc`) neue Kreditkartendaten hinterlegen. Die nächste Abbuchung nutzt den aktualisierten `pseudocardpan`.

View file

@ -0,0 +1,406 @@
# 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 |