Abo Einmalprodukte: Phase 4 - Ausfuehrung, Purge & User-Retry

- UserMakeOrder: bestaetigte Einmal-Artikel in den Yard, is_abo_addon
  auf ShoppingOrderItem; amount bleibt reiner Abo-Betrag (Reihenfolge)
- AboOneTimeService::purgeAfterExecution: loescht alle Einmal-Artikel
  und rechnet Comp-Produkte neu - nur im Erfolgszweig (Cron + Retry)
- User-Retry in Sales Center und Portal mit Berechtigungspruefung,
  gemeinsames Confirm-Modal; Admin-Retry unveraendert
- Tests: AboMakeOrderOneTimeTest, AboUserRetryTest; Plan-Doku Phase 4

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-08 15:32:27 +00:00
parent 8288ea59ac
commit ee04146217
14 changed files with 536 additions and 19 deletions

View file

@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Models\UserAboOrder; use App\Models\UserAboOrder;
use App\Services\AboHelper; use App\Services\AboHelper;
use App\Services\AboOneTimeService;
use App\Services\Incentive\IncentiveTracker; use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog; use App\Services\MyLog;
use App\Services\Payment; use App\Services\Payment;
@ -74,7 +75,7 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), '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; return 1;
} }
@ -164,7 +165,7 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), '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) private function makeOrder($userAbo)
{ {
\Log::channel('abo_order')->info('UserMakeAboOrder: Starte Bestellungserstellung', ['abo_id' => $userAbo->id]); \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; $shoppingOrder = null;
$userOrder = new UserMakeOrder($userAbo); $userOrder = new UserMakeOrder($userAbo);
@ -205,7 +206,7 @@ class UserMakeAboOrder extends Command
]); ]);
$response = $userOrder->makePayment(); $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) // Prüfe ob Response ein Array ist (kann auch Objekt sein)
if (is_object($response)) { if (is_object($response)) {
@ -274,7 +275,7 @@ class UserMakeAboOrder extends Command
'status' => $response['status'], 'status' => $response['status'],
]); ]);
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$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 { } else {
// Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren // Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [ \Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
@ -283,7 +284,7 @@ class UserMakeAboOrder extends Command
'status' => $response['status'], 'status' => $response['status'],
]); ]);
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$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) { } catch (\Throwable $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [ \Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
@ -291,11 +292,11 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), '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 // Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar
if ($shoppingOrder) { if ($shoppingOrder) {
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage()); $this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: '.$e->getMessage());
return $shoppingOrder; return $shoppingOrder;
} }
@ -356,12 +357,15 @@ class UserMakeAboOrder extends Command
// Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief // Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief
// (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt) // (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt)
IncentiveTracker::trackAboActivated($shoppingOrder); IncentiveTracker::trackAboActivated($shoppingOrder);
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
AboOneTimeService::purgeAfterExecution($userAbo);
} catch (\Exception $e) { } catch (\Exception $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [ \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
'abo_id' => $userAbo->id, 'abo_id' => $userAbo->id,
'error' => $e->getMessage(), '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 throw $e; // Re-throw für besseres Error-Handling
} }
} }
@ -421,7 +425,7 @@ class UserMakeAboOrder extends Command
'abo_id' => $userAbo->id, 'abo_id' => $userAbo->id,
'error' => $e->getMessage(), '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 // Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann
} }
} }
@ -437,6 +441,6 @@ class UserMakeAboOrder extends Command
$sec = intval($diff); $sec = intval($diff);
$micro = $diff - $sec; $micro = $diff - $sec;
return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms'; return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
} }
} }

View file

@ -177,8 +177,13 @@ class UserMakeOrder
} }
// hier wird die Bestellung erstellt inkl aktueller Preise // hier wird die Bestellung erstellt inkl aktueller Preise
// (setzt user_abos.amount auf den REINEN Abo-Betrag)
AboOrderCart::makeOrderYard($this->userAbo); 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); $yard = Yard::instance(AboOrderCart::INSTANCE);
// Debug: Logge welche Produkte im Cart sind // Debug: Logge welche Produkte im Cart sind
@ -268,6 +273,7 @@ class UserMakeOrder
'row_id' => $item->rowId, 'row_id' => $item->rowId,
'product_id' => $item->id, 'product_id' => $item->id,
'comp' => $item->options->comp, 'comp' => $item->options->comp,
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
'qty' => $item->qty, 'qty' => $item->qty,
'price' => $item->price, 'price' => $item->price,
'price_net' => $price_net, 'price_net' => $price_net,

View file

@ -15,6 +15,7 @@ use App\Services\AboHelper;
use App\Services\AboItemHistoryService; use App\Services\AboItemHistoryService;
use App\Services\AboOneTimeService; use App\Services\AboOneTimeService;
use App\Services\AboOrderCart; use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\Shop; use App\Services\Shop;
use App\Services\UserService; use App\Services\UserService;
use App\Services\Util; use App\Services\Util;
@ -491,6 +492,18 @@ class AboController extends Controller
return false; 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) private function checkPortalPermission($user_abo)
{ {
$user = Auth::guard('customers')->user(); $user = Auth::guard('customers')->user();

View file

@ -12,6 +12,7 @@ use App\Repositories\AboRepository;
use App\Services\AboHelper; use App\Services\AboHelper;
use App\Services\AboItemHistoryService; use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart; use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\Shop; use App\Services\Shop;
use App\User; use App\User;
use Request; use Request;
@ -507,6 +508,18 @@ class AboController extends Controller
->make(true); ->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) private function checkPermissions($view, $user_abo)
{ {
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]); \Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);

View file

@ -192,6 +192,44 @@ class AboOneTimeService
->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2); ->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 public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
{ {
return UserAboOneTimeItem::withTrashed() return UserAboOneTimeItem::withTrashed()

View file

@ -157,6 +157,9 @@ class AboRetryPaymentService
'error' => $e->getMessage(), '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 private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void

View file

@ -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. - [x] 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). - [x] 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). - [x] 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. - [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.
- [ ] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück). - [x] 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.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 224226) 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-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 | 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 | 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 |

View file

@ -0,0 +1,49 @@
@if ($user_abo->status === 3 && $user_abo->active)
<div class="text-right mt-3">
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#modal-retry-abo-payment">
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
</button>
</div>
<div class="modal fade" id="modal-retry-abo-payment" tabindex="-1" role="dialog" aria-labelledby="modal-retry-abo-payment-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
{!! Form::open(['action' => $retryAction]) !!}
<div class="modal-header">
<h5 class="modal-title" id="modal-retry-abo-payment-label">{{ __('abo.retry_payment') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>{{ __('abo.retry_payment_confirm_title') }}</strong><br>
{{ __('abo.retry_payment_confirm_copy') }}
</div>
<table class="table table-sm mb-0">
<tr>
<td class="font-weight-bold">{{ __('navigation.abo') }}:</td>
<td>#{{ $user_abo->id }}</td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.payment') }}:</td>
<td>{{ $user_abo->getPaymentType() }}</td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.amount') }}:</td>
<td>{{ $user_abo->getFormattedAmount() }} </td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.next_date') }}:</td>
<td>{{ $user_abo->next_date }}</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit" class="btn btn-warning">{{ __('abo.retry_payment_confirm_button') }}</button>
</div>
{!! Form::close() !!}
</div>
</div>
</div>
@endif

View file

@ -7,6 +7,15 @@
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span> <span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span>
</h4> </h4>
@if (Session::has('alert-success'))
<div class="col-sm-12">
<div class="alert alert-success p-2 mt-2">
<ul>
<li>{{ Session::get('alert-success') }}</li>
</ul>
</div>
</div>
@endif
@if (Session::has('alert-error')) @if (Session::has('alert-error'))
<div class="col-sm-12"> <div class="col-sm-12">
<div class="alert alert-danger p-2 mt-2"> <div class="alert alert-danger p-2 mt-2">
@ -90,6 +99,7 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@include('admin.abo._retry_payment_modal', ['retryAction' => route('portal_abos_retry_payment', [$user_abo->id])])
<a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a> <a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a>

View file

@ -16,6 +16,15 @@
</h4> </h4>
@if (Session::has('alert-success'))
<div class="col-sm-12">
<div class="alert alert-success p-2 mt-2">
<ul>
<li>{{ Session::get('alert-success') }}</li>
</ul>
</div>
</div>
@endif
@if (Session::has('alert-error')) @if (Session::has('alert-error'))
<div class="col-sm-12"> <div class="col-sm-12">
<div class="alert alert-danger p-2 mt-2"> <div class="alert alert-danger p-2 mt-2">
@ -107,6 +116,7 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])])
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a> <a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>

View file

@ -161,6 +161,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail'); Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail');
Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update'); Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update');
Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime'); Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime');
Route::post('/user/abos/retry-payment/{view}/{id}', 'User\AboController@retryPayment')->name('user_abos_retry_payment');
Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable'); Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable');
Route::get('/user/abo/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable'); Route::get('/user/abo/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable');
// Route to show team subscriptions (Abos) // Route to show team subscriptions (Abos)

View file

@ -49,6 +49,7 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create'); Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create');
Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update'); Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update');
Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime'); Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime');
Route::post('portal/my-subscriptions/retry-payment/{id}', [AboController::class, 'retryPayment'])->name('portal_abos_retry_payment');
Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable'); Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable');
Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable'); Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable');
Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load'); Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load');

View file

@ -0,0 +1,269 @@
<?php
use App\Cron\UserMakeOrder;
use App\Models\Country;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOneTimeItem;
use App\Models\UserAccount;
use App\Models\UserShop;
use App\Services\AboOneTimeService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
/**
* Baut ein vollständiges Berater-Abo ('me') inklusive Stammkunde, Referenz-Bestellung,
* Versandland, einem regulären Abo-Artikel und einem verbindlich bestätigten Einmal-Artikel.
*
* @return array{abo: UserAbo, aboProductId: int, oneTimeProductId: int}
*/
function makeMakeOrderFixture(): array
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'Standard', 'active' => true]);
ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
$account = UserAccount::create([
'salutation' => 'Herr',
'first_name' => 'Max',
'last_name' => 'Muster',
'address' => 'Musterstr. 1',
'zipcode' => '12345',
'city' => 'Musterstadt',
'country_id' => $country->id,
'phone' => '123456',
'same_as_billing' => 1,
'language' => 'de',
]);
$user = User::forceCreate([
'email' => 'consultant-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'account_id' => $account->id,
]);
$userShop = UserShop::create([
'user_id' => $user->id,
'name' => 'TS'.substr(uniqid('', true), 0, 8),
'slug' => 'ts-'.uniqid(),
'active' => true,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'member_id' => $user->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => $user->email,
'shipping_email' => $user->email,
'billing_firstname' => 'Max',
'billing_lastname' => 'Muster',
'shipping_firstname' => 'Max',
'shipping_lastname' => 'Muster',
'same_as_billing' => 1,
'is_for' => 'me',
'is_from' => 'user_order',
]);
ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'member_id' => $user->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'payment_for' => 3,
'total' => 100,
'subtotal' => 90,
'total_shipping' => 100,
'paid' => true,
'is_abo' => true,
'txaction' => 'paid',
'mode' => 'test',
]);
$abo = UserAbo::create([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => 900200,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->toDateString(),
]);
$aboProduct = makeProduct(['12'], 119, 19);
$oneTimeProduct = makeProduct(['2'], 50, 19);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $aboProduct->id,
'qty' => 1,
'comp' => 0,
'status' => 1,
]);
UserAboOneTimeItem::create([
'user_abo_id' => $abo->id,
'product_id' => $oneTimeProduct->id,
'qty' => 2,
'confirmed_qty' => 2,
'confirmed_at' => now(),
'price' => 50.0,
'price_net' => 42.017,
'tax_rate' => 19.0,
'tax' => 7.983,
'points' => 7,
'status' => 1,
]);
return [
'abo' => $abo->fresh(),
'aboProductId' => $aboProduct->id,
'oneTimeProductId' => $oneTimeProduct->id,
];
}
it('schreibt verbindlich bestätigte Einmal-Artikel mit is_abo_addon in die Bestellung', function () {
$fixture = makeMakeOrderFixture();
$userMakeOrder = new UserMakeOrder($fixture['abo']);
$userMakeOrder->createShoppingUser();
$order = $userMakeOrder->makeShoppingOrder();
expect($order)->not->toBeFalse();
$aboItem = $order->shopping_order_items()
->where('product_id', $fixture['aboProductId'])
->first();
$addonItem = $order->shopping_order_items()
->where('product_id', $fixture['oneTimeProductId'])
->first();
expect($aboItem)->not->toBeNull()
->and((bool) $aboItem->is_abo_addon)->toBeFalse()
->and($addonItem)->not->toBeNull()
->and((bool) $addonItem->is_abo_addon)->toBeTrue()
->and((int) $addonItem->qty)->toBe(2);
});
it('lässt user_abos.amount der reine Abo-Betrag (ohne Einmal-Artikel)', function () {
$fixture = makeMakeOrderFixture();
$userMakeOrder = new UserMakeOrder($fixture['abo']);
$userMakeOrder->createShoppingUser();
$order = $userMakeOrder->makeShoppingOrder();
$pureAboAmount = (float) $fixture['abo']->fresh()->amount; // Cent, nur Abo
$combinedTotal = (float) $order->total_shipping * 100; // Cent, inkl. Einmal-Artikel
// Der kombinierte Abbuchungsbetrag enthält die Einmal-Artikel, der gespeicherte
// Abo-Betrag jedoch nicht -> kombiniert ist größer.
expect($combinedTotal)->toBeGreaterThan($pureAboAmount);
});
it('nimmt nur bestätigte Einmal-Artikel auf, offene Entwürfe nicht', function () {
$fixture = makeMakeOrderFixture();
$abo = $fixture['abo'];
// Zusätzlicher, NICHT bestätigter Einmal-Artikel
$pendingProduct = makeProduct(['2'], 30, 19);
UserAboOneTimeItem::create([
'user_abo_id' => $abo->id,
'product_id' => $pendingProduct->id,
'qty' => 1,
'price' => 30.0,
'tax_rate' => 19.0,
'status' => 0,
]);
$userMakeOrder = new UserMakeOrder($abo);
$userMakeOrder->createShoppingUser();
$order = $userMakeOrder->makeShoppingOrder();
expect($order->shopping_order_items()->where('product_id', $pendingProduct->id)->exists())->toBeFalse()
->and($order->shopping_order_items()->where('product_id', $fixture['oneTimeProductId'])->exists())->toBeTrue();
});
it('entfernt beim Purge ALLE Einmal-Artikel (bestätigt, offen und soft-deleted)', function () {
$fixture = makeMakeOrderFixture();
$abo = $fixture['abo'];
// zusätzlich ein offener und ein soft-deleted Artikel
$pending = makeProduct(['2'], 30, 19);
UserAboOneTimeItem::create([
'user_abo_id' => $abo->id,
'product_id' => $pending->id,
'qty' => 1,
'price' => 30.0,
'tax_rate' => 19.0,
'status' => 0,
]);
$trashed = makeProduct(['2'], 20, 19);
$trashedItem = UserAboOneTimeItem::create([
'user_abo_id' => $abo->id,
'product_id' => $trashed->id,
'qty' => 1,
'confirmed_qty' => 1,
'confirmed_at' => now(),
'price' => 20.0,
'tax_rate' => 19.0,
'status' => 1,
]);
$trashedItem->delete();
expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(3);
AboOneTimeService::purgeAfterExecution($abo);
expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
});
it('reduziert nach dem Purge nicht mehr benötigte Kompensationsprodukte', function () {
$fixture = makeMakeOrderFixture();
$abo = $fixture['abo'];
// Ein Comp-Abo-Artikel, der nach Wegfall der Einmal-Artikel nicht mehr nötig ist.
$compProduct = makeProduct(['12'], 0, 0);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $compProduct->id,
'comp' => 1,
'qty' => 1,
'status' => 1,
]);
expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(1);
AboOneTimeService::purgeAfterExecution($abo);
expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(0)
->and(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
});
it('entfernt durch reine Bestellerstellung KEINE Einmal-Artikel (behalten bei Fehler)', function () {
$fixture = makeMakeOrderFixture();
$abo = $fixture['abo'];
$userMakeOrder = new UserMakeOrder($abo);
$userMakeOrder->createShoppingUser();
$userMakeOrder->makeShoppingOrder();
// makeShoppingOrder läuft sowohl im Erfolgs- als auch im Fehlerfall; der Purge
// erfolgt ausschließlich separat im Erfolgszweig. Daher bleiben die Artikel hier erhalten.
expect(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(1);
});

View file

@ -0,0 +1,78 @@
<?php
use App\Http\Controllers\User\AboController;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboRetryPaymentService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\HttpException;
use function Pest\Laravel\mock;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeRetryOwnerAbo(): array
{
$owner = User::forceCreate([
'email' => 'owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$abo = UserAbo::create([
'user_id' => $owner->id,
'member_id' => $owner->id,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => $owner->email,
'payone_userid' => 900300,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'abo_interval' => 5,
'next_date' => now()->toDateString(),
]);
return ['owner' => $owner, 'abo' => $abo];
}
it('führt den User-Retry für den eigenen Abo aus und leitet zur Detailseite zurück', function () {
// Domain-Routen werden hostabhängig geladen; Stub für den Redirect der Methode.
Route::get('/user/abos/detail/{view}/{id}', fn () => '')->name('user_abos_detail');
app('router')->getRoutes()->refreshNameLookups();
$fixture = makeRetryOwnerAbo();
$this->actingAs($fixture['owner']);
mock(AboRetryPaymentService::class)
->shouldReceive('retry')
->once()
->andReturn(['success' => true, 'message' => 'OK']);
$controller = new AboController(app(AboRepository::class));
$response = $controller->retryPayment('me', $fixture['abo']->id, app(AboRetryPaymentService::class));
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
expect(session('alert-success'))->toBe('OK');
});
it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
$fixture = makeRetryOwnerAbo();
$stranger = User::forceCreate([
'email' => 'stranger-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$this->actingAs($stranger);
$service = mock(AboRetryPaymentService::class);
$service->shouldNotReceive('retry');
$controller = new AboController(app(AboRepository::class));
expect(fn () => $controller->retryPayment('me', $fixture['abo']->id, $service))
->toThrow(HttpException::class);
});