mivita/app/Http/Controllers/User/AboController.php
Kevin 5a38f4ac3e Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das
Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand
nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem
uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft
deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen
Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad.

A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung,
Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt
auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein
Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine
spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller
`active = true` nimmt es dauerhaft aus allen Folgemonaten.

Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und
nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht
vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung.
Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date`
— bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen
Monaten, jetzt bleibt die Historie korrekt.

A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der
Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte
in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()`
zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist —
genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen
jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel.

A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25
Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die
Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und
Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als
letzte Regel entscheidet die user_id, damit eine gecachte Rangliste
reproduzierbar bleibt.

A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die
Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde
zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert;
ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter
dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen
Stornowerten, und `next_date` wurde doppelt formatiert.

Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09
(Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17
(Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text)
und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt
0,00 EUR).

Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:38 +00:00

852 lines
38 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\Abo\PaylineForecastService;
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
{
/**
* Relationen, die die Kundenabo-Liste je Zeile auswertet — inklusive
* `user_abo_orders` fuer die Anzahl bezahlter Lieferungen
* ({@see UserAbo::getCountPaidOrders()}). Ohne Vorladen entsteht dort eine
* Abfrage pro dargestelltem Abo.
*
* @var string[]
*/
private const CUSTOMER_LIST_RELATIONS = ['user_abo_items', 'user_abo_items.product', 'shopping_user', 'user_abo_orders'];
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(self::CUSTOMER_LIST_RELATIONS)
->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,
// Liefertag-Vorschau nur aus den eigenen Kundenabos
// (Kundenwunsch 2026-09-07), und nur im laufenden Monat sinnvoll.
'customerAboForecast' => $selectedMonth === 0 || ($selectedMonth === (int) now()->month && $selectedYear === (int) now()->year)
? app(PaylineForecastService::class)->personalCustomerAboForecast(\Auth::user())
: 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 = __('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');
}
}
}