497 lines
17 KiB
PHP
497 lines
17 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Product;
|
||
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\Services\Incentive\IncentiveTracker;
|
||
use App\User;
|
||
use Carbon\Carbon;
|
||
|
||
class AboHelper
|
||
{
|
||
/**
|
||
* Mindestabstand (Kalendertage) vom Bestell-/Referenzdatum bis zur ersten Abo-Ausführung.
|
||
*/
|
||
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
|
||
|
||
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)->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 '<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);
|
||
}
|
||
|
||
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<int, int|null> 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;
|
||
}
|
||
}
|