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>
836 lines
37 KiB
PHP
836 lines
37 KiB
PHP
<?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.' €">
|
||
<strong>€ '.$price.'</strong> +<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.' €">
|
||
<strong>€ '.$price.'</strong> +<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');
|
||
}
|
||
}
|
||
}
|