mivita/app/Http/Controllers/User/AboController.php
Kevin 671022a28d Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung
- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan
  (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs)
- Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich
  begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail)
- Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail
- Admin: Abo löschen (destroy) inkl. Test
- Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID
- VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt
- Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:19 +00:00

787 lines
34 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') {
$user_abo = UserAbo::findOrFail($data['id']);
$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 (! $user_abo->canUpdatePaymentMethod()) {
\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();
}
public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
$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]));
}
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) {
\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');
}
}
}