'paymend_paid', 'appointed' => 'paymend_open', 'failed' => 'paymend_failed', 'extern' => 'extern_open', // offen 'extern_paid' => 'extern_paid', 'invoice_open' => 'invoice_open', 'invoice_paid' => 'invoice_paid', 'invoice_non' => 'invoice_no_payment', 'NULL' => 'no_payment', ]; public static function userHasAbo(User $user) { $user = $user ? $user : \Auth::user(); return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>', 1)->first() === null ? false : true; } public static function memberHasAbo(ShoppingUser $shopping_user) { if (! $shopping_user) { return false; } return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>', 1)->first() === null ? false : true; } public static function hasAboByEmail($email) { return UserAbo::where('email', $email)->where('status', '>', 1)->first() === null ? false : true; } public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false) { $user_abo = $shopping_order->getUserAbo(); if ($user_abo) { // Neuaktivierung nach erfolgreicher Zahlung (z. B. Payone paid): immer wieder auf abo_okay (2), // auch wenn das Abo vorher abo_hold (3) war (z. B. Cron-Zahlung fehlgeschlagen, spaeter bezahlt). if ($paid && (int) $status === 2) { $user_abo->update(['status' => 2]); } elseif ($user_abo->status < 2) { $user_abo->update(['status' => $status]); } } if (! $user_abo) { return; } UserAboOrder::where('user_abo_id', $user_abo->id)->where('shopping_order_id', $shopping_order->id)->update(['status' => $status, 'paid' => $paid]); } public static function setAboActive(ShoppingOrder $shopping_order, $status, $paid = false) { self::setAboStatus($shopping_order, $status, $paid); // delete UserAbo is not active status = 1 // is_for = me UserAbo::where('user_id', $shopping_order->auth_user_id)->where('is_for', 'me')->where('status', 1)->delete(); // is_for = ot UserAbo::where('member_id', $shopping_order->member_id)->where('email', $shopping_order->shopping_user->billing_email)->where('is_for', 'ot')->where('status', 1)->delete(); } public static function getAboMinDuration() { return \App\Models\Setting::getContentBySlug('abo-min-duration'); } public static function canCancelAbo(UserAbo $user_abo, $view = 'user') { $minDuration = self::getAboMinDuration(); if ($view === 'admin') { return true; } $paidOrdersCount = $user_abo->getCountPaidOrders(); return $paidOrdersCount >= (int) $minDuration; } public static function isAddOnlyMode(UserAbo $user_abo, $view = 'user'): bool { if ($view === 'admin') { return false; } return ! self::canCancelAbo($user_abo, $view); } public static function canEditAbo($user_abo, $view = 'user') { if ($view === 'portal') { return true; } $user = \Auth::user(); if ($view !== 'admin' && (! $user || ($user_abo->user_id != $user->id && $user_abo->member_id != $user->id))) { return false; } return true; } public static function aboHasBaseProduct($yard_products) { foreach ($yard_products as $product) { if (is_array($product->options->show_on)) { if (in_array('12', $product->options->show_on)) { return true; } } } return false; } public static function getAboShowOn(Product $product) { $show_on = $product->show_on; if (in_array('12', $show_on)) { return 'base'; } if (in_array('13', $show_on)) { return 'upgrade'; } return false; } public static function getAboTypeBadge($abo_type) { if ($abo_type === 'base') { return ' ' . __('abo.' . $abo_type) . ''; } if ($abo_type === 'upgrade') { return ' ' . __('abo.' . $abo_type) . ''; } return ''; } public static function setNextDate($date, $abo_interval) { $nextDate = Carbon::parse($date)->firstOfMonth(); $nextDate->addDays($abo_interval - 1); return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1); } /** * Konfiguriertes Zeitfenster (Kalendertage) für einmalige Produkte vor der Ausführung. */ public static function getOneTimeWindowDays(): int { $days = (int) \App\Models\Setting::getContentBySlug('abo-onetime-window-days'); return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS; } /** * Prüft, ob für dieses Abo aktuell einmalig Produkte aus dem normalen * Bestellsortiment hinzugefügt werden dürfen (Zeitfenster vor der Ausführung). * * Nur die zeitliche Bedingung wird geprüft. Aufrufer sollten zusätzlich den * Abo-Zustand (active/status) berücksichtigen, falls relevant. */ public static function isOneTimeWindowOpen(UserAbo $userAbo): bool { if (! $userAbo->next_date) { return false; } $today = Carbon::today(); $nextDate = Carbon::parse($userAbo->next_date)->startOfDay(); if ($nextDate->lt($today)) { return false; } $daysUntilExecution = $today->diffInDays($nextDate); return $daysUntilExecution <= self::getOneTimeWindowDays(); } /** * Sichtbarkeit des Einmalprodukte-Features während der Live-Abstimmung. * * Temporäre Einschränkung: Das Feature wird vorerst nur VIP-Usern (Admins) * im Sales Center angezeigt. Das Endkunden-Portal läuft über den * `customers`-Guard und hat keinen VIP-User auf dem `user`-Guard, daher * bleibt das Feature dort vollständig ausgeblendet. Nach Abschluss der * Abstimmung kann diese Methode wieder auf reines isOneTimeWindowOpen() * zurückgesetzt werden, um das Feature für alle freizuschalten. */ public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool { /* if (! self::isOneTimeWindowOpen($userAbo)) { return false; }*/ $user = \Auth::guard('user')->user(); return $user instanceof User && $user->isVIP(); } public static function getFirstAboDate($date, $abo_interval) { $reference = Carbon::parse($date)->startOfDay(); $candidate = self::computeFirstAboCandidateWithoutMinDays($reference, (int) $abo_interval); while ($reference->diffInDays($candidate->copy()->startOfDay(), true) < self::MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION) { $candidate = self::advanceAboCandidateOneMonth($candidate, (int) $abo_interval); } return $candidate; } /** * Kalendertage von $from bis $to (nur Datum, ohne Uhrzeit). * Verhindert Abweichungen, wenn {@see now()} eine Tageszeit hat und Carbon {@see diffInDays} in 24h-Schritten zählt. */ public static function calendarDaysUntil(Carbon|string $from, Carbon $to): int { $start = Carbon::parse($from)->startOfDay(); $end = $to->copy()->startOfDay(); return (int) $start->diffInDays($end, true); } /** * Erste mögliche Ausführung (nächster Monat, gewählter Liefertag) ohne Mindestabstand-Regel. */ private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon { $nextDate = $reference->copy()->firstOfMonth()->addMonth(1); $nextDate->addDays($aboDayOfMonth - 1); if (! $nextDate->gt($reference)) { $nextDate->addMonth(1); } return $nextDate->copy()->startOfDay(); } /** * Gleicher Liefertag im Folgemonat (Monatsende beachten). */ private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon { $next = $candidate->copy()->addMonthNoOverflow(); $dim = $next->daysInMonth; $day = min($aboDayOfMonth, $dim); return $next->day($day)->startOfDay(); } public static function createNewAbo(ShoppingPayment $shopping_payment) { $order = $shopping_payment->shopping_order; if (! $order || ! $order->is_abo || (int) $order->abo_interval <= 0) { return; } // Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) { return; } $aboInterval = (int) ($shopping_payment->abo_interval ?? $order->abo_interval); if ($aboInterval <= 0) { return; } $payment_transaction = $shopping_payment->payment_transactions->last(); $payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0; // next_date immer im nächsten Monat starten // is auth_user_id = Berater bestellung // is member_id = Kunden bestellung // is for = me = mich oder ot = kunde $user_abo = UserAbo::create([ 'user_id' => $order->auth_user_id, 'member_id' => $order->member_id, 'shopping_user_id' => $order->shopping_user_id, 'email' => $order->shopping_user->billing_email, 'is_for' => $order->shopping_user->is_for, 'payone_userid' => $payoneUserId, 'clearingtype' => $shopping_payment->clearingtype, 'wallettype' => $shopping_payment->wallettype, 'carddata' => $shopping_payment->carddata, 'amount' => $shopping_payment->amount, 'status' => 1, 'abo_interval' => $aboInterval, 'start_date' => now(), 'last_date' => now(), 'next_date' => self::getFirstAboDate(now(), $aboInterval), ]); if ($user_abo) { self::createAboItems($user_abo, $shopping_payment); UserAboOrder::create([ 'user_abo_id' => $user_abo->id, 'shopping_order_id' => $shopping_payment->shopping_order_id, 'status' => 1, ]); // Payone-Status-URL kann vor dem Checkout-Redirect laufen: dann existierte // noch kein UserAboOrder → Payment::paymentStatusPaidAction → trackAboActivated ohne Wirkung. // Nach Anlage hier erneut versuchen, wenn die Bestellung bereits als bezahlt gilt. $shopping_payment->shopping_order->refresh(); if ($shopping_payment->shopping_order->paid) { IncentiveTracker::trackAboActivated($shopping_payment->shopping_order); } } } public static function createAboItems($user_abo, ShoppingPayment $shopping_payment) { foreach ($shopping_payment->shopping_order->shopping_order_items as $item) { UserAboItem::create([ 'user_abo_id' => $user_abo->id, 'product_id' => $item->product_id, 'comp' => $item->comp ?? 0, 'qty' => $item->qty, 'status' => 1, ]); } $user_abo->load('user_abo_items'); AboItemHistoryService::logInitialCreation($user_abo, 'system'); } /** * Stellt Abo-Artikel aus der letzten Bestellung mit Positionen wieder her, wenn user_abo_items leer sind * (z. B. manuell angelegtes Abo ohne Checkout-AboItem-Anlage). */ public static function ensureUserAboItemsFromLatestOrder(UserAbo $userAbo): bool { if ($userAbo->user_abo_items()->exists()) { return true; } $userAboOrders = $userAbo->user_abo_orders() ->orderByDesc('id') ->with(['shopping_order.shopping_order_items']) ->get(); $order = null; foreach ($userAboOrders as $link) { $shoppingOrder = $link->shopping_order; if ($shoppingOrder && $shoppingOrder->shopping_order_items->isNotEmpty()) { $order = $shoppingOrder; break; } } if (! $order) { return false; } foreach ($order->shopping_order_items as $item) { UserAboItem::create([ 'user_abo_id' => $userAbo->id, 'product_id' => $item->product_id, 'comp' => $item->comp ?? 0, 'qty' => $item->qty, 'status' => 1, ]); } $userAbo->unsetRelation('user_abo_items'); if (! UserAboItemHistory::query() ->where('user_abo_id', $userAbo->id) ->where('is_initial', true) ->exists()) { $userAbo->load('user_abo_items'); AboItemHistoryService::logInitialCreation($userAbo, 'system'); } return true; } public static function getTransStatusFilterText() { $ret = []; foreach (self::$txaction_filter_text as $key => $val) { $ret[$key] = trans('payment.' . $val); } return $ret; } /** * Prüft effizient, ob ein User im Team eines anderen Users ist (Downline). * Traversiert die Sponsor-Hierarchie rekursiv (m_sponsor) statt die komplette * TreeCalcBot-Struktur aufzubauen. * * @param int $teamOwnerId ID des Team-Users (Berechtigter) * @param int $userToCheckId ID des zu prüfenden Users (z.B. Abo-Besitzer) * @param int $maxDepth Max. Tiefe (Schutz vor zirkulären Referenzen) * @return bool True wenn userToCheckId im Team von teamOwnerId ist */ public static function isUserInTeam(int $teamOwnerId, int $userToCheckId, int $maxDepth = 100): bool { if ($teamOwnerId === $userToCheckId) { return true; } $currentId = $userToCheckId; $depth = 0; while ($depth < $maxDepth) { $currentUser = User::where('id', $currentId)->select('m_sponsor')->first(); if (! $currentUser || ! $currentUser->m_sponsor) { return false; } if ($currentUser->m_sponsor === $teamOwnerId) { return true; } $currentId = $currentUser->m_sponsor; $depth++; } return false; } /** * Liefert alle User-IDs im Team (Downline) eines Users. * Traversiert die Sponsor-Hierarchie rekursiv nach unten statt TreeCalcBot. * * @param int $teamOwnerId ID des Team-Users * @param int $maxDepth Max. Tiefe (Schutz vor Endlosschleifen) * @return int[] */ public static function getTeamUserIds(int $teamOwnerId, int $maxDepth = 50): array { $teamUserIds = []; $toProcess = [$teamOwnerId]; $depth = 0; while (! empty($toProcess) && $depth < $maxDepth) { $children = User::whereIn('m_sponsor', $toProcess) ->whereNull('deleted_at') ->pluck('id') ->toArray(); $teamUserIds = array_merge($teamUserIds, $children); $toProcess = $children; $depth++; } return array_values(array_unique($teamUserIds)); } /** * Berechnet die Anzahl aktiver Abos pro Monat für ein gegebenes Jahr. * Ein Abo gilt als aktiv in Monat M wenn: * - start_date <= letzter Tag von M * - cancel_date ist NULL oder >= erster Tag von M * * @param \Illuminate\Database\Eloquent\Builder $query Basis-Query (gefiltert nach User/Team etc.) * @param int $year Jahr für die Berechnung * @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember) */ /** * Liefert die Abo-Zählung pro Monat für ein Jahr. * * Vergangene Monate → aus DB-Snapshot (eingefroren, unabhängig von Strukturänderungen). * Aktueller Monat → live berechnet. * Zukünftige Monate → null (kein Balken im Chart). * * @param \Illuminate\Database\Eloquent\Builder $liveQuery Basis-Query für den aktuellen Monat * @param string $scope 'ot' | 'team_abos' | 'team_cust_abos' * @param int $userId Eingeloggter Berater * @return array 12 Einträge (Index 0 = Jan), null = Zukunft */ public static function getMonthlyAboCounts( \Illuminate\Database\Eloquent\Builder $liveQuery, int $year, string $scope, int $userId ): array { $data = []; $now = Carbon::now(); $currentYear = (int) $now->year; $currentMonth = (int) $now->month; $lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12; // Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden $snapshots = \App\Models\AboChartSnapshot::where('user_id', $userId) ->where('scope', $scope) ->where('year', $year) ->get() ->keyBy('month'); for ($month = 1; $month <= 12; $month++) { if ($month > $lastCountableMonth) { $data[] = null; continue; } $isPastMonth = $year < $currentYear || ($year === $currentYear && $month < $currentMonth); if ($isPastMonth && $snapshots->has($month)) { // Eingefroren – aus DB $data[] = $snapshots->get($month)->count; } else { // Aktueller Monat oder noch kein Snapshot → live $startOfMonth = Carbon::create($year, $month, 1)->startOfMonth(); $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); $terminalStatuses = [4, 5]; $data[] = (clone $liveQuery) ->whereDate('start_date', '<=', $endOfMonth) ->where(function ($q) use ($startOfMonth, $terminalStatuses) { $q->whereDate('cancel_date', '>=', $startOfMonth) ->orWhere(function ($q2) use ($terminalStatuses) { $q2->whereNull('cancel_date') ->whereNotIn('status', $terminalStatuses); }); }) ->count(); } } return $data; } }