mivita/app/Http/Controllers/User/AboController.php
Kevin ac3bd6e8ea Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:56:02 +00:00

836 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\AboOneTimeItemRequest;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Mail\AboCardUpdateLinkMail;
use App\Models\AboCardUpdateLink;
use App\Models\Product;
use App\Models\ShoppingInstance;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOneTimeItem;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\Services\AboItemHistoryService;
use App\Services\AboOneTimeService;
use App\Services\AboOrderCart;
use App\Services\AboPaymentMethodService;
use App\Services\AboRecoveryCheckoutService;
use App\Services\HTMLHelper;
use App\Services\OrderPaymentService;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Shop;
use App\User;
use Carbon\Carbon;
use Request;
use Yard;
class AboController extends Controller
{
protected $aboRepository;
public function __construct(AboRepository $aboRepository)
{
$this->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(['user_abo_items', 'user_abo_items.product', 'shopping_user'])
->addedInMonth($selectedYear, $selectedMonth)
->orderBy('id', 'desc')
->get();
return view('user.abo.index', [
'user_abos' => $user_abos,
'abo_payment_links' => $this->getAboPaymentLinks(),
'view' => 'ot',
'isAdmin' => false,
'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 = __('abo.error_change_locked', [
'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false),
'lock' => 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<int, array<string, mixed>>
*/
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 '<button type="button" class="btn btn-sm btn-md-extra btn-secondary add-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' &euro;">
<strong>&euro; '.$price.'</strong>&nbsp; +<span class="ion ion-md-cart"></span>
</button>';
})
->addColumn('picture', function (Product $product) {
if (count($product->images)) {
return '<img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'">';
}
return '';
})
->addColumn('name', function (Product $product) {
return '<strong>'.$product->getLang('name').'</strong><br>'.get_abo_type_badge_by_product($product);
})
->addColumn('points', function (Product $product) {
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
})
->addColumn('price_net', function (Product $product) use ($user_abo) {
$ufactor = $user_abo->is_for === 'me' ? true : false;
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
})
->addColumn('price_gross', function (Product $product) use ($user_abo) {
$ufactor = $user_abo->is_for === 'me' ? true : false;
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>'.'<span class="no-line-break">'.$product->getFormattedPriceCurrencyWith(true, true, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).'</span>';
})
->addColumn('action', function (Product $product) {
return '<button class="btn btn-default btn-sm icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
data-toggle="modal" data-target="#modals-load-content" data-id="'.$product->id.'" data-route="'.route('modal_load').'"
data-action="user-order-show-product" data-view="customer"><i class="ion ion-md-eye"></i></button>';
})
->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 '<button type="button" class="btn btn-sm btn-md-extra btn-warning add-onetime-product-basket" data-product-id="'.$product->id.'" data-product-name="'.e($product->getLang('name')).'" data-product-price="'.$price.' &euro;">
<strong>&euro; '.$price.'</strong>&nbsp; +<span class="ion ion-md-cart"></span>
</button>';
})
->addColumn('picture', function (Product $product) {
if (count($product->images)) {
return '<img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'">';
}
return '';
})
->addColumn('name', function (Product $product) {
return '<strong>'.$product->getLang('name').'</strong>';
})
->addColumn('points', function (Product $product) {
return '<span class="no-line-break">'.$product->getFormattedPoints().'</span>';
})
->addColumn('price_net', function (Product $product) use ($user_abo) {
$ufactor = $user_abo->is_for === 'me' ? true : false;
return '<span class="no-line-break">'.$product->getFormattedPriceWith(true, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
})
->addColumn('price_gross', function (Product $product) use ($user_abo) {
$ufactor = $user_abo->is_for === 'me' ? true : false;
return '<span class="no-line-break">'.$product->getFormattedPriceWith(false, $ufactor, Yard::instance(AboOrderCart::INSTANCE)->getUserCountry()).' €</span>';
})
->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');
}
}
}