diff --git a/app/Console/Commands/UserMakeAboOrder.php b/app/Console/Commands/UserMakeAboOrder.php index d005d87..0f9805f 100644 --- a/app/Console/Commands/UserMakeAboOrder.php +++ b/app/Console/Commands/UserMakeAboOrder.php @@ -6,7 +6,6 @@ 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; @@ -75,7 +74,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; } @@ -165,7 +164,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()); } } } @@ -179,7 +178,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); @@ -206,7 +205,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)) { @@ -275,7 +274,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', [ @@ -284,7 +283,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', [ @@ -292,11 +291,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; } @@ -357,15 +356,12 @@ 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 } } @@ -425,7 +421,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 } } @@ -441,6 +437,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 8defbdb..685826a 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -177,13 +177,8 @@ 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 @@ -273,7 +268,6 @@ 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/ModalController.php b/app/Http/Controllers/ModalController.php index 0cb97aa..d2bc34a 100644 --- a/app/Http/Controllers/ModalController.php +++ b/app/Http/Controllers/ModalController.php @@ -11,7 +11,6 @@ use App\Models\UserAbo; use App\Models\UserCredit; use App\Models\UserLevel; use App\Models\UserSalesVolume; -use App\Services\AboHelper; use App\Services\BusinessPlan\TreeCalcBot; use App\Services\BusinessPlan\TreeCalcBotOptimized; use App\Services\DhlModalService; @@ -173,7 +172,6 @@ class ModalController extends Controller } if ($data['action'] === 'abo-add-onetime') { $user_abo = UserAbo::find($data['id']); - abort_unless($user_abo && AboHelper::isOneTimeFeatureVisible($user_abo), 403); $ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render(); } diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index 00c7c3e..8769456 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -15,7 +15,6 @@ 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; @@ -74,7 +73,7 @@ class AboController extends Controller AboOrderCart::makeOrderYard($user_abo); $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); - $oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo); + $oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo); if ($oneTimeWindowOpen) { AboOrderCart::addOneTimeItemsToYard($user_abo); AboOrderCart::checkNumOfCompProducts($user_abo); @@ -212,7 +211,7 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); - if (AboHelper::isOneTimeFeatureVisible($user_abo)) { + if (AboHelper::isOneTimeWindowOpen($user_abo)) { AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); } AboOrderCart::checkNumOfCompProducts($user_abo); @@ -222,7 +221,7 @@ class AboController extends Controller $html_cart = view('admin.abo._order_abo_show', [ 'user_abo' => $user_abo, 'error_message' => $error_message, - 'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo), + 'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo), 'summary' => $summary, 'add_only_mode' => $isAddOnlyMode, ])->render(); @@ -326,7 +325,6 @@ class AboController extends Controller if ($data['action'] === 'abo-add-onetime') { $user_abo = UserAbo::find($data['id']); $this->checkPortalPermission($user_abo); - abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403); $ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render(); } if ($data['action'] === 'abo_update_settings') { @@ -354,7 +352,7 @@ class AboController extends Controller $this->checkPortalPermission($user_abo); $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view); - if (! AboHelper::isOneTimeFeatureVisible($user_abo)) { + if (! AboHelper::isOneTimeWindowOpen($user_abo)) { return response()->json([ 'response' => false, 'message' => __('abo.onetime_window_closed'), @@ -406,8 +404,6 @@ class AboController extends Controller $user_abo = UserAbo::findOrFail($user_abo_id); $this->checkPortalPermission($user_abo); - abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403); - AboOrderCart::initYard($user_abo); $query = Product::select('products.*') @@ -492,18 +488,6 @@ 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 0455522..68dfa7c 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -12,7 +12,6 @@ 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; @@ -90,7 +89,7 @@ class AboController extends Controller AboOrderCart::makeOrderYard($user_abo); $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); - $oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo); + $oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo); if ($oneTimeWindowOpen) { AboOrderCart::addOneTimeItemsToYard($user_abo); AboOrderCart::checkNumOfCompProducts($user_abo); @@ -234,7 +233,7 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); - if (AboHelper::isOneTimeFeatureVisible($user_abo)) { + if (AboHelper::isOneTimeWindowOpen($user_abo)) { AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); } AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product @@ -247,7 +246,7 @@ class AboController extends Controller $html_cart = view('admin.abo._order_abo_show', [ 'user_abo' => $user_abo, 'error_message' => $error_message, - 'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo), + 'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo), 'summary' => $summary, 'add_only_mode' => $isAddOnlyMode, ])->render(); @@ -274,7 +273,7 @@ class AboController extends Controller $editView = \Auth::user()?->isAdmin() ? 'admin' : $view; $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView); - if (! AboHelper::isOneTimeFeatureVisible($user_abo)) { + if (! AboHelper::isOneTimeWindowOpen($user_abo)) { return response()->json([ 'response' => false, 'message' => __('abo.onetime_window_closed'), @@ -449,8 +448,6 @@ class AboController extends Controller { $user_abo = UserAbo::findOrFail($user_abo_id); - abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403); - AboOrderCart::initYard($user_abo); $show_on_ids = $user_abo->is_for === 'me' ? ['2'] : ['3']; @@ -508,18 +505,6 @@ 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/AboHelper.php b/app/Services/AboHelper.php index cedc2ca..f2953b2 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -157,10 +157,10 @@ class AboHelper public static function getAboTypeBadge($abo_type) { if ($abo_type === 'base') { - return ' ' . __('abo.' . $abo_type) . ''; + return ' '.__('abo.'.$abo_type).''; } if ($abo_type === 'upgrade') { - return ' ' . __('abo.' . $abo_type) . ''; + return ' '.__('abo.'.$abo_type).''; } return ''; @@ -209,27 +209,6 @@ class AboHelper return $daysUntilExecution <= self::getOneTimeWindowDays(); } - /** - * Sichtbarkeit des Einmalprodukte-Features während der Live-Abstimmung. - * - * Temporäre Einschränkung: Das Feature wird vorerst nur VIP-Usern (Admins) - * im Sales Center angezeigt. Das Endkunden-Portal läuft über den - * `customers`-Guard und hat keinen VIP-User auf dem `user`-Guard, daher - * bleibt das Feature dort vollständig ausgeblendet. Nach Abschluss der - * Abstimmung kann diese Methode wieder auf reines isOneTimeWindowOpen() - * zurückgesetzt werden, um das Feature für alle freizuschalten. - */ - public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool - { - /* if (! self::isOneTimeWindowOpen($userAbo)) { - return false; - }*/ - - $user = \Auth::guard('user')->user(); - - return $user instanceof User && $user->isVIP(); - } - public static function getFirstAboDate($date, $abo_interval) { $reference = Carbon::parse($date)->startOfDay(); @@ -412,7 +391,7 @@ class AboHelper { $ret = []; foreach (self::$txaction_filter_text as $key => $val) { - $ret[$key] = trans('payment.' . $val); + $ret[$key] = trans('payment.'.$val); } return $ret; diff --git a/app/Services/AboOneTimeService.php b/app/Services/AboOneTimeService.php index 2b43e5a..57c97db 100644 --- a/app/Services/AboOneTimeService.php +++ b/app/Services/AboOneTimeService.php @@ -158,78 +158,6 @@ class AboOneTimeService return null; } - /** - * Verbindlich bestätigte Einmal-Artikel (werden mit der nächsten Lieferung versandt). - * - * @return \Illuminate\Support\Collection - */ - public static function confirmedItems(UserAbo $userAbo): \Illuminate\Support\Collection - { - return $userAbo->one_time_items() - ->with('product') - ->get() - ->filter(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed()) - ->values(); - } - - /** - * Noch nicht bestätigte Einmal-Artikel (Entwurf, nicht verbindlich). - * - * @return \Illuminate\Support\Collection - */ - public static function pendingItems(UserAbo $userAbo): \Illuminate\Support\Collection - { - return $userAbo->one_time_items() - ->with('product') - ->get() - ->reject(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed()) - ->values(); - } - - public static function pendingGross(UserAbo $userAbo): float - { - return round(self::pendingItems($userAbo) - ->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/AboOrderCart.php b/app/Services/AboOrderCart.php index b81c43a..e78a9ea 100644 --- a/app/Services/AboOrderCart.php +++ b/app/Services/AboOrderCart.php @@ -246,14 +246,7 @@ class AboOrderCart $yard = Yard::instance(self::INSTANCE); - // Nur verbindlich bestätigte Einmal-Artikel fließen in die nächste Lieferung - // (Snapshot-Menge == bestätigte Menge). Unbestätigte Entwürfe bleiben außen vor. - $confirmedItems = $user_abo->one_time_items() - ->whereNotNull('confirmed_at') - ->whereColumn('confirmed_qty', 'qty') - ->get(); - - foreach ($confirmedItems as $item) { + foreach ($user_abo->one_time_items()->get() as $item) { $product = Product::find($item->product_id); if (! $product) { continue; diff --git a/app/Services/AboRetryPaymentService.php b/app/Services/AboRetryPaymentService.php index 0057e8c..1fbeb7e 100644 --- a/app/Services/AboRetryPaymentService.php +++ b/app/Services/AboRetryPaymentService.php @@ -157,9 +157,6 @@ 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 33e8560..7a49bf5 100644 --- a/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md +++ b/dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md @@ -230,35 +230,16 @@ Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo --- -### Phase 4 – Ausführung, Retry & Aufräumen · `ERLEDIGT (2026-06-08)` +### Phase 4 – Ausführung, Retry & Aufräumen · `OFFEN` -- [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. +- [ ] 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. -**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. +**Doku:** _(nach Umsetzung ausfüllen)_ --- @@ -404,8 +385,3 @@ Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automa | 2026-06-05 | UI/Consent | Rechtssicheres Modal für dauerhafte Abo-Mengenerhöhungen zentralisiert (`_confirm_add_modal`): Artikel-/Mengenänderung, Zusatzkosten, neuer Gesamtbetrag, Lieferdatum, AGB/Widerruf, Button „Zahlungspflichtig aktualisieren" (9.13) | ERLEDIGT | | 2026-06-05 | UI/Onetime | Confirm-/Discard-State für Einmalprodukte: `confirmed_qty`/`confirmed_at`, Aktionen `confirm`/`discard`, rechtliche Bestätigungsfläche, Erfolgshinweis nach Speichern und Rücksprung auf ungespeichert bei späteren Änderungen | 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 | 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/lang/de/abo.php b/resources/lang/de/abo.php index 18b4e71..4ebce2d 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -114,12 +114,6 @@ return [ 'onetime_reset_hint' => 'Einmalige Produkte werden nur deiner nächsten Lieferung beigelegt und danach automatisch wieder aus dem Abo entfernt.', 'add_onetime_product' => 'Einmaliges Produkt hinzufügen', 'onetime_subtotal' => 'Zwischensumme einmalige Produkte', - 'onetime_confirmed_subtotal' => 'Zwischensumme bestätigt (wird versandt)', - 'onetime_pending_subtotal' => 'Noch zu bestätigen (nicht verbindlich)', - 'onetime_new_total' => 'Neue Gesamtsumme nach Bestätigung', - 'onetime_status_confirmed' => 'Bestätigt', - 'onetime_status_pending' => 'Noch zu bestätigen', - 'onetime_pending_hint' => 'Bereits bestätigte Produkte werden mit der nächsten Lieferung versandt – unabhängig davon, ob du diese neuen Produkte bestätigst.', 'onetime_next_delivery_total' => 'Gesamtbetrag der nächsten Lieferung (Abo + Einmalig)', 'onetime_legal_notice' => 'Die hinzugefügten Produkte werden einmalig am :nextBillingDate versendet. Es gelten unsere :agb und die :withdrawal.', 'onetime_discard_changes' => 'Änderungen verwerfen', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 1fbcf02..c354a6b 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -105,12 +105,6 @@ return [ 'onetime_reset_hint' => 'One-time products are added only to your next delivery and are automatically removed from the subscription afterwards.', 'add_onetime_product' => 'Add one-time product', 'onetime_subtotal' => 'Subtotal one-time products', - 'onetime_confirmed_subtotal' => 'Subtotal confirmed (will be shipped)', - 'onetime_pending_subtotal' => 'Pending confirmation (not binding)', - 'onetime_new_total' => 'New total after confirmation', - 'onetime_status_confirmed' => 'Confirmed', - 'onetime_status_pending' => 'To be confirmed', - 'onetime_pending_hint' => 'Products you have already confirmed will be shipped with your next delivery, regardless of whether you confirm these new products.', 'onetime_next_delivery_total' => 'Total amount of next delivery (subscription + one-time)', 'onetime_legal_notice' => 'The added products will be shipped once on :nextBillingDate. Our :agb and :withdrawal apply.', 'onetime_discard_changes' => 'Discard changes', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index e9a6aad..3b5d27b 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -105,12 +105,6 @@ return [ 'onetime_reset_hint' => 'Los productos únicos solo se añaden a su próxima entrega y después se eliminan automáticamente de la suscripción.', 'add_onetime_product' => 'Añadir producto único', 'onetime_subtotal' => 'Subtotal productos únicos', - 'onetime_confirmed_subtotal' => 'Subtotal confirmado (se enviará)', - 'onetime_pending_subtotal' => 'Pendiente de confirmar (no vinculante)', - 'onetime_new_total' => 'Nuevo total tras la confirmación', - 'onetime_status_confirmed' => 'Confirmado', - 'onetime_status_pending' => 'Por confirmar', - 'onetime_pending_hint' => 'Los productos que ya has confirmado se enviarán con tu próxima entrega, independientemente de si confirmas estos nuevos productos.', 'onetime_next_delivery_total' => 'Importe total de la próxima entrega (suscripción + único)', 'onetime_legal_notice' => 'Los productos añadidos se enviarán una sola vez el :nextBillingDate. Se aplican nuestros :agb y el :withdrawal.', 'onetime_discard_changes' => 'Descartar cambios', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index 88a1fd6..53d94c6 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -114,12 +114,6 @@ return [ 'onetime_reset_hint' => 'Les produits ponctuels sont ajoutés uniquement à votre prochaine livraison puis supprimés automatiquement de l\'abonnement.', 'add_onetime_product' => 'Ajouter un produit ponctuel', 'onetime_subtotal' => 'Sous-total produits ponctuels', - 'onetime_confirmed_subtotal' => 'Sous-total confirmé (sera expédié)', - 'onetime_pending_subtotal' => 'À confirmer (non contraignant)', - 'onetime_new_total' => 'Nouveau total après confirmation', - 'onetime_status_confirmed' => 'Confirmé', - 'onetime_status_pending' => 'À confirmer', - 'onetime_pending_hint' => 'Les produits déjà confirmés seront expédiés avec votre prochaine livraison, que vous confirmiez ou non ces nouveaux produits.', 'onetime_next_delivery_total' => 'Montant total de la prochaine livraison (abonnement + ponctuel)', 'onetime_legal_notice' => 'Les produits ajoutés seront expédiés une seule fois le :nextBillingDate. Nos :agb et le :withdrawal s’appliquent.', 'onetime_discard_changes' => 'Annuler les modifications', diff --git a/resources/views/admin/abo/_order_combined_summary.blade.php b/resources/views/admin/abo/_order_combined_summary.blade.php index 0ffba33..b737494 100644 --- a/resources/views/admin/abo/_order_combined_summary.blade.php +++ b/resources/views/admin/abo/_order_combined_summary.blade.php @@ -5,10 +5,10 @@
{{ __('abo.combined_summary_hl') }}
-
+
- @if (($summary['one_time']['gross'] ?? 0) > 0) + @if(($summary['one_time']['gross'] ?? 0) > 0) @@ -20,37 +20,30 @@ - + - + - @if ($taxFree) + @if($taxFree) - + @else - + - + @endif - +
{{ __('abo.onetime_subtotal') }}: {{ formatNumber($summary['one_time']['gross']) }} €
{{ __('Delivery country') }}:{{ Yard::instance($cartInstance)->getShippingCountryName() }} - {{ Yard::instance($cartInstance)->getShippingCountryName() }}
{{ __('order.shipping_costs') }}: - {{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} - €{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} €
{{ __('order.sum_net') }}: - {{ Yard::instance($cartInstance)->subtotalWithShipping() }} €{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €
{{ __('order.total_without_VAT') }}: - {{ Yard::instance($cartInstance)->subtotalWithShipping() }} €{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €
{{ __('order.plus_VAT') }}:{{ Yard::instance($cartInstance)->taxWithShipping() }} € - {{ Yard::instance($cartInstance)->taxWithShipping() }} €
{{ __('order.total_sum') }}: - {{ Yard::instance($cartInstance)->totalWithShipping() }} €{{ Yard::instance($cartInstance)->totalWithShipping() }} €
diff --git a/resources/views/admin/abo/_order_onetime_show.blade.php b/resources/views/admin/abo/_order_onetime_show.blade.php index 376e2e6..ebdc231 100644 --- a/resources/views/admin/abo/_order_onetime_show.blade.php +++ b/resources/views/admin/abo/_order_onetime_show.blade.php @@ -2,13 +2,11 @@ $one_time_items = $user_abo->one_time_items()->with('product')->get(); $hasOneTimeChanges = \App\Services\AboOneTimeService::hasUnconfirmedChanges($user_abo); $hasConfirmedOneTimeItems = \App\Services\AboOneTimeService::hasConfirmedItems($user_abo); - $hasBindingConfirmed = \App\Services\AboOneTimeService::confirmedItems($user_abo)->isNotEmpty(); - $confirmedGross = $summary['one_time']['gross'] ?? 0; - $pendingGross = \App\Services\AboOneTimeService::pendingGross($user_abo); + $oneTimeGross = $summary['one_time']['gross'] ?? 0; $nextDeliveryTotal = $summary['total_with_shipping'] ?? 0; $oneTimeConfirmationState = $hasOneTimeChanges ? 'changed' : ($hasConfirmedOneTimeItems ? 'confirmed' : 'empty'); @endphp -@if (isset($error_message) && $error_message) +@if(isset($error_message) && $error_message)
{{ $error_message }}
@endif
@@ -25,48 +23,30 @@ @forelse($one_time_items as $one_time_item) - @if ($one_time_item->product && count($one_time_item->product->images)) - + @if($one_time_item->product && count($one_time_item->product->images)) + @endif {{ $one_time_item->product?->getLang('name') }} - - {{ __('abo.onetime_badge') }} - @if ($one_time_item->isConfirmed()) - - {{ __('abo.onetime_status_confirmed') }} - @else - - {{ __('abo.onetime_status_pending') }} - @endif + {{ __('abo.onetime_badge') }}
{{ __('order.content') }}: {{ $one_time_item->product?->contents }}
{{ __('order.art_no') }}: {{ $one_time_item->product?->number }}
- + - + - +
@@ -83,67 +63,31 @@ - -
- +
- @if ($hasBindingConfirmed) + + {{ __('abo.onetime_subtotal') }}: + {{ formatNumber($summary['one_time']['gross'] ?? 0) }} € + + @if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems) - - {{ __('abo.onetime_confirmed_subtotal') }}: - - {{ formatNumber($confirmedGross) }} € - - - - {{ __('abo.onetime_next_delivery_total') }}: - + {{ __('abo.onetime_next_delivery_total') }}: {{ formatNumber($nextDeliveryTotal) }} € @endif - @if ($pendingGross > 0) - @if ($hasBindingConfirmed) - - -
- - - @endif - - - {{ __('abo.onetime_pending_subtotal') }}: - - + - {{ formatNumber($pendingGross) }} € - - - - {{ __('abo.onetime_new_total') }}: - - - {{ formatNumber(($hasBindingConfirmed ? $confirmedGross : 0) + $pendingGross) }} € - - @endif
-@if ($one_time_items->count() > 0) -
- +@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems) +

{!! __('abo.onetime_legal_notice', [ 'nextBillingDate' => $user_abo->next_date ?: __('abo.confirm_next_delivery_unknown'), - 'agb' => '' . __('abo.confirm_terms_link') . '', - 'withdrawal' => - '' . - __('abo.confirm_withdrawal_link') . - '', + 'agb' => ''.__('abo.confirm_terms_link').'', + 'withdrawal' => ''.__('abo.confirm_withdrawal_link').'', ]) !!}

- @if ($hasOneTimeChanges) + @if($hasOneTimeChanges)
@endif - @if ($hasOneTimeChanges && $hasConfirmedOneTimeItems) -
-
{{ __('abo.onetime_pending_hint') }}
- @endif
@endif diff --git a/resources/views/admin/abo/_retry_payment_modal.blade.php b/resources/views/admin/abo/_retry_payment_modal.blade.php deleted file mode 100644 index 94572f7..0000000 --- a/resources/views/admin/abo/_retry_payment_modal.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -@if ($user_abo->status === 3 && $user_abo->active) -
- -
- -@endif diff --git a/resources/views/portal/abo/my_abo.blade.php b/resources/views/portal/abo/my_abo.blade.php index e2684c5..a764488 100755 --- a/resources/views/portal/abo/my_abo.blade.php +++ b/resources/views/portal/abo/my_abo.blade.php @@ -7,15 +7,6 @@ {{ '#' . $user_abo->payone_userid }} - @if (Session::has('alert-success')) -
-
-
    -
  • {{ Session::get('alert-success') }}
  • -
-
-
- @endif @if (Session::has('alert-error'))
@@ -99,7 +90,6 @@ @include('admin.abo._executions')
- @include('admin.abo._retry_payment_modal', ['retryAction' => route('portal_abos_retry_payment', [$user_abo->id])]) {{ __('abo.back') }} diff --git a/resources/views/user/abo/detail.blade.php b/resources/views/user/abo/detail.blade.php index b757c0b..29b6d82 100644 --- a/resources/views/user/abo/detail.blade.php +++ b/resources/views/user/abo/detail.blade.php @@ -16,15 +16,6 @@ - @if (Session::has('alert-success')) -
-
-
    -
  • {{ Session::get('alert-success') }}
  • -
-
-
- @endif @if (Session::has('alert-error'))
@@ -116,7 +107,6 @@ @include('admin.abo._executions')
- @include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])]) {{ __('back') }} diff --git a/routes/domains/crm.php b/routes/domains/crm.php index c082e36..f0cc88e 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -161,7 +161,6 @@ 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::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/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/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable'); // Route to show team subscriptions (Abos) diff --git a/routes/domains/portal.php b/routes/domains/portal.php index c641794..cdcf28e 100644 --- a/routes/domains/portal.php +++ b/routes/domains/portal.php @@ -49,7 +49,6 @@ 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::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/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/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable'); Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load'); diff --git a/tests/Feature/AboMakeOrderOneTimeTest.php b/tests/Feature/AboMakeOrderOneTimeTest.php deleted file mode 100644 index 5309b8e..0000000 --- a/tests/Feature/AboMakeOrderOneTimeTest.php +++ /dev/null @@ -1,269 +0,0 @@ - '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); -}); diff --git a/tests/Feature/AboOneTimeServiceTest.php b/tests/Feature/AboOneTimeServiceTest.php index 7a27841..1325120 100644 --- a/tests/Feature/AboOneTimeServiceTest.php +++ b/tests/Feature/AboOneTimeServiceTest.php @@ -129,71 +129,6 @@ describe('AboOneTimeService', function () { expect($this->service->handleAction($abo, ['action' => 'foo']))->toBe(__('abo.onetime_action_invalid')); }); - - it('trennt bestätigte und offene Einmal-Artikel inkl. offener Zwischensumme', function () { - $abo = makeMeAbo(); - $confirmed = makeProduct(['2'], 119, 19); - $pending = makeProduct(['2'], 50, 19); - - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, 'product_id' => $confirmed->id, - 'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(), - 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1, - ]); - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, 'product_id' => $pending->id, - 'qty' => 2, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0, - ]); - - expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(1) - ->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1) - ->and(AboOneTimeService::pendingGross($abo))->toBe(100.0); - }); - - it('behandelt einen geänderten bestätigten Artikel als offen', function () { - $abo = makeMeAbo(); - $product = makeProduct(['2'], 119, 19); - - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, 'product_id' => $product->id, - 'qty' => 3, 'confirmed_qty' => 1, 'confirmed_at' => now(), - 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 0, - ]); - - expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(0) - ->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1) - ->and(AboOneTimeService::pendingGross($abo))->toBe(357.0); - }); -}); - -describe('AboOrderCart::addOneTimeItemsToYard', function () { - beforeEach(fn () => makeShopEnv()); - - it('lädt nur verbindlich bestätigte Einmal-Artikel in den Warenkorb', function () { - $abo = makeMeAbo(); - $confirmed = makeProduct(['2'], 119, 19); - $pending = makeProduct(['2'], 50, 19); - - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, 'product_id' => $confirmed->id, - 'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(), - 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1, - ]); - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, 'product_id' => $pending->id, - 'qty' => 1, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0, - ]); - - $yard = Yard::instance(AboOrderCart::INSTANCE); - $yard->destroy(); - - AboOrderCart::addOneTimeItemsToYard($abo->fresh()); - - expect($yard->content()->count())->toBe(1) - ->and((int) $yard->content()->first()->id)->toBe($confirmed->id); - - $yard->destroy(); - }); }); describe('AboOrderCart::buildOneTimeSnapshot', function () { diff --git a/tests/Feature/AboOneTimeViewTest.php b/tests/Feature/AboOneTimeViewTest.php index e7a3d0a..bdcd4ff 100644 --- a/tests/Feature/AboOneTimeViewTest.php +++ b/tests/Feature/AboOneTimeViewTest.php @@ -9,7 +9,7 @@ uses(Tests\TestCase::class, RefreshDatabase::class); describe('Abo Einmal-Produkte Views', function () { beforeEach(fn () => makeShopEnv()); - it('rendert die Einmal-Produktliste mit Position und offener Zwischensumme', function () { + it('rendert die Einmal-Produktliste mit Position und Zwischensumme', function () { $abo = makeMeAbo(); $product = makeProduct(['2'], 119, 19); @@ -21,7 +21,7 @@ describe('Abo Einmal-Produkte Views', function () { 'price_net' => 100.0, 'tax_rate' => 19.0, 'tax' => 19.0, - 'status' => 0, + 'status' => 1, ]); $summary = ['one_time' => ['gross' => 238.0, 'net' => 200.0, 'tax' => 38.0]]; @@ -32,50 +32,10 @@ describe('Abo Einmal-Produkte Views', function () { ])->render(); expect($html)->toContain($product->getLang('name')) - ->and($html)->toContain(__('abo.onetime_pending_subtotal')) - ->and($html)->toContain(__('abo.onetime_status_pending')) + ->and($html)->toContain(__('abo.onetime_subtotal')) ->and($html)->toContain('238,00'); }); - it('zeigt bestätigte und offene Artikel mit getrennten Zwischensummen', function () { - $abo = makeMeAbo(); - $confirmed = makeProduct(['2'], 119, 19); - $pending = makeProduct(['2'], 50, 19); - - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, - 'product_id' => $confirmed->id, - 'qty' => 1, - 'confirmed_qty' => 1, - 'confirmed_at' => now(), - 'price' => 119.0, - 'tax_rate' => 19.0, - 'status' => 1, - ]); - UserAboOneTimeItem::create([ - 'user_abo_id' => $abo->id, - 'product_id' => $pending->id, - 'qty' => 1, - 'price' => 50.0, - 'tax_rate' => 19.0, - 'status' => 0, - ]); - - $html = view('admin.abo._order_onetime_show', [ - 'user_abo' => $abo->fresh(), - 'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0], - ])->render(); - - expect($html)->toContain(__('abo.onetime_confirmed_subtotal')) - ->and($html)->toContain(__('abo.onetime_pending_subtotal')) - ->and($html)->toContain(__('abo.onetime_new_total')) - ->and($html)->toContain(__('abo.onetime_status_confirmed')) - ->and($html)->toContain(__('abo.onetime_status_pending')) - ->and($html)->toContain(__('abo.onetime_pending_hint')) - ->and($html)->toContain('50,00') - ->and($html)->toContain('169,00'); - }); - it('zeigt bei unbestätigten Einmal-Artikeln die Bestätigungsbuttons', function () { $abo = makeMeAbo(); $product = makeProduct(['2'], 119, 19); @@ -96,7 +56,7 @@ describe('Abo Einmal-Produkte Views', function () { 'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0], ])->render(); - expect($html)->toContain(__('abo.onetime_pending_subtotal')) + expect($html)->toContain(__('abo.onetime_next_delivery_total')) ->and($html)->toContain(__('abo.onetime_legal_notice', [ 'nextBillingDate' => $abo->next_date, 'agb' => ''.__('abo.confirm_terms_link').'', diff --git a/tests/Feature/AboOneTimeWindowTest.php b/tests/Feature/AboOneTimeWindowTest.php index b1bacc2..11517dc 100644 --- a/tests/Feature/AboOneTimeWindowTest.php +++ b/tests/Feature/AboOneTimeWindowTest.php @@ -5,7 +5,6 @@ use App\Models\ShoppingOrderItem; use App\Models\UserAbo; use App\Models\UserAboOneTimeItem; use App\Services\AboHelper; -use App\User; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -83,48 +82,6 @@ describe('isOneTimeWindowOpen', function () { }); }); -describe('isOneTimeFeatureVisible', function () { - it('ist sichtbar, wenn das Fenster offen ist und ein VIP-User (Admin) eingeloggt ist', function () { - $vip = new User; - $vip->admin = 1; - $this->actingAs($vip, 'user'); - - $abo = new UserAbo; - $abo->next_date = now()->addDays(2); // innerhalb des 4-Tage-Fensters - - expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue(); - }); - - it('ist nicht sichtbar für eingeloggte Nicht-VIP-User', function () { - $user = new User; - $user->admin = 0; - $this->actingAs($user, 'user'); - - $abo = new UserAbo; - $abo->next_date = now()->addDays(2); - - expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse(); - }); - - it('ist nicht sichtbar ohne User auf dem user-Guard (Portal/Endkunde)', function () { - $abo = new UserAbo; - $abo->next_date = now()->addDays(2); - - expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse(); - }); - - it('ist nicht sichtbar bei geschlossenem Fenster, auch für VIP-User', function () { - $vip = new User; - $vip->admin = 1; - $this->actingAs($vip, 'user'); - - $abo = new UserAbo; - $abo->next_date = now()->addDays(30); // außerhalb des Fensters - - expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse(); - }); -}); - describe('UserAboOneTimeItem Model', function () { it('definiert die Beziehung one_time_items auf UserAbo', function () { $relation = (new UserAbo)->one_time_items(); diff --git a/tests/Feature/AboUserRetryTest.php b/tests/Feature/AboUserRetryTest.php deleted file mode 100644 index 22caec5..0000000 --- a/tests/Feature/AboUserRetryTest.php +++ /dev/null @@ -1,78 +0,0 @@ - '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); -});