mivita/app/Services/AboHelper.php
Kevin cf0311afc1 Abo (A1+A2): Liefertag frei waehlbar 1-31, Aenderung nur nach vorn, Sperre erst am Ausfuehrungstag
A1: Jeder Monatstag ist als Liefertag erlaubt (UserAbo::allowedDeliveryDays),
Tage 29-31 liefern in kuerzeren Monaten am letzten Tag. Eine Stelle
AboHelper::dateForDayInMonth klammert; setNextDate, Erstausfuehrung,
Admin-Termin und Pause rechnen darueber, der Folgemonat immer aus dem
Liefertag (31.01 -> 28.02 -> 31.03). Liefertag-Aenderung gilt fuer den
laufenden Zyklus und nur nach vorn: ist der Tag im Referenzmonat vorbei,
wird abgelehnt, der Monat verschiebt sich nie (Rueckfrage R1).
Admin-Filter "naechste Ausfuehrung" als Datumsbereich. Prognose-Kacheln
bekommen die Kachel Monatsende, Team-Abos rechnen bis Monatsende.

A2: Sperrfristen Default 0, Setting 0 ist ausdruecklich 0 (fiel bisher auf
den Default zurueck). Bedingung Kalendertage <= Sperre, zentral in
AboHelper::isWithinLock: am Ausfuehrungstag ab 0:00 Uhr fixiert, Vortag bis
23:59 Uhr frei, fuer Liefertag, Produkte, Pause und Kuendigung (R2). Texte
mit _zero-Varianten ueber AboHelper::lockMessage.

Sprachdateien de/en/es/fr, neuer AboDeliveryDayTest, bestehende Tests
angepasst, Plan-Status mit Deploy-Schritt (Settings auf 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:28:55 +00:00

871 lines
33 KiB
PHP
Raw Permalink 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') {
return false;
}
return self::isWithinLock($userAbo, AboRepository::getLockDaysChange());
}
/**
* Kalendertage von heute bis zum naechsten Liefertermin (negativ, wenn der
* Termin liegen geblieben ist); null ohne Termin.
*/
public static function daysUntilNextDate(UserAbo $userAbo): ?int
{
$raw = $userAbo->getRawOriginal('next_date') ?: $userAbo->next_date;
if (! $raw) {
return null;
}
return (int) Carbon::today()->diffInDays(Carbon::parse($raw)->startOfDay(), false);
}
/**
* Sperre (Ticket A2): fixiert ab 0:00 Uhr des Tages `next_date - lockDays`.
* Mit lockDays = 0 ist das genau der Ausfuehrungstag — bis 23:59 Uhr des
* Vortags bleibt alles aenderbar. Ein liegen gebliebener Termin (negativ)
* sperrt nicht.
*/
public static function isWithinLock(UserAbo $userAbo, int $lockDays): bool
{
$daysUntil = self::daysUntilNextDate($userAbo);
return $daysUntil !== null && $daysUntil >= 0 && $daysUntil <= $lockDays;
}
/**
* Sperrtext fuer Aenderung / Pause / Kuendigung: bei Sperre 0 der Text
* "bis zum Vortag" (`abo.<key>_zero`), sonst der Text mit Vorlauf
* (`abo.<key>`, Platzhalter :lock, :days, :date).
*/
public static function lockMessage(string $key, UserAbo $userAbo, int $lockDays): string
{
$daysUntil = self::daysUntilNextDate($userAbo) ?? 0;
$date = $userAbo->getRawOriginal('next_date') ? Carbon::parse($userAbo->getRawOriginal('next_date'))->format(\Util::formatDateDB()) : '-';
return __('abo.'.$key.($lockDays <= 0 ? '_zero' : ''), ['lock' => $lockDays, 'days' => max(0, $daysUntil), 'date' => $date]);
}
/**
* Prüft, ob Pausieren/Kündigen aktuell durch die Sperrfrist vor der
* Ausführung blockiert ist (Setting `abo-lock-days-pause-cancel`). Admins
* umgehen die Sperre. Spiegelt die serverseitige Prüfung in
* {@see AboRepository::updateStatus()} bzw. `pauseOneMonthLocked()` für die
* UI, damit der Abschnitt "Abo pausieren oder kündigen" den Hinweis statt
* der Buttons zeigt.
*/
public static function isPauseCancelLocked(UserAbo $userAbo, string $view = 'user'): bool
{
if ($view === 'admin') {
return false;
}
return self::isWithinLock($userAbo, AboRepository::getLockDaysPauseCancel());
}
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 '';
}
/**
* Liefertermin fuer den gewuenschten Monatstag im Monat von $month, in
* kuerzeren Monaten der letzte Tag (31 im Februar => 28./29.). Einzige
* Stelle, die aus Tag + Monat ein Datum macht (Ticket A1).
*/
public static function dateForDayInMonth(Carbon|string $month, int $day): Carbon
{
$first = Carbon::parse($month)->startOfMonth()->startOfDay();
return $first->day(max(1, min($day, $first->daysInMonth)));
}
/**
* Naechster Liefertermin nach $date fuer den Monatstag $abo_interval: im
* Monat von $date, wenn dieser Termin noch nach $date liegt, sonst im
* Folgemonat. Der Folgemonat wird aus dem Monatstag gerechnet, nicht aus dem
* (ggf. geklammerten) Vortermin — ein 31er-Abo bleibt nach dem Februar nicht
* auf dem 28.
*/
public static function setNextDate($date, $abo_interval)
{
$reference = Carbon::parse($date);
$nextDate = self::dateForDayInMonth($reference, (int) $abo_interval);
return $nextDate->gt($reference)
? $nextDate
: self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), (int) $abo_interval);
}
/**
* 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 = self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), $aboDayOfMonth);
if (! $nextDate->gt($reference)) {
$nextDate = self::advanceAboCandidateOneMonth($nextDate, $aboDayOfMonth);
}
return $nextDate;
}
/**
* Gleicher Liefertag im Folgemonat (Monatsende beachten).
*/
private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon
{
return self::dateForDayInMonth($candidate->copy()->startOfMonth()->addMonth(), $aboDayOfMonth);
}
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 {
// War das Abo deaktiviert, ist das hier eine Reaktivierung. Der
// Protokolleintrag entsteht in derselben Transaktion am Model
// ({@see UserAbo::logStatusTransition()}) — ohne ihn verschwaende das Abo
// aus der historischen Bestandsrueckrechnung.
$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;
}
}