middleware('active.account'); $this->aboRepository = $aboRepository; } public function index($view) { if ($view === 'me') { // Nur Abos des aktuellen Benutzers $baseQuery = UserAbo::where('user_id', \Auth::user()->id) ->where('status', '>=', 1) ->where('is_for', 'me'); if ((clone $baseQuery)->where('status', 1)->exists()) { return view('user.abo.index', [ 'user_abos' => (clone $baseQuery)->orderBy('id', 'desc')->get(), 'view' => 'me', 'isAdmin' => false, ]); } $user_abos = (clone $baseQuery)->orderBy('id', 'desc')->get(); if ($user_abos->count() > 0) { return redirect(route('user_abos_detail', ['me', $user_abos->first()->id])); } return view('user.abo.index', [ 'user_abos' => $user_abos, 'view' => 'me', 'isAdmin' => false, ]); } if ($view === 'ot') { $selectedYear = (int) Request::get('year', now()->year); $selectedMonth = (int) Request::get('month', 0); $baseQuery = UserAbo::where('member_id', \Auth::user()->id) ->where('status', '>=', 1) ->where('is_for', 'ot'); $user_abos = (clone $baseQuery) ->with(self::CUSTOMER_LIST_RELATIONS) ->addedInMonth($selectedYear, $selectedMonth) ->orderBy('id', 'desc') ->get(); // Vorschau und Monatsspalte gelten fuer den laufenden Monat; bei einem // Filter auf einen anderen Monat waeren sie irrefuehrend. $isCurrentMonthView = $selectedMonth === 0 || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year); return view('user.abo.index', [ 'user_abos' => $user_abos, 'abo_payment_links' => $this->getAboPaymentLinks(), 'view' => 'ot', 'isAdmin' => false, // Liefertag-Vorschau nur aus den eigenen Kundenabos // (Kundenwunsch 2026-09-07), und nur im laufenden Monat sinnvoll. 'customerAboForecast' => $isCurrentMonthView ? app(PaylineForecastService::class)->personalCustomerAboForecast(\Auth::user()) : null, // Je Abo die im laufenden Monat abgerechneten Punkte — inklusive // Einmal-Artikel, die nach der Ausfuehrung aus dem Abo verschwinden. // Ohne diese Spalte laesst sich die Vorschau oben nicht nachrechnen. 'customerAboBilled' => $isCurrentMonthView ? app(PaylineForecastService::class)->billedCustomerAboPointsByAbo(\Auth::user(), (int) now()->month, (int) now()->year) : null, 'customerAboBilledTotal' => $isCurrentMonthView ? app(PaylineForecastService::class)->billedCustomerAboPoints(\Auth::user(), (int) now()->month, (int) now()->year) : null, 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id), 'chartYear' => $selectedYear, 'chartYears' => HTMLHelper::getYearRange(2026), 'chartMonths' => HTMLHelper::getTransMonths(), 'filterMonth' => $selectedMonth, 'filterYear' => $selectedYear, ]); } // Standardfall, wenn weder 'me' noch 'ot' return view('user.abo.index', [ 'user_abos' => [], 'view' => 'me', 'isAdmin' => false, ]); } public function detail($view, $id) { $data = Request::all(); $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); // init Yard AboOrderCart::initYard($user_abo); // holt die aktuellen UserAccount Daten oder die Userdaten des Abo $customer_detail = AboOrderCart::getCustomerDetail(); AboOrderCart::makeOrderYard($user_abo); $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); $oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo); if ($oneTimeWindowOpen) { AboOrderCart::addOneTimeItemsToYard($user_abo); AboOrderCart::checkNumOfCompProducts($user_abo); } $comp_products = []; if ($user_abo->is_for === 'me') { $comp_products = Shop::getCompProducts('abo-me'); } $summary = AboOrderCart::getSplitSummary(); $data = [ 'user_abo' => $user_abo, 'isAdmin' => false, 'customer_detail' => $customer_detail, 'view' => $view, 'comp_products' => $comp_products, 'one_time_window_open' => $oneTimeWindowOpen, 'summary' => $summary, 'base_comp_count' => $baseCompCount, ]; return view('user.abo.detail', $data); } public function update($view, $id) { $data = Request::all(); $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); $editView = \Auth::user()?->isAdmin() ? 'admin' : $view; // Add-only (Mindestlaufzeit) und Change-Lock richten sich nach der Abo-Sicht ($view), // NICHT nach Admin-Status – auf der Selbstbedienungs-Seite (view me/ot) gelten für alle // (auch VIP/Admin) die Berater-Regeln. Die Admin-Detailseite postet mit view=admin // (nur für Admins zugelassen, siehe checkPermissions) und umgeht beide Sperren. // $editView dient nur dem History-Logging. $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view); $changeLocked = AboHelper::isAboChangeLocked($user_abo, $view); if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { // Body-id kann von der Route abweichen: Berechtigungen erneut auf dem // tatsächlich bearbeiteten Abo prüfen. Die Sicht kommt ausschließlich aus // der (bereits geprüften) Route, nie aus dem Formular — sonst ließen sich // per view=admin sämtliche Selfservice-Regeln umgehen. $user_abo = UserAbo::findOrFail($data['id']); $this->checkPermissions($view, $user_abo); $data['view'] = $view; $this->aboRepository->setModel($user_abo); $this->aboRepository->update($data); return redirect(route('user_abos_detail', [$view, $id])); } if (Request::ajax()) { $message = false; // Yard für Gewichts-/Versandprüfung initialisieren (Versandland setzen) AboOrderCart::initYard($user_abo); if ($changeLocked) { $message = AboHelper::lockMessage('error_change_locked', $user_abo, AboRepository::getLockDaysChange()); } // addProduct if (! $changeLocked && $data['action'] === 'addProduct') { if ($product = Product::find($data['product_id'])) { if (AboOrderCart::exceedsMaxWeight($user_abo, (int) $product->weight)) { $message = __('msg.cart_max_weight_reached'); } elseif ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('product_id', $product->id)->where('comp', 0)->first()) { $qtyBefore = $UserAboItem->qty; $UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->save(); AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1, ]); AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } } // updateCart if (! $changeLocked && $data['action'] === 'updateCart') { // product_id | order_item_id | cart_order_id | qty if (isset($data['product_id']) && $product = Product::find($data['product_id'])) { if (isset($data['order_item_id']) && $UserAboItem = UserAboItem::find($data['order_item_id'])) { if (isset($data['qty'])) { $qtyBefore = $UserAboItem->qty; $qty = (int) $data['qty']; $qty = $qty < 1 ? 1 : $qty; $qty = $qty > 100 ? 100 : $qty; if ($isAddOnlyMode && $qty < $UserAboItem->qty) { $qty = $UserAboItem->qty; } $additionalWeight = (int) $product->weight * ($qty - $qtyBefore); if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($user_abo, $additionalWeight)) { $message = __('msg.cart_max_weight_reached'); } else { $UserAboItem->qty = $qty; $UserAboItem->save(); AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView); } } } } } // removeFromCart if (! $changeLocked && $data['action'] === 'removeFromCart') { if ($isAddOnlyMode) { return response()->json([ 'response' => false, 'message' => __('abo.error_add_only_no_remove'), ], 403); } if (! isset($data['product_id']) || ! ($product = Product::find($data['product_id']))) { $message = __('abo.product_not_found'); } if (! isset($data['order_item_id']) || ! ($userAboItem = UserAboItem::find($data['order_item_id']))) { $message = __('abo.abo_item_not_found'); } $has_basis_product = $this->check_need_basis_product($user_abo, $product, $data['order_item_id']); if (! $has_basis_product) { $message = __('abo.need_basis_product'); } if (! $message) { AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $editView); $userAboItem->delete(); $user_abo->refresh(); // Abo neu laden um die aktualisierten Items zu erhalten } } // updateCompProduct if (! $changeLocked && $data['action'] === 'updateCompProduct') { if ($UserAboItem = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', $data['comp_num'])->first()) { $oldProduct = $UserAboItem->product; $UserAboItem->product_id = $data['comp_product_id']; $UserAboItem->save(); $UserAboItem->load('product'); AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, 'product_id' => $data['comp_product_id'], 'comp' => $data['comp_num'], 'qty' => 1, 'status' => 1, ]); AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); if (AboHelper::isOneTimeFeatureVisible($user_abo)) { AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); } AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product // Relation neu laden, damit die gerenderte Menge dem gespeicherten Stand // entspricht (sonst zeigt _order_abo_show die gecachte alte Menge → Feld „springt" zurück). $user_abo->load('user_abo_items'); if ($user_abo->is_for === 'me') { $data['comp_products'] = Shop::getCompProducts('abo-me'); } $summary = AboOrderCart::getSplitSummary(); $error_message = $message ? $message : false; $html_cart = view('admin.abo._order_abo_show', [ 'user_abo' => $user_abo, 'error_message' => $error_message, 'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo), 'summary' => $summary, 'add_only_mode' => $isAddOnlyMode, 'change_locked' => $changeLocked, ])->render(); $html_comp = view('user.order.comp_product', array_merge($data, [ 'cart_instance' => AboOrderCart::INSTANCE, 'base_comp_count' => $baseCompCount, ]))->render(); $html_summary = view('admin.abo._order_combined_summary', [ 'summary' => $summary, ])->render(); $amount = $user_abo->getFormattedAmount(); // $html_total = view("user.homeparty.show_total_order", ['homeparty' => $homeparty])->render(); return response()->json(['response' => true, 'data' => $data, 'html_cart' => $html_cart, 'html_comp' => $html_comp, 'html_summary' => $html_summary, 'amount' => $amount]); } } } public function oneTime(AboOneTimeItemRequest $request, $view, $id) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); // Add-only/Change-Lock nach Abo-Sicht ($view), kein Admin-Bypass (siehe update()). $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view); $changeLocked = AboHelper::isAboChangeLocked($user_abo, $view); if (! AboHelper::isOneTimeFeatureVisible($user_abo)) { return response()->json([ 'response' => false, 'message' => __('abo.onetime_window_closed'), ], 403); } AboOrderCart::initYard($user_abo); $message = (new AboOneTimeService)->handleAction($user_abo, $request->validated()); AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); $baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp(); AboOrderCart::addOneTimeItemsToYard($user_abo->fresh()); AboOrderCart::checkNumOfCompProducts($user_abo); $user_abo = $user_abo->fresh(); $summary = AboOrderCart::getSplitSummary(); $compProducts = $user_abo->is_for === 'me' ? Shop::getCompProducts('abo-me') : []; return response()->json([ 'response' => ! $message, 'message' => $message ?: null, 'summary' => $summary, 'one_time_items' => $this->oneTimeItemsPayload($user_abo), 'html_onetime' => view('admin.abo._order_onetime_show', [ 'user_abo' => $user_abo, 'summary' => $summary, 'error_message' => $message ?: false, ])->render(), 'html_abo' => view('admin.abo._order_abo_show', [ 'user_abo' => $user_abo, 'split_mode' => true, 'summary' => $summary, 'only_show_products' => false, 'add_only_mode' => $isAddOnlyMode, 'change_locked' => $changeLocked, ])->render(), 'html_summary' => view('admin.abo._order_combined_summary', [ 'summary' => $summary, ])->render(), 'html_comp' => view('user.order.comp_product', [ 'comp_products' => $compProducts, 'cart_instance' => AboOrderCart::INSTANCE, 'base_comp_count' => $baseCompCount, ])->render(), ]); } /** * @return array> */ private function oneTimeItemsPayload(UserAbo $user_abo): array { return $user_abo->one_time_items()->with('product')->get()->map(function (UserAboOneTimeItem $item) { return [ 'id' => $item->id, 'product_id' => $item->product_id, 'name' => $item->product?->getLang('name'), 'qty' => $item->qty, 'price' => $item->price, 'total' => round($item->price * $item->qty, 2), ]; })->all(); } public function check_need_basis_product($user_abo, $product, $order_item_id) { // Wenn das zu entfernende Produkt kein Basis-Produkt ist, keine weitere Prüfung nötig if (AboHelper::getAboShowOn($product) !== 'base') { return true; } // Prüfe ob noch ein anderes Basis-Produkt vorhanden ist (nur reguläre Items, keine Comp-Produkte) foreach ($user_abo->user_abo_items as $user_abo_item) { if ($user_abo_item->id == $order_item_id) { continue; } if ($user_abo_item->comp) { continue; } if (AboHelper::getAboShowOn($user_abo_item->product) === 'base') { return true; } } return false; } public function datatable($user_abo_id) { $user_abo = UserAbo::findOrFail($user_abo_id); if (! $user_abo) { abort(404); } // $user_abo->is_for === 'me' $show_on_ids = ['12', '13']; $query = Product::select('products.*') ->where('active', true) ->where(function ($q) use ($show_on_ids) { foreach ($show_on_ids as $id) { $q->orWhereJsonContains('show_on', $id); } }) ->orderByRaw( "CASE WHEN JSON_CONTAINS(show_on, ?, '$') THEN 1 WHEN JSON_CONTAINS(show_on, ?, '$') THEN 2 ELSE 3 END", [$show_on_ids[0], isset($show_on_ids[1]) ? $show_on_ids[1] : $show_on_ids[0]] ); return \DataTables::eloquent($query) ->addColumn('add_card', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; $tax_free = $user_abo->is_for === 'me' ? true : Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree(); $price = $product->getFormattedPriceWith($tax_free, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()); return ''; }) ->addColumn('picture', function (Product $product) { if (count($product->images)) { return ''; } return ''; }) ->addColumn('name', function (Product $product) { return ''.$product->getLang('name').'
'.get_abo_type_badge_by_product($product); }) ->addColumn('points', function (Product $product) { return ''.$product->getFormattedPoints().''; }) ->addColumn('price_net', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; return ''.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'.''.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).''; }) ->addColumn('price_gross', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; return ''.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'.''.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).''; }) ->addColumn('action', function (Product $product) { return ''; }) ->filterColumn('product', function ($query, $keyword) { if ($keyword != '') { $query->where('name', 'LIKE', '%'.$keyword.'%'); } }) ->orderColumn('name', 'name $1') ->orderColumn('product', 'name $1') ->orderColumn('number', 'number $1') ->orderColumn('points', 'points $1') ->orderColumn('price_net', 'price_net $1') ->orderColumn('price_gross', 'price_gross $1') ->orderColumn('contents_total', 'contents_total $1') ->orderColumn('weight', 'weight $1') ->rawColumns(['add_card', 'points', 'product', 'name', 'quantity', 'picture', 'price_net', 'price_gross', 'action']) ->make(true); } public function oneTimeDatatable($user_abo_id) { $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']; $query = Product::select('products.*') ->where('active', true) ->where(function ($q) use ($show_on_ids) { foreach ($show_on_ids as $id) { $q->orWhereJsonContains('show_on', $id); } }) ->orderBy('name', 'asc'); return \DataTables::eloquent($query) ->addColumn('add_card', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; $tax_free = $user_abo->is_for === 'me' ? true : Yard::instance(AboOrderCart::INSTANCE)->getUserTaxFree(); $price = $product->getFormattedPriceWith($tax_free, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()); return ''; }) ->addColumn('picture', function (Product $product) { if (count($product->images)) { return ''; } return ''; }) ->addColumn('name', function (Product $product) { return ''.$product->getLang('name').''; }) ->addColumn('points', function (Product $product) { return ''.$product->getFormattedPoints().''; }) ->addColumn('price_net', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; return ''.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; }) ->addColumn('price_gross', function (Product $product) use ($user_abo) { $ufactor = $user_abo->is_for === 'me' ? true : false; return ''.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €'; }) ->filterColumn('product', function ($query, $keyword) { if ($keyword != '') { $query->where('name', 'LIKE', '%'.$keyword.'%'); } }) ->orderColumn('name', 'name $1') ->orderColumn('number', 'number $1') ->rawColumns(['add_card', 'points', 'name', 'picture', 'price_net', 'price_gross']) ->make(true); } /** * Startet den Recovery-Checkout für ein fehlgeschlagenes Abo: der Berater begleicht die offene * Folgerechnung seines EIGENEN Abos (view=me) über den Checkout mit frei wählbarer Zahlart. * Kein Kunden-Abo (ot), kein Admin-Bypass – das übernimmt der Kunde selbst im Portal. */ public function payNow($view, $id, AboRecoveryCheckoutService $recoveryCheckout) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) { abort(403, 'Pay now is only allowed for the subscription owner.'); } if ($user_abo->status !== 3 || ! $user_abo->active) { \Session()->flash('alert-error', __('abo.pay_now_not_allowed')); return redirect(route('user_abos_detail', [$view, $id])); } $url = $recoveryCheckout->buildCheckoutUrl($user_abo); if (! $url) { \Session()->flash('alert-error', __('abo.pay_now_failed')); return redirect(route('user_abos_detail', [$view, $id])); } return redirect()->away($url); } public function deletePending($view, $id) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); if (! $user_abo->canDeletePendingInitialPayment()) { \Session()->flash('alert-error', __('abo.pending_delete_not_allowed')); return redirect(route('user_abos', [$view])); } \DB::transaction(function () use ($user_abo) { $user_abo->delete(); }); \Session()->flash('alert-success', __('abo.pending_delete_success')); return redirect(route('user_abos', [$view])); } public function deletePaymentLink($identifier) { $shoppingInstance = ShoppingInstance::where('identifier', $identifier)->firstOrFail(); $user = \Auth::user(); $userShopId = $user && $user->shop ? $user->shop->id : null; if ((int) $shoppingInstance->user_shop_id !== (int) $userShopId || ! OrderPaymentService::isAboCustomerPaymentLink($shoppingInstance)) { abort(403); } if (! OrderPaymentService::canDeleteInstance($shoppingInstance)) { \Session()->flash('alert-error', __('abo.payment_link_delete_not_allowed')); return redirect(route('user_abos', ['ot'])); } OrderPaymentService::deleteInstance($identifier); \Session()->flash('alert-success', __('abo.payment_link_delete_success')); return redirect(route('user_abos', ['ot'])); } private function getAboPaymentLinks() { $user = \Auth::user(); $userShopId = $user && $user->shop ? $user->shop->id : null; if (! $userShopId) { return collect(); } return ShoppingInstance::where('user_shop_id', $userShopId) ->where('payment', 6) ->where('status', '<', 10) ->orderBy('created_at', 'desc') ->get() ->filter(fn (ShoppingInstance $shoppingInstance) => OrderPaymentService::isAboCustomerPaymentLink($shoppingInstance)) ->reject(fn (ShoppingInstance $shoppingInstance) => OrderPaymentService::hasUserAboForPaymentLink($shoppingInstance, $user)) ->values(); } public function editPaymentMethod($view, $id) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo); if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) { \Session()->flash('alert-error', __('abo.card_update_not_allowed')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } return view('user.abo.payment_method', ['user_abo' => $user_abo, 'view' => $view]); } public function updatePaymentMethod(UpdateAboCreditCardRequest $request, $view, $id, AboPaymentMethodService $service) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo); $returnUrls = [ 'successurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']), 'errorurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'error']), 'backurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']), ]; $result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'crm'); if ($result->isApproved()) { \Session()->flash('alert-success', __('abo.card_update_success')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } if ($result->isRedirect()) { session(['abo_card_update.'.$user_abo->id => [ 'carddata' => $result->carddata, 'userid' => $result->userid, ]]); return redirect()->away($result->redirectUrl); } \Session()->flash('alert-error', $result->errorMessage ?? __('abo.card_update_failed')); return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput(); } /** * Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5): Redirect * zur Hosted-Seite, freie Zahlartenwahl, keine Abbuchung. Gilt auch für * PAYONE-Bestandsabos (Umzug auf Stripe bei Erfolg). */ public function startStripePaymentMethodChange($view, $id, AboPaymentMethodService $service) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo); abort_unless($service->usesStripeChange(), 404); $result = $service->beginStripeChange( $user_abo, route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']).'?session_id={CHECKOUT_SESSION_ID}', route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']), 'crm', ); if ($result->isRedirect()) { return redirect()->away($result->redirectUrl); } \Session()->flash('alert-error', $result->errorMessage ?? __('abo.payment_method_change_failed')); return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id])); } public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo); // Stripe-Setup-Return (Plan S5): Session prüfen und Zahlungsart verankern. if ($status === 'success' && ($sessionId = request('session_id'))) { if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'crm')) { \Session()->flash('alert-success', __('abo.payment_method_change_success')); } else { \Session()->flash('alert-error', __('abo.payment_method_change_failed')); } return redirect(route('user_abos_detail', [$view, $user_abo->id])); } $stash = session()->pull('abo_card_update.'.$user_abo->id); if ($status === 'success' && is_array($stash)) { $service->applyNewCard($user_abo, $stash['carddata'], (int) ($stash['userid'] ?? 0), 'crm'); \Session()->flash('alert-success', __('abo.card_update_success')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } $user_abo->forceFill(['card_update_pending_at' => null])->save(); \Session()->flash('alert-error', __('abo.card_update_failed')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } /** * Berater verschickt einen Kreditkarten-Update-Link an seinen Kunden (Kunden-Abo, view=ot). * Der Berater gibt die Karte NICHT selbst ein – der Kunde aktualisiert sie login-frei über den Link. */ public function sendCardUpdateLink($view, $id) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) { \Session()->flash('alert-error', __('payment.abo_payments_frozen')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } // Stripe-Setup-Flow: Link ist für jede Zahlart möglich (freie Wahl bei // Stripe); die "nur CC"-Einschränkung gilt nur für den PAYONE-Legacy-Flow. $requiresCreditCard = ! app(AboPaymentMethodService::class)->usesStripeChange(); if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ($requiresCreditCard && ! $user_abo->isCreditCard()) || ! app(AboPaymentMethodService::class)->canStartChange($user_abo)) { \Session()->flash('alert-error', __('abo.card_update_link_not_allowed')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } [$link, $token] = AboCardUpdateLink::generateFor($user_abo, (int) \Auth::id()); \Mail::to($user_abo->email)->send(new AboCardUpdateLinkMail($user_abo, $token)); \Session()->flash('alert-success', __('abo.card_update_link_sent')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); } /** * Karteneingabe ausschließlich durch den Inhaber selbst (Berater-eigenes Abo, view=me). * Kein Admin-Bypass, keine Kunden-Abos (ot) – diese laufen über den Update-Link. */ private function assertOwnerCardEntry($view, UserAbo $user_abo): void { if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) { abort(403, 'Card entry is only allowed for the subscription owner.'); } } private function checkPermissions($view, $user_abo) { \Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]); $user = \Auth::user(); // Admins dürfen alle Abos bearbeiten if ($user && $user->isAdmin()) { return; } // view=admin (und alle unbekannten Views) sind Nicht-Admins nicht erlaubt, // sonst würden sämtliche is_for-/Besitzer-Prüfungen unten übersprungen. if (! in_array($view, ['me', 'ot'], true)) { abort(403, 'Unauthorized action. Invalid view'); } if ($view === 'me' && $user_abo->is_for !== 'me') { abort(403, 'Unauthorized action. Is not for me'); } if ($view === 'ot' && $user_abo->is_for !== 'ot') { abort(403, 'Unauthorized action. Is not your customer'); } if ($view === 'me' && $user_abo->user_id !== $user->id) { abort(403, 'Unauthorized action. Is not my abo'); } if ($view === 'ot' && $user_abo->member_id !== $user->id) { abort(403, 'Unauthorized action. Is not my customer abo'); } } }