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

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

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

795 lines
29 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\Services;
use App\Enums\PaymentProvider;
use App\Models\AboChartSnapshot;
use App\Models\Product;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboItemHistory;
use App\Models\UserAboOrder;
use App\Repositories\AboRepository;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class AboHelper
{
/**
* Mindestabstand (Kalendertage) vom Bestell-/Referenzdatum bis zur ersten Abo-Ausführung.
*/
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
/**
* Standard-Zeitfenster (Kalendertage) vor der Ausführung, in dem einmalige
* Produkte aus dem normalen Sortiment hinzugefügt werden dürfen.
*/
public const DEFAULT_ONETIME_WINDOW_DAYS = 4;
/**
* Standard-Mindestlaufzeit (bezahlte Lieferungen) bis zur Kündigung, falls
* das Setting `abo-min-duration` nicht gepflegt ist.
*/
public const DEFAULT_ABO_MIN_DURATION = 6;
/**
* Abo-Status, die ein Abo endgültig beenden (4 = gekündigt, 5 = beendet).
* Solche Abos blockieren die Anlage eines neuen Abos nicht mehr
* (Ein-Abo-Regel gilt nur für laufende Abos).
*/
public const ENDED_STATUSES = [4, 5];
/**
* Standard-Abstand (Monate) zwischen zwei Pausen, falls das Setting
* `abo-pause-interval-months` nicht gepflegt ist (max. 1 Pause pro X Monate,
* rollierend ab der letzten Pause).
*/
public const DEFAULT_PAUSE_INTERVAL_MONTHS = 6;
public static $txaction_filter_text = [
'paid' => '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)->whereNotIn('status', self::ENDED_STATUSES)->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)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true;
}
public static function hasAboByEmail($email)
{
return UserAbo::where('email', $email)->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->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()
{
$minDuration = Setting::getContentBySlug('abo-min-duration');
return $minDuration === false || $minDuration === null || $minDuration === '' ? self::DEFAULT_ABO_MIN_DURATION : $minDuration;
}
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);
}
/**
* Konfigurierter Mindestabstand (Monate) zwischen zwei Pausen.
*/
public static function getPauseIntervalMonths(): int
{
$months = (int) Setting::getContentBySlug('abo-pause-interval-months');
return $months > 0 ? $months : self::DEFAULT_PAUSE_INTERVAL_MONTHS;
}
/**
* Darf das Abo aktuell um einen Monat pausiert werden?
* Voraussetzungen (außer Admin): Abo läuft (aktiv, Status 2), Mindestlaufzeit
* erreicht (gleiche Basis wie die Kündigung, Setting `abo-min-duration`) und
* die letzte Pause liegt mindestens {@see getPauseIntervalMonths()} Monate
* zurück (rollierend). Die Sperrfrist vor der Ausführung
* (`abo-lock-days-pause-cancel`) wird separat in der Pause-Aktion geprüft.
*/
public static function canPauseAbo(UserAbo $user_abo, $view = 'user'): bool
{
if (! $user_abo->active || (int) $user_abo->status !== 2) {
return false;
}
if ($view === 'admin') {
return true;
}
if (! self::canCancelAbo($user_abo, $view)) {
return false;
}
return self::nextPossiblePauseDate($user_abo) === null;
}
/**
* Frühestes Datum, ab dem wieder pausiert werden darf — oder null, wenn die
* Pause bereits jetzt möglich ist (keine oder ausreichend lange zurückliegende
* letzte Pause). Dient auch dem UI-Hinweis "wieder möglich ab".
*/
public static function nextPossiblePauseDate(UserAbo $user_abo): ?Carbon
{
$lastPauseDate = $user_abo->getRawOriginal('last_pause_date');
if (! $lastPauseDate) {
return null;
}
$nextPossible = Carbon::parse($lastPauseDate)->startOfDay()->addMonthsNoOverflow(self::getPauseIntervalMonths());
return $nextPossible->lte(Carbon::today()) ? null : $nextPossible;
}
/**
* Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die
* Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre.
* Spiegelt die serverseitige Prüfung in {@see AboRepository::validate()}
* für die UI (Feld/Button deaktivieren).
*/
public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool
{
if ($view === 'admin' || ! $userAbo->next_date) {
return false;
}
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange();
}
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 ($product->options->abo_addon ?? false) {
continue;
}
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 '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
}
if ($abo_type === 'upgrade') {
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
}
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) 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:
* - VIP-User (Admin) sehen/nutzen das Feature dauerhaft, unabhängig vom Zeitfenster.
* - Alle anderen (normale Berater im Sales Center sowie Endkunden im Portal, die nie
* auf dem `user`-Guard angemeldet sind) nur innerhalb des konfigurierten Zeitfensters
* vor der Ausführung ({@see isOneTimeWindowOpen}, Setting `abo-onetime-window-days`).
*/
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
{
$user = \Auth::guard('user')->user();
if ($user instanceof User && $user->isVIP()) {
return true;
}
return self::isOneTimeWindowOpen($userAbo);
}
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;
}
// Recovery-Zahlungen schreiben ein BESTEHENDES Abo fort (advanceAboAfterRecoveryPayment),
// wiederkehrende Cron-Abbuchungen (user_abo_id an der Zahlung) gehören ebenfalls
// zu einem bestehenden Abo — beides darf nie ein neues Abo erzeugen.
if ($order->recovery_user_abo_id || $shopping_payment->user_abo_id) {
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;
}
$isStripe = $shopping_payment->payment_provider === PaymentProvider::Stripe->value;
/*
* Stripe (Plan S4.2): Customer-/PaymentMethod-ID liefert erst der Webhook
* (captureOffSessionPaymentMethod). Läuft der Browser-Return schneller,
* hier NICHT anlegen — der Webhook-Paid-Pfad ruft createNewAbo erneut auf
* und legt das Abo dann vollständig an (einzige Wahrheit wie bei S3).
*/
if ($isStripe && (empty($shopping_payment->stripe_customer_id) || empty($shopping_payment->stripe_payment_method_id))) {
\Log::channel('stripe')->info('AboHelper::createNewAbo: Stripe-IDs noch nicht an der Zahlung — Abo-Anlage dem Webhook überlassen.', [
'shopping_payment_id' => $shopping_payment->id,
'shopping_order_id' => $order->id,
]);
return;
}
$payment_transaction = $shopping_payment->payment_transactions->last();
$payoneUserId = ! $isStripe && $payment_transaction ? (int) $payment_transaction->userid : 0;
/*
* Einmalprodukte der Erstbestellung (is_abo_addon) gehören nicht zum wiederkehrenden
* Abo-Betrag: amount analog zu AboOrderCart::makeOrderYard() (Abo-Artikel + Versand,
* in Cent) um den Addon-Bruttoanteil reduzieren.
*/
$aboAmount = (int) $shopping_payment->amount;
$addonGross = (float) $order->shopping_order_items
->where('is_abo_addon', true)
->reduce(fn ($carry, $item) => $carry + ((float) $item->price * (int) $item->qty), 0.0);
if ($addonGross > 0) {
$aboAmount = max(0, $aboAmount - (int) round($addonGross * 100));
}
// 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,
'payment_provider' => $shopping_payment->payment_provider ?: PaymentProvider::Payone->value,
'stripe_customer_id' => $isStripe ? $shopping_payment->stripe_customer_id : null,
'stripe_payment_method_id' => $isStripe ? $shopping_payment->stripe_payment_method_id : null,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'amount' => $aboAmount,
'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);
}
}
}
/**
* Schreibt ein bestehendes Abo nach einer erfolgreichen Recovery-Zahlung fort
* (der Inhaber hat die offene Folgerechnung über den Checkout mit freier Zahlart beglichen).
* Es wird KEIN neues Abo angelegt – nur das vorhandene Abo reaktiviert und der nächste Termin gesetzt.
*/
public static function advanceAboAfterRecoveryPayment(ShoppingPayment $shopping_payment): void
{
$order = $shopping_payment->shopping_order;
if (! $order || ! $order->recovery_user_abo_id) {
return;
}
$userAbo = UserAbo::find($order->recovery_user_abo_id);
if (! $userAbo) {
return;
}
// Bereits verknüpft (z. B. Payone-Status-URL vor Checkout-Redirect) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return;
}
$aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval);
$isStripe = $shopping_payment->payment_provider === PaymentProvider::Stripe->value;
/*
* Stripe (Plan S4 / §7.2 Nr. 3): Das Abo zieht mit der Recovery-Zahlung auf
* Stripe um. Die IDs liefert der Webhook — fehlen sie noch (Browser-Return
* vor Webhook), NICHT fortschreiben: der Webhook-Paid-Pfad ruft diese
* Methode erneut auf und schreibt dann vollständig fort.
*/
if ($isStripe && (empty($shopping_payment->stripe_customer_id) || empty($shopping_payment->stripe_payment_method_id))) {
\Log::channel('stripe')->info('AboHelper::advanceAboAfterRecoveryPayment: Stripe-IDs noch nicht an der Zahlung — Fortschreibung dem Webhook überlassen.', [
'shopping_payment_id' => $shopping_payment->id,
'user_abo_id' => $userAbo->id,
]);
return;
}
// Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen,
// damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt).
$paymentTransaction = $shopping_payment->payment_transactions->last();
$payoneUserId = ! $isStripe && $paymentTransaction && (int) $paymentTransaction->userid > 0
? (int) $paymentTransaction->userid
: (int) $userAbo->payone_userid;
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId, $isStripe): void {
$userAbo->update([
'status' => 2,
'active' => true,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'payone_userid' => $payoneUserId,
'payment_provider' => $isStripe ? PaymentProvider::Stripe->value : $userAbo->payment_provider,
'stripe_customer_id' => $isStripe ? $shopping_payment->stripe_customer_id : $userAbo->stripe_customer_id,
'stripe_payment_method_id' => $isStripe ? $shopping_payment->stripe_payment_method_id : $userAbo->stripe_payment_method_id,
'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date,
'last_date' => now(),
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 1,
'paid' => true,
]);
if (! empty($shopping_payment->carddata)) {
$userAbo->card_changes()->create([
'masked_card' => UserAbo::formatMaskedCard($shopping_payment->carddata),
'source' => 'recovery-checkout',
]);
}
});
try {
$order->refresh();
if ($order->paid) {
IncentiveTracker::trackAboActivated($order);
}
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboHelper::advanceAboAfterRecoveryPayment: Incentive-Tracking fehlgeschlagen', [
'abo_id' => $userAbo->id,
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
AboOneTimeService::purgeAfterExecution($userAbo);
}
public static function createAboItems($user_abo, ShoppingPayment $shopping_payment)
{
foreach ($shopping_payment->shopping_order->shopping_order_items as $item) {
if ($item->is_abo_addon) {
continue;
}
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) {
if ($item->is_abo_addon) {
continue;
}
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 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 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<int, int|null> 12 Einträge (Index 0 = Jan), null = Zukunft
*/
public static function getMonthlyAboCounts(
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 = 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;
}
}