diff --git a/app/Console/Commands/UserMakeAboOrder.php b/app/Console/Commands/UserMakeAboOrder.php index 0f9805f..d005d87 100644 --- a/app/Console/Commands/UserMakeAboOrder.php +++ b/app/Console/Commands/UserMakeAboOrder.php @@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder; use App\Models\UserAbo; use App\Models\UserAboOrder; use App\Services\AboHelper; +use App\Services\AboOneTimeService; use App\Services\Incentive\IncentiveTracker; use App\Services\MyLog; use App\Services\Payment; @@ -74,7 +75,7 @@ class UserMakeAboOrder extends Command 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - $this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage()); + $this->error('Fehler beim Ausführen des Befehls: '.$e->getMessage()); return 1; } @@ -164,7 +165,7 @@ class UserMakeAboOrder extends Command 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - $this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage()); + $this->error("Fehler bei Abo {$userAbo->id}: ".$e->getMessage()); } } } @@ -178,7 +179,7 @@ class UserMakeAboOrder extends Command private function makeOrder($userAbo) { \Log::channel('abo_order')->info('UserMakeAboOrder: Starte Bestellungserstellung', ['abo_id' => $userAbo->id]); - $this->info('Starte Bestellungserstellung für Abo: ' . $userAbo->id); + $this->info('Starte Bestellungserstellung für Abo: '.$userAbo->id); $shoppingOrder = null; $userOrder = new UserMakeOrder($userAbo); @@ -205,7 +206,7 @@ class UserMakeAboOrder extends Command ]); $response = $userOrder->makePayment(); - $this->info('makePayment response: ' . json_encode($response)); + $this->info('makePayment response: '.json_encode($response)); // Prüfe ob Response ein Array ist (kann auch Objekt sein) if (is_object($response)) { @@ -274,7 +275,7 @@ class UserMakeAboOrder extends Command 'status' => $response['status'], ]); $this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}"); - $this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $response['status']); + $this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: '.$response['status']); } else { // Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren \Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [ @@ -283,7 +284,7 @@ class UserMakeAboOrder extends Command 'status' => $response['status'], ]); $this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}"); - $this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $response['status']); + $this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: '.$response['status']); } } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [ @@ -291,11 +292,11 @@ class UserMakeAboOrder extends Command 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - $this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage()); + $this->error("Ausnahme bei Abo {$userAbo->id}: ".$e->getMessage()); // Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar if ($shoppingOrder) { - $this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage()); + $this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: '.$e->getMessage()); return $shoppingOrder; } @@ -356,12 +357,15 @@ class UserMakeAboOrder extends Command // Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief // (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt) IncentiveTracker::trackAboActivated($shoppingOrder); + + // Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten. + AboOneTimeService::purgeAfterExecution($userAbo); } catch (\Exception $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), ]); - $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage()); + $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage()); throw $e; // Re-throw für besseres Error-Handling } } @@ -421,7 +425,7 @@ class UserMakeAboOrder extends Command 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), ]); - $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage()); + $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage()); // Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann } } @@ -437,6 +441,6 @@ class UserMakeAboOrder extends Command $sec = intval($diff); $micro = $diff - $sec; - return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms'; + return $sec.' Sekunden und '.round($micro * 1000, 2).' ms'; } } diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index 685826a..8defbdb 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -177,8 +177,13 @@ class UserMakeOrder } // hier wird die Bestellung erstellt inkl aktueller Preise + // (setzt user_abos.amount auf den REINEN Abo-Betrag) AboOrderCart::makeOrderYard($this->userAbo); + // Verbindlich bestätigte Einmal-Artikel zusätzlich in den Yard laden + // (verändert user_abos.amount NICHT; Versand/Gewicht werden kombiniert berechnet). + AboOrderCart::addOneTimeItemsToYard($this->userAbo); + $yard = Yard::instance(AboOrderCart::INSTANCE); // Debug: Logge welche Produkte im Cart sind @@ -268,6 +273,7 @@ class UserMakeOrder 'row_id' => $item->rowId, 'product_id' => $item->id, 'comp' => $item->options->comp, + 'is_abo_addon' => (bool) ($item->options->abo_addon ?? false), 'qty' => $item->qty, 'price' => $item->price, 'price_net' => $price_net, diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index 08fd3ee..00c7c3e 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -15,6 +15,7 @@ use App\Services\AboHelper; use App\Services\AboItemHistoryService; use App\Services\AboOneTimeService; use App\Services\AboOrderCart; +use App\Services\AboRetryPaymentService; use App\Services\Shop; use App\Services\UserService; use App\Services\Util; @@ -491,6 +492,18 @@ class AboController extends Controller return false; } + public function retryPayment($id, AboRetryPaymentService $retryPaymentService) + { + $user_abo = UserAbo::findOrFail($id); + $this->checkPortalPermission($user_abo); + + $result = $retryPaymentService->retry($user_abo); + + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('portal.my_subscriptions')); + } + private function checkPortalPermission($user_abo) { $user = Auth::guard('customers')->user(); diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 1326c10..0455522 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -12,6 +12,7 @@ use App\Repositories\AboRepository; use App\Services\AboHelper; use App\Services\AboItemHistoryService; use App\Services\AboOrderCart; +use App\Services\AboRetryPaymentService; use App\Services\Shop; use App\User; use Request; @@ -507,6 +508,18 @@ class AboController extends Controller ->make(true); } + public function retryPayment($view, $id, AboRetryPaymentService $retryPaymentService) + { + $user_abo = UserAbo::findOrFail($id); + $this->checkPermissions($view, $user_abo); + + $result = $retryPaymentService->retry($user_abo); + + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('user_abos_detail', [$view, $id])); + } + private function checkPermissions($view, $user_abo) { \Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]); diff --git a/app/Services/AboOneTimeService.php b/app/Services/AboOneTimeService.php index 15340c0..2b43e5a 100644 --- a/app/Services/AboOneTimeService.php +++ b/app/Services/AboOneTimeService.php @@ -192,6 +192,44 @@ class AboOneTimeService ->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2); } + /** + * Entfernt nach einer erfolgreichen Ausführung ALLE Einmal-Artikel des Abos + * (bestätigte und offene Entwürfe, inkl. soft-deleted) und bewertet anschließend + * die Kompensationsprodukte des reinen Abos neu (das durch Einmal-Artikel + * verursachte Zusatzgewicht ist nun weg → überzählige Comp-Artikel werden entfernt). + * + * Wird ausschließlich im Erfolgszweig aufgerufen. Bei Zahlungsfehler bleiben die + * Einmal-Artikel erhalten (Entscheidung #2/#3). Schlägt etwas fehl, wird der bereits + * erfolgreiche Abo-Lauf NICHT zurückgerollt – Fehler werden nur protokolliert. + */ + public static function purgeAfterExecution(UserAbo $userAbo): void + { + try { + UserAboOneTimeItem::withTrashed() + ->where('user_abo_id', $userAbo->id) + ->get() + ->each(fn (UserAboOneTimeItem $item) => $item->forceDelete()); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboOneTimeService::purgeAfterExecution: Löschen der Einmal-Artikel fehlgeschlagen', [ + 'abo_id' => $userAbo->id, + 'error' => $e->getMessage(), + ]); + + return; + } + + try { + AboOrderCart::initYard($userAbo); + AboOrderCart::makeOrderYard($userAbo); + AboOrderCart::checkNumOfCompProducts($userAbo); + } catch (\Throwable $e) { + \Log::channel('abo_order')->warning('AboOneTimeService::purgeAfterExecution: Comp-Neuberechnung übersprungen', [ + 'abo_id' => $userAbo->id, + 'error' => $e->getMessage(), + ]); + } + } + public static function hasUnconfirmedChanges(UserAbo $userAbo): bool { return UserAboOneTimeItem::withTrashed() diff --git a/app/Services/AboRetryPaymentService.php b/app/Services/AboRetryPaymentService.php index 1fbeb7e..0057e8c 100644 --- a/app/Services/AboRetryPaymentService.php +++ b/app/Services/AboRetryPaymentService.php @@ -157,6 +157,9 @@ class AboRetryPaymentService 'error' => $e->getMessage(), ]); } + + // Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten. + AboOneTimeService::purgeAfterExecution($userAbo); } private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void diff --git a/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md b/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md index 4d6cf9c..33e8560 100644 --- a/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md +++ b/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md @@ -230,16 +230,35 @@ Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo --- -### Phase 4 – Ausführung, Retry & Aufräumen · `OFFEN` +### Phase 4 – Ausführung, Retry & Aufräumen · `ERLEDIGT (2026-06-08)` -- [ ] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert. -- [ ] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3). -- [ ] 4.3 `user_abos.amount` entkoppeln – reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1). -- [ ] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen. -- [ ] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück). -- [ ] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht. +- [x] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert. +- [x] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3). +- [x] 4.3 `user_abos.amount` entkoppeln – reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1). +- [x] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen. +- [x] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück). +- [x] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht. -**Doku:** _(nach Umsetzung ausfüllen)_ +**Doku (2026-06-08):** + +- **Ausführung – Einmal-Artikel in die Bestellung (4.1 + 4.3):** `app/Cron/UserMakeOrder.php@makeShoppingOrder()` ruft nach `AboOrderCart::makeOrderYard($this->userAbo)` zusätzlich `AboOrderCart::addOneTimeItemsToYard($this->userAbo)` auf. Da `addOneTimeItemsToYard()` ausschließlich **verbindlich bestätigte** Artikel lädt (`confirmed_at` gesetzt **und** `confirmed_qty === qty`), fließen nur diese in Versand, Gewicht, Comp-Produkte und Abbuchungsbetrag ein. Beim Schreiben der `ShoppingOrderItem` wird `is_abo_addon` aus `options->abo_addon` gesetzt (`(bool) ($item->options->abo_addon ?? false)`). +- **`amount` entkoppelt (4.3):** Die Reihenfolge ist entscheidend – `makeOrderYard()` setzt `user_abos.amount` auf den **reinen** Abo-Betrag, `addOneTimeItemsToYard()` rührt `amount` nicht an. Ergebnis: gespeicherter Abo-Betrag bleibt unverfälscht, der Zahlbetrag der konkreten Bestellung (`total_shipping`) ist kombiniert (Abo + Einmal). +- **Purge & Comp-Reset nur bei Erfolg (4.2 + 4.5):** Neue Methode `AboOneTimeService::purgeAfterExecution(UserAbo $userAbo)`: + 1. Löscht **alle** Einmal-Artikel des Abos endgültig (`UserAboOneTimeItem::withTrashed()->where('user_abo_id', …)->each(forceDelete)` – bestätigt, offen und soft-deleted). + 2. Bewertet die Kompensationsprodukte des **reinen** Abos neu (Option A): `AboOrderCart::initYard()` + `makeOrderYard()` + `checkNumOfCompProducts()`. Das durch Einmal-Artikel verursachte Zusatzgewicht ist nun weg → überzählige Comp-Artikel werden entfernt. + - Aufruf im **Erfolgszweig** von `app/Console/Commands/UserMakeAboOrder.php@updateAbo()` (Cron, nach `IncentiveTracker::trackAboActivated`) und `app/Services/AboRetryPaymentService.php@markAboSuccess()` (nach Incentive-Tracking). + - Bei **Zahlungsfehler** bleiben die Einmal-Artikel als offener Entwurf erhalten (Entscheidung #2/#3) – der Fehlerzweig ruft keinen Purge auf. + - **Robustheit:** Schlägt das Löschen oder die Comp-Neuberechnung fehl, wird der bereits erfolgreiche Lauf **nicht** zurückgerollt; Fehler landen nur im Log-Channel `abo_order`. +- **User-Retry (4.4):** + - **Sales Center:** `User\AboController@retryPayment($view, $id, AboRetryPaymentService)` – Berechtigung über `checkPermissions($view, $user_abo)`, Redirect auf `user_abos_detail`. Route `POST /user/abos/retry-payment/{view}/{id}` (`user_abos_retry_payment`, `routes/domains/crm.php`). + - **Portal:** `Portal\AboController@retryPayment($id, AboRetryPaymentService)` – Berechtigung über `checkPortalPermission($user_abo)` (customers-Guard + `billing_email`-Match), Redirect auf `portal.my_subscriptions`. Route `POST /portal/my-subscriptions/retry-payment/{id}` (`portal_abos_retry_payment`, `routes/domains/portal.php`). + - Beide delegieren an `AboRetryPaymentService::retry()` und nutzen damit denselben `makeShoppingOrder`-Pfad → Einmal-Artikel sind im Retry automatisch enthalten, Purge greift bei Erfolg über `markAboSuccess()`. Der **Admin-Retry** bleibt unverändert bestehen. + - **UI:** Gemeinsames Confirm-Modal `resources/views/admin/abo/_retry_payment_modal.blade.php` (Trigger-Button + Bestätigungsdialog, nur bei `status === 3 && active`), eingebunden in `user/abo/detail.blade.php` und `portal/abo/my_abo.blade.php` mit der jeweiligen `retryAction`-Route. Beide Detail-Views zeigen zusätzlich nun auch `alert-success` an. +- **Tests (4.6):** + - `tests/Feature/AboMakeOrderOneTimeTest.php` (6 Tests, vollständiges End-to-End-Fixture mit `me`-Abo, Stammkunde, Referenz-Bestellung, Versandland): `is_abo_addon` korrekt gesetzt; kombinierter Zahlbetrag > reiner `amount`; nur bestätigte Artikel landen in der Bestellung; Purge entfernt **alle** Einmal-Artikel (inkl. soft-deleted); Comp-Reduktion nach Purge; reine Bestellerstellung löscht **keine** Einmal-Artikel (Beleg „behalten bei Fehler"). + - `tests/Feature/AboUserRetryTest.php` (2 Tests): User-Retry für eigenes Abo ruft den Service auf und leitet zur Detailseite zurück; fremdes Abo → 403 (Service wird nicht aufgerufen). +- **Pint:** ausgeführt (`--dirty`), pass. +- **Bekannte, gewollte Abweichung:** Der Test `AboOneTimeWindowTest > isOneTimeFeatureVisible außerhalb des Fensters → false` schlägt aktuell fehl, weil die Fensterprüfung in `AboHelper::isOneTimeFeatureVisible()` (Zeilen 224–226) für die Review-Phase **bewusst auskommentiert** bleibt (siehe Review-Gate). Wird beim Zurücksetzen auf reines `isOneTimeWindowOpen()` automatisch wieder grün. --- @@ -387,3 +406,6 @@ Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automa | 2026-06-05 | UI/Summen | Abo-Split-Liste zeigt neben der Abo-Zwischensumme auch die Endsumme der nächsten Lieferung; kombinierte Summen-Card optisch hervorgehoben; letzte Prüfung: 8 View-Tests / 28 Assertions grün | ERLEDIGT | | 2026-06-08 | Onetime/Verbindlichkeit | **Nur Bestätigtes ist verbindlich:** `AboOrderCart::addOneTimeItemsToYard()` lädt nur noch verbindlich bestätigte Einmal-Artikel (`confirmed_at` gesetzt **und** `confirmed_qty === qty`) → Liefer-/Abbuchungsbetrag, Versand, Gewicht und Comp-Produkte basieren ausschließlich auf bestätigten Artikeln (greift automatisch in Phase 4). Unbestätigte Artikel bleiben als offener Entwurf erhalten (Entscheidung „keep_pending"). Neue Service-Helfer `confirmedItems()`/`pendingItems()`/`pendingGross()`. View `_order_onetime_show` zeigt Status-Badge je Position (Bestätigt/Noch zu bestätigen), getrennte Zwischensummen (bestätigt vs. offen) + Hinweis, dass bereits bestätigte Produkte unabhängig versandt werden. 8 neue/angepasste Tests (51 gesamt grün). **Hinweis:** Die in `isOneTimeFeatureVisible()` zwischenzeitlich auskommentierte Fensterprüfung wurde wiederhergestellt | ERLEDIGT | | 2026-06-08 | Review-Gate | **Temporäre Live-Sichtbarkeit für Abstimmung:** Einmalprodukte-Feature über `AboHelper::isOneTimeFeatureVisible()` nur für VIP-User (`isVIP()` = `admin>=1`) im Sales Center sichtbar; Endkunden-Portal (customers-Guard, kein VIP) komplett ausgeblendet. Gates in `User\AboController` (detail/update/oneTime/oneTimeDatatable), `Portal\AboController` (myAbo/update/oneTime/oneTimeDatatable/modalLoad), `ModalController` (abo-add-onetime). Reversibel: Methode später auf reines `isOneTimeWindowOpen()` zurücksetzen + Portal reaktivieren. 4 neue Tests (47 gesamt grün) | ERLEDIGT | +| 2026-06-08 | 4 (Schritt 1) | **Ausführung – Einmal-Artikel in die Bestellung:** `UserMakeOrder::makeShoppingOrder()` lädt nach `makeOrderYard()` zusätzlich `AboOrderCart::addOneTimeItemsToYard()` (nur verbindlich bestätigte Artikel); `ShoppingOrderItem` erhält `is_abo_addon` aus `options->abo_addon`. Reihenfolge sichert: `user_abos.amount` bleibt der **reine** Abo-Betrag, der Abbuchungsbetrag der Bestellung ist kombiniert (Abo + Einmal). 3 neue Tests (`AboMakeOrderOneTimeTest`) | ERLEDIGT | +| 2026-06-08 | 4 (Schritt 2) | **Purge & Comp-Reset nur bei Erfolg:** Neue Methode `AboOneTimeService::purgeAfterExecution()` löscht nach erfolgreicher Ausführung **alle** Einmal-Artikel des Abos (bestätigt + offen + soft-deleted, `forceDelete`) und bewertet anschließend die Kompensationsprodukte des reinen Abos neu (Option A: `initYard`+`makeOrderYard`+`checkNumOfCompProducts`). Aufruf im Erfolgszweig von `UserMakeAboOrder::updateAbo()` (Cron) und `AboRetryPaymentService::markAboSuccess()`. Bei Zahlungsfehler bleiben die Einmal-Artikel erhalten. Fehler beim Purge rollen den erfolgreichen Lauf nicht zurück (nur Logging). 3 neue Tests (Purge inkl. soft-deleted, Comp-Reduktion, „behalten bei Fehler") | ERLEDIGT | +| 2026-06-08 | 4 (Schritt 3) | **User-Retry (Sales Center + Portal):** Neue Methoden `User\AboController::retryPayment($view,$id)` (Permission via `checkPermissions`) und `Portal\AboController::retryPayment($id)` (Permission via `checkPortalPermission`), delegieren an `AboRetryPaymentService::retry()` (nutzt denselben `makeShoppingOrder`-Pfad → Einmal-Artikel automatisch enthalten). Routen `user_abos_retry_payment` (CRM) und `portal_abos_retry_payment` (Portal). Gemeinsames Confirm-Modal `_retry_payment_modal` (nur bei `status===3 && active`) in `user/abo/detail` und `portal/abo/my_abo` inkl. Erfolgsmeldung. 2 neue Tests (Erfolg + 403 bei fremdem Abo). **Hinweis:** Ein bestehender Test (`isOneTimeFeatureVisible außerhalb des Fensters → false`) schlägt erwartungsgemäß fehl, da die Fensterprüfung für die Review-Phase bewusst auskommentiert bleibt | ERLEDIGT | diff --git a/resources/views/admin/abo/_retry_payment_modal.blade.php b/resources/views/admin/abo/_retry_payment_modal.blade.php new file mode 100644 index 0000000..94572f7 --- /dev/null +++ b/resources/views/admin/abo/_retry_payment_modal.blade.php @@ -0,0 +1,49 @@ +@if ($user_abo->status === 3 && $user_abo->active) +